iPhone SE の実機を手に取って、画面の中央で止まりました。
壁紙アプリの名言カード。二行目が「、深い呼吸をひとつ。」から始まっています。読点が行頭に落ちていました。
Rork に「引用文をカード中央に大きく表示して」と頼んで生成された画面です。コードは正しく動いています。レイアウトも崩れていません。ただ、日本語として組まれていない。
シミュレータの iPhone 15 Pro では起きませんでした。幅が違うと折り返し位置が変わるので、たまたま見えていなかっただけです。
React Native の Text は禁則を保証していない
React Native の Text は、行分割を自分では実装していません。iOS では TextKit / CoreText に、Android では android.text.LineBreaker に、それぞれ委譲しています。
つまり折り返しの品質は、プラットフォームとロケール設定に依存します。ここが厄介でした。
環境 行頭禁則の扱い 私が観測した挙動
iOS(既定) CoreText がある程度考慮 「、」「。」は概ね回避。ただし長音符「ー」や小書き仮名「っ」は行頭に落ちる
iOS(lineBreakStrategyIOS="push-out") 押し出し方式で回避 違反は大きく減るが、行数が増える場合がある
Android(既定 simple) ほぼ考慮しない 読点・句点・閉じ括弧が行頭に落ちる
Android(textBreakStrategy="balanced") 行長を均す方針 見た目は整うが禁則そのものは保証されない
「Android では日本語ロケールなら禁則が効くはず」と考えていました。実際には、端末の言語設定ではなく、テキストに紐づくロケール情報で判定されます。アプリ内で日本語を表示していても、端末が英語設定なら期待した処理は走りません。
私のアプリは16言語に対応しています。ユーザーの端末言語は日本語とは限らない。この前提を見落としていました。
まず違反率を測りました
感覚で直すと、直った気になるだけで終わります。数字を取りました。
計測対象は、癒し系・引き寄せ系を含む壁紙アプリ6本で使っている名言テキスト120本。デバイス幅は 320 / 375 / 393 / 430 の4種です。
onTextLayout は、レイアウト確定後の各行を返してくれます。行の先頭文字を見れば、違反はそのまま判定できます。
// KinsokuAudit.tsx — 実行時に行頭禁則違反を検出する
import { Text, type TextLayoutEventData, type NativeSyntheticEvent } from "react-native" ;
// 行頭に来てはいけない文字(約物・小書き仮名・長音符・繰り返し記号)
const LINE_START_FORBIDDEN =
"、。,.・:;?!ヽヾゝゞ々ー’”)〕]}〉》」』】…‥" +
"ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ" ;
type Violation = { lineIndex : number ; head : string ; lineText : string };
export function auditLines (
e : NativeSyntheticEvent < TextLayoutEventData >
) : Violation [] {
const lines = e.nativeEvent.lines;
const out : Violation [] = [];
// 1行目は「行頭」ではあっても折り返し起因ではないので除外する
for ( let i = 1 ; i < lines. length ; i ++ ) {
const text = lines[i].text ?? "" ;
const head = Array. from (text)[ 0 ];
if ( ! head) continue ;
if ( LINE_START_FORBIDDEN . includes (head)) {
out. push ({ lineIndex: i, head, lineText: text });
}
}
return out;
}
export function QuoteCard ({ quote , onAudit } : { quote : string ; onAudit ?: ( v : Violation []) => void }) {
return (
< Text
style = { { fontSize: 22 , lineHeight: 36 } }
onTextLayout = { ( e ) => onAudit ?.( auditLines (e)) }
>
{ quote }
</ Text >
);
}
1行目を除外している理由は、折り返しで生じた行頭だけを数えたいからです。原文が読点で始まることは通常ありませんが、除外しておくほうが数字の意味が明確になります。
これを4つの固定幅コンテナに並べて、120本を流しました。結果です。
条件 違反行数 / 総折り返し行 (iOS) 違反行数 / 総折り返し行 (Android)
素の Text(既定) 31 / 664(4.7%) 96 / 671(14.3%)
lineBreakStrategyIOS="push-out" のみ9 / 682(1.3%) 96 / 671(14.3%)
textBreakStrategy="balanced" のみ31 / 664(4.7%) 88 / 669(13.2%)
事前結合(後述) 0 / 689(0%) 0 / 690(0%)
Android の14.3%が効きました。7行に1行の割合で、日本語として不自然な折り返しが出ていたことになります。
iOS の push-out は効果が大きい一方で、残った9件は長音符と小書き仮名でした。プラットフォームの判断に委ねると、そこまでは面倒を見てくれません。
WORD JOINER で折り返し位置そのものを封じる
プラットフォームの設定を頼るのをやめました。禁則が必要な箇所に、改行してはいけないという情報を文字列自身へ埋め込む方針に変えています。
使うのは U+2060 WORD JOINER です。幅ゼロで、その位置での改行を禁止します。
よく使われる U+200B ZERO WIDTH SPACE は逆の意味(改行してよい位置)なので、ここでは誤りになります。また、 (U+00A0)は幅を持ってしまうため、日本語の約物の前に入れると字間が空きます。
// kinsoku.ts
const WJ = "" ; // WORD JOINER: 幅ゼロ・改行禁止
const LINE_START_FORBIDDEN =
"、。,.・:;?!ヽヾゝゞ々ー’”)〕]}〉》」』】…‥" +
"ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ" ;
const LINE_END_FORBIDDEN = "‘“(〔[{〈《「『【" ;
// 結合が長く続くと1語扱いになり、はみ出しや極端な行長を生む。上限を設ける
const MAX_BIND_RUN = 4 ;
export function applyKinsoku ( input : string ) : string {
const chars = Array. from (input); // サロゲートペアを壊さない
let out = "" ;
let run = 0 ;
for ( let i = 0 ; i < chars. length ; i ++ ) {
const cur = chars[i];
const next = chars[i + 1 ];
out += cur;
if (next === undefined ) break ;
if (cur === " \n " || next === " \n " ) { run = 0 ; continue ; }
const bind =
LINE_START_FORBIDDEN . includes (next) || LINE_END_FORBIDDEN . includes (cur);
if (bind && run < MAX_BIND_RUN ) {
out += WJ ;
run += 1 ;
} else {
run = 0 ;
}
}
return out;
}
MAX_BIND_RUN は実測から入れました。上限なしで実装したとき、「……」「!?」「』」が連続する箇所で7文字が1語として扱われ、狭い幅ではみ出しました。4に置いたのは、120本のうち最長の禁則連続が3文字だったためです。
Array.from を使っているのは、絵文字や異体字セレクタを含む引用文でインデックスが壊れないようにするためです。for (let i = 0; i < input.length; i++) にすると、サロゲートペアの途中に WORD JOINER が入り、文字が化けます。これは実際に一度やりました。
コピペと読み上げを壊さないための一手間
ここが一番の落とし穴でした。
WORD JOINER は不可視です。ユーザーがカードの文言を長押しコピーして SNS に貼ると、見えない文字が付いていきます。多くのアプリでは無害ですが、検索フィールドに貼られると一致しなくなります。
さらに VoiceOver は、埋め込んだ文字列をそのまま解釈しようとします。表示用と意味用を分けるのが正解でした。
// QuoteCard.tsx
import { Text } from "react-native" ;
import { applyKinsoku } from "./kinsoku" ;
export function QuoteCard ({ quote } : { quote : string }) {
return (
< Text
style = { { fontSize: 22 , lineHeight: 36 } }
lineBreakStrategyIOS = "push-out" // 事前結合の取りこぼしを拾う保険
textBreakStrategy = "balanced" // Android 側の行長を均す
accessibilityLabel = { quote } // 読み上げには原文をそのまま渡す
>
{ applyKinsoku (quote) }
</ Text >
);
}
accessibilityLabel に原文を渡す。この一行を入れるまで、VoiceOver の読み上げに不自然な間が入っていました。
コピー機能を自前で持っている場合は、Clipboard.setStringAsync(quote) のように原文を渡します。表示している文字列を渡してはいけません。
共有画像を生成している箇所も同じです。私のアプリでは react-native-view-shot でカードを画像化していますが、画像化の経路は表示文字列のままで問題ありません。テキストとして外へ出る経路だけを原文に戻す、という切り分けです。
いつ適用するか — 実行時か、ビルド時か
applyKinsoku は文字列長に比例するだけの処理です。120文字程度なら計測上0.1ms未満で、実行時に呼んで支障はありませんでした。
それでもビルド時に寄せた理由が2つあります。
ひとつは、名言データが JSON で配信されていて、端末側のロジック更新には App Store と Google Play の審査が挟まること。禁則テーブルの一文字を直したいだけで審査を待つのは、避けたいところでした。
もうひとつは、監査を CI に置けることです。配信前に違反ゼロを確認できれば、実機で気付く事態そのものが消えます。
// scripts/kinsoku-build.mjs — 配信前にJSONへ適用し、結果を検証する
import { readFileSync, writeFileSync } from "node:fs" ;
import { applyKinsoku } from "../src/text/kinsoku.js" ;
const WJ = "" ;
const src = JSON . parse ( readFileSync ( "data/quotes.ja.json" , "utf8" ));
const out = src. map (( q ) => ({
... q,
// 表示用と原文を両方持たせる。原文はコピー・読み上げ・検索に使う
bodyRaw: q.body,
body: applyKinsoku (q.body),
}));
// 冪等性の確認: 二重適用でWJが増えないこと
const doubled = out. filter (( q ) => applyKinsoku (q.body) !== q.body);
if (doubled. length > 0 ) {
throw new Error ( `二重適用で差分が出ました: ${ doubled . length }件` );
}
// サロゲートペア破壊の検出
const broken = out. filter (( q ) => q.body. replaceAll ( WJ , "" ) !== q.bodyRaw);
if (broken. length > 0 ) {
throw new Error ( `WJ除去で原文に戻りません: ${ broken . length }件` );
}
writeFileSync ( "data/quotes.ja.json" , JSON . stringify (out, null , 2 ));
console. log ( `適用: ${ out . length }件` );
冪等性のチェックは、実際に事故を防いでくれました。パイプラインを組み直した際にスクリプトが二度走り、WORD JOINER が重なった状態のデータが出来ています。表示は変わらないので気付けません。「二重適用で差分が出ないこと」を条件にすれば、そこで止まります。
body.replaceAll(WJ, "") === bodyRaw の検証も同様です。これが通れば、少なくとも原文は無傷だと言い切れます。
Rork Max(SwiftUI 生成)ではどうなるか
Rork Max の生成コードでも同じカードを組んでみました。
SwiftUI の Text は TextKit 2 の経路を通るため、React Native よりも素直に禁則が効きます。読点・句点・閉じ括弧はほぼ行頭に落ちません。
ただし完全ではありませんでした。長音符「ー」は同じように落ちます。押し出しを明示すると解消します。
// QuoteText.swift — 段落スタイルで押し出しを明示する
import SwiftUI
struct QuoteText : View {
let quote: String
var body: some View {
Text (attributed)
. font (. system ( size : 22 ))
. lineSpacing ( 14 )
. accessibilityLabel (quote) // 読み上げは原文
}
private var attributed: AttributedString {
var a = AttributedString (quote)
let style = NSMutableParagraphStyle ()
style.lineBreakStrategy = .pushOut // 押し出しで禁則を回避
style.lineBreakMode = .byWordWrapping
a.paragraphStyle = style
return a
}
}
lineBreakStrategy = .pushOut は、禁則対象が行頭に来る場合に前の文字ごと次行へ送ります。行数が増えることがあるので、行数固定のカードでは minimumScaleFactor との併用を検討する必要があります。
React Native 版と Rork Max 版で挙動が違う。この差は、両方を運用する場合に効いてきます。
観点 React Native(Rork 通常版) SwiftUI(Rork Max)
既定の禁則 iOS は部分的・Android はほぼ無し 概ね機能する
長音符・小書き仮名 行頭に落ちる 長音符は落ちる場合がある
プラットフォーム差 iOS と Android で結果が異なる Apple 系のみのため差が出ない
推奨する対処 事前結合 + 属性指定の二段構え .pushOut 指定で概ね足りる
Rork Max を使うなら禁則の面倒は減ります。React Native 版を選ぶなら、文字列側で解く前提を最初から持っておくほうが安全でした。
段階的に入れた手順
6本のアプリへ一度に入れるのは避けました。個人開発で並行運用していると、判断を誤ったときの巻き戻しが6回分になります。順序はこうです。
監査だけを先に入れる (onTextLayout で違反を計測し、開発ビルドでのみ console.warn)。数字が出るまで実装しない
1本のアプリ・日本語のみに事前結合を適用 し、実機4幅で目視確認する
CI に冪等性と原文一致の検証を追加 する。ここを飛ばすと二重適用に気付けない
accessibilityLabel とコピー経路を原文へ戻す 。VoiceOver で1本読み上げて確認する
残り5本へ展開 し、MAX_BIND_RUN の上限に触れる文言がないか監査ログで見る
3番を2番より後に置いたのは順序として失敗でした。適用を先にしたため、二重適用のデータを一度配信しています。表示は正常なので、監査を書いてから初めて気付きました。検証を先に置いてから適用するほうが確実です。
中国語・韓国語を含む場合
私のアプリは16言語対応なので、この点も確認しました。
禁則テーブルは言語ごとに違います。日本語のテーブルを中国語へそのまま当てると、簡体字の約物は全角の位置調整が別ルールのため、意図しない結合が起きます。
言語コードで分岐して、日本語以外は素通しにしました。
export function applyKinsokuFor ( locale : string , input : string ) : string {
// 日本語以外は各プラットフォームの既定に委ねる
if ( ! locale. startsWith ( "ja" )) return input;
return applyKinsoku (input);
}
韓国語は単語間にスペースがあるため、そもそも折り返し位置の問題が起きにくい構造でした。中国語は独自テーブルが必要ですが、私のアプリでは中国語圏の名言が短文中心で違反が観測されなかったため、今回は対象外としています。必要になったら測ってから足す方針です。
多言語の扱い全般については、翻訳の抜けとフォールバック設計 にまとめています。右横書きの言語を含む場合はアラビア語対応で踏んだ RTL の落とし穴 のほうが近いはずです。
生成されたコードの、その先
Rork が組んだ画面は正しく動いていました。バグではありません。
日本語として読めるかどうかは、フレームワークの守備範囲の外にあります。動くことと、読めること。この二つは別の基準です。
生成されたコードをそのまま出すか、一度日本語の目で見るか。その差が、名言カードのような「文字そのものが商品」の画面では大きく出ます。
まず onTextLayout の監査を1本入れて、自分のアプリの違反率を測ってみてください。数字が出れば、直すかどうかは自然に決まります。
私自身、iPhone SE の実機が手元になければ、しばらく気付かないままでした。実装の参考になれば幸いです。