アラビア語を話す利用者から「戻るボタンが画面の左にあって押しにくい」という連絡をもらったのは、壁紙アプリにアラビア語のメタデータを足して数日後のことでした。文言は確かにアラビア語になっています。けれどレイアウトはそのまま左から右のままで、本来は右上にあるべき戻る矢印が左上に居座っていました。文言だけ翻訳して RTL(右から左)のレイアウト反転を忘れていたのです。
やっかいなのは、コードで I18nManager.forceRTL(true) を呼んでもその場では何も変わらないことでした。ログには反映済みと出るのに、画面は左から右のまま。私自身、最初はコードが間違っているのかと小一時間コードを見つめていました。原因はもっと単純で、React Native の RTL はネイティブ側のレイアウトエンジンに渡るため、アプリを作り直さない限り切り替わらないという仕様だったのです。
ここでは Rork が出力する Expo アプリを題材に、その再起動の壁をどう越えるか、そして「文言は翻訳できたのにレイアウトだけ壊れる」を仕組みで防ぐ設計を、個人開発で実際に踏んだ落とし穴とあわせて共有します。
鏡像化は自動、でも有効化は手動
最初に押さえておきたいのは、RTL が有効になりさえすれば flexDirection: 'row' のような並びは React Native が自動で左右反転してくれる、という点です。手で全画面を組み替える必要はありません。問題は「有効にする」操作のほうにあります。
RTL の有効・無効はネイティブのビュー階層に焼き込まれる設定で、JavaScript 実行中に動的に切り替えても、既に組み上がった画面には適用されません。I18nManager には次の2つがあります。
| API | 役割 | 反映タイミング |
I18nManager.allowRTL(bool) | RTL を許可するかどうかの土台。これが false だと forceRTL も効かない | 次回起動時 |
I18nManager.forceRTL(bool) | 実際に RTL レイアウトを強制する | 次回起動時(=アプリ再構築が必要) |
どちらも「次回起動時」というのが肝です。つまり呼んだ直後にユーザーへ見せたいなら、こちらから作り直しを起こす必要があります。Expo なら expo-updates の Updates.reloadAsync() が一番確実です。
端末の言語方向を読んで、初回起動の向きを決める
まず、端末がアラビア語やヘブライ語のような RTL 言語に設定されているかを判定します。expo-localization の getLocales() は各ロケールに textDirection を返すので、これを使うのが正確です。言語コードを自前で RTL 判定するより、OS の判断に乗るほうが取りこぼしがありません。
// lib/rtl.ts
import * as Localization from 'expo-localization';
import { I18nManager } from 'react-native';
import * as Updates from 'expo-updates';
// 端末の最優先ロケールが RTL かどうか
export function deviceWantsRTL(): boolean {
const [primary] = Localization.getLocales();
return primary?.textDirection === 'rtl';
}
// アプリ起動時に一度だけ呼ぶ。向きが食い違っていたら作り直す
export async function syncLayoutDirection(): Promise<void> {
const wantsRTL = deviceWantsRTL();
// 既に望む向きなら何もしない(無限リロード防止)
if (I18nManager.isRTL === wantsRTL) return;
I18nManager.allowRTL(wantsRTL);
I18nManager.forceRTL(wantsRTL);
// 開発中の Fast Refresh では reload が効かないことがあるためガード
if (!__DEV__) {
await Updates.reloadAsync();
}
}
if (I18nManager.isRTL === wantsRTL) return; のガードが地味ですが重要です。これを忘れると、起動するたびに「向きが違う→reload→また起動→…」と無限ループに入ります。私は最初このガードを書かず、シミュレータが延々と再起動を繰り返すのを眺めることになりました。
呼び出しはルートで、UI を描く前に行います。
// app/_layout.tsx(Expo Router)
import { useEffect, useState } from 'react';
import { syncLayoutDirection } from '../lib/rtl';
export default function RootLayout() {
const [ready, setReady] = useState(false);
useEffect(() => {
syncLayoutDirection().finally(() => setReady(true));
}, []);
if (!ready) return null; // reload が走る場合はここで止まる
return <Stack /* ... */ />;
}
app.json 側でも RTL を許可しておきます。Expo の場合、ビルド時に allowRTL を入れておくと初回から安定します。
{
"expo": {
"extra": { "supportsRTL": true },
"ios": { "infoPlist": { "CFBundleAllowMixedLocalizations": true } }
}
}
アプリ内で言語を切り替えたら、確実に作り直す
端末設定とは別に、アプリ内に言語切り替えを持たせている場合、その場で forceRTL を呼んでも見た目は変わりません。ユーザーには「設定したのに反映されない」と映ります。ここは正直に reload を起こすのが一番親切でした。
// lib/rtl.ts(続き)
export async function applyLanguage(lang: string, isRTL: boolean): Promise<void> {
await saveLanguagePreference(lang); // AsyncStorage 等に保存
if (I18nManager.isRTL !== isRTL) {
I18nManager.allowRTL(isRTL);
I18nManager.forceRTL(isRTL);
// 向きが変わるときだけ作り直す
await Updates.reloadAsync();
return;
}
// 向きが同じ言語間(英語→フランス語など)は reload 不要
}
向きが変わらない言語切り替え(英語からフランス語など)では reload を起こさないのがポイントです。毎回作り直すと体感が重くなるので、I18nManager.isRTL !== isRTL の差分があるときだけに絞ります。切り替え直前に「アプリを再起動して言語を適用します」と一言出しておくと、突然の再起動に利用者が驚きません。
レイアウトが崩れる本当の原因は left/right の直書き
flexDirection: 'row' は自動反転する一方で、marginLeft や left: のような物理方向の指定は反転しません。RTL にしたとき右に寄せたかったボタンが左に残るのは、たいていこれが原因です。解決は logical property(始端・終端ベース)への置き換えです。
| 避けたい(物理方向・反転しない) | 使う(論理方向・自動反転する) |
marginLeft / marginRight | marginStart / marginEnd |
paddingLeft / paddingRight | paddingStart / paddingEnd |
left / right(position) | start / end |
textAlign: 'left' | textAlign: 'auto' |
特に痛かったのが、全画面の壁紙ビューアの閉じるボタンでした。右上に絶対配置していたのですが、right: 16 と書いていたため RTL でも右上のまま。アラビア語の利用者にとっては「閉じる」が直感に反する側にあったわけです。end: 16 に直すだけで、LTR では右上・RTL では左上へ自然に移動するようになりました。
// Before(RTL でも右上に固定されてしまう)
const styles = StyleSheet.create({
closeButton: { position: 'absolute', top: 16, right: 16 },
label: { textAlign: 'left', marginLeft: 12 },
});
// After(向きに追従する)
const styles = StyleSheet.create({
closeButton: { position: 'absolute', top: 16, end: 16 },
label: { textAlign: 'auto', marginStart: 12 },
});
textAlign: 'auto' は文章の言語に合わせて寄せ方を決めてくれるので、英語は左寄せ・アラビア語は右寄せと、こちらが分岐を書かなくても整います。
矢印とチェブロンだけを鏡像化する
ここで一つ落とし穴があります。レイアウトは反転しても、アイコンの絵柄そのものは反転しません。戻る矢印(←)は LTR では「左へ戻る」を意味しますが、RTL では進行方向が逆なので「→」で表現すべきです。これは自動では起きないため、向き依存のアイコンだけを明示的に鏡像化します。
import { I18nManager } from 'react-native';
function DirectionalIcon({ name, ...props }) {
// 向きで意味が変わるアイコンだけ反転
const mirror = I18nManager.isRTL ? { transform: [{ scaleX: -1 }] } : undefined;
return <Icon name={name} style={mirror} {...props} />;
}
反転すべきは「戻る・進む・送信(紙飛行機)・インデント・順序を示す矢印」など、向きが意味を持つものだけです。チェックマーク・歯車・ハート・再生ボタンのような左右対称または向きが無意味なアイコンは反転してはいけません。再生ボタンの三角を反転させてしまうと、RTL の利用者には早戻しに見えてしまいます。私はここを雑に「全アイコン反転」でやってしまい、設定の歯車まで裏返って一度作り直しました。
壁紙そのものは反転させない — UIだけRTLにする線引き
壁紙や癒し系のアプリで RTL 対応をするとき、最も大事な判断が「何を反転し、何を反転しないか」です。ナビゲーション・テキスト・ボタンといった UI シャシーは反転すべきですが、ユーザーが鑑賞するコンテンツ(壁紙画像、写真、ロゴ、QRコード)は絶対に反転してはいけません。鏡像化された壁紙は単純に壊れた画像ですし、反転したロゴはブランド毀損です。
幸い React Native は画像の中身を反転しないので、画像は放っておけば安全です。問題はグリッドの「並び順」のほうです。サムネイルを格子状に並べるとき、RTL では並びが右上始まりになります。これは多くの場合むしろ自然で、無理に LTR の並びへ戻す必要はありません。ただし「新着」「人気」のように順序に意味があるリストでは、先頭が右に来ることを前提に番号やラベルの位置を確認しておきます。
実装としては、コンテンツ表示部だけ向きを固定したいケースもあります。その場合は親に明示的な direction を与えます。
// 画像プレビュー領域だけは常に LTR の座標系で扱いたいとき
<View style={{ direction: 'ltr' }}>
<ZoomableImage source={wallpaper} />
{/* ズーム倍率や座標計算が RTL で符号反転しないように固定 */}
</View>
私の場合、ピンチズームの座標計算が RTL で符号反転して画像が思わぬ方向へ飛ぶ不具合があり、プレビュー領域だけ direction: 'ltr' に固定して回避しました。UI 全体は RTL、コンテンツの座標系だけ LTR、という二層構造です。
テストと段階公開で確かめる
RTL は端末をアラビア語に切り替えれば実機で確認できますが、毎回 OS 設定を変えるのは面倒です。開発中は強制フラグで素早く切り替えると効率的です。
// 開発時だけ RTL を強制してスクリーンショットを撮る
if (__DEV__ && process.env.EXPO_PUBLIC_FORCE_RTL === '1') {
I18nManager.allowRTL(true);
I18nManager.forceRTL(true);
}
確認すべき箇所は、戻る/閉じるボタンの位置、リストの行内アイコンの向き、絶対配置要素(フローティングボタン・バッジ・チェックマーク)、そして文章中に英数字が混じったときの折り返しです。アラビア語に価格や型番が混じると、数字部分だけ LTR になる双方向テキストになり、ここで崩れることがよくあります。
本番反映は AdMob のバナー位置がずれていないかも含め、一気に全ユーザーへ出さず段階公開で確かめると安全です。私は壁紙・癒し・引き寄せ系のアプリを複数運用していますが、RTL 化のような土台に効く変更は 5% から始めてクラッシュ率を見ながら広げるようにしています。レイアウトの反転は思わぬ画面で破綻しがちなので、最初の数パーセントの利用者の挙動が何よりの検証材料になります。
次の一歩
まずは自分のアプリの marginLeft・right:・textAlign: 'left' を grep で洗い出し、logical property へ置き換えるところから始めてみてください。RTL を完全対応する前でも、この置き換えだけで LTR の挙動は一切変わらず、将来の RTL 対応の地ならしになります。向きに追従するレイアウトは、結果として LTR 側のコードもより素直になります。