朝の30分を、設定アプリの往復に溶かしていました。
一覧画面を暗くして確認、設定を開いてライトに戻す、詳細画面を暗くして確認、また戻す。壁紙アプリのように画面のほとんどが画像で埋まるアプリだと、背景色の差が出る場所は少ないのに、確認そのものは全画面ぶん必要になります。1画面あたり10秒の切り替えでも、20画面で往復すれば7分近くが移動時間です。個人開発で本数を抱えていると、この7分がアプリの本数ぶん積み上がります。
Expo SDK 57 に上げたあと、React Native DevTools にライトモードとダークモードをエミュレートする切り替えが入っていることに気づきました。端末の設定を触らずにトグル1つで切り替わります。
正直に言えば、最初は「これで全部片づく」と思いました。実際には片づかない場所が残りました。ただ、残った場所がどこかを一度きちんと線引きしたことで、確認の手順がかなり短くなりました。その線引きを書き残しておきます。
エミュレーションが差し替えているのは「JS が報告する値」です
DevTools のトグルが何をしているのかを理解しておくと、あとの判断が速くなります。
React Native には Appearance モジュールがあり、Appearance.setColorScheme() で JS 側が報告する配色を上書きできます。DevTools のエミュレーションは、この上書きを開発ツールから叩けるようにしたものと考えると筋が通ります。
つまり、useColorScheme() の戻り値を見て色を決めているコンポーネントは、すべて切り替わります。
import { useColorScheme, View, Text } from 'react-native';
export function Card({ title }: { title: string }) {
const scheme = useColorScheme(); // 'light' | 'dark' | null
const isDark = scheme === 'dark';
return (
<View style={{ backgroundColor: isDark ? '#16181d' : '#ffffff' }}>
<Text style={{ color: isDark ? '#e8eaed' : '#1f2328' }}>{title}</Text>
</View>
);
}このコードは DevTools のトグルで完全に追従します。NativeWind の dark: 修飾子も、内部で同じ配色ソースを読んでいるので同様です。
逆に言えば、OS の配色設定をネイティブ側で直接読んでいるものは追従しません。JS が「今はダークです」と申告しても、UIKit や Android のリソース解決は元の端末設定のまま動き続けます。
ここが境界です。手元で最初に混乱したのも、この2つが同じ画面に同居していたからでした。
出所を1画面に並べて、自分のプロジェクトで境界を確かめる
仕組みの説明を信じるより、自分のプロジェクトとバージョンで一度確かめたほうが確実です。配色の申告先を3つ並べて表示するだけの診断画面を作りました。
import { useEffect, useState } from 'react';
import { Appearance, useColorScheme, View, Text, Platform } from 'react-native';
import { WebView } from 'react-native-webview';
const PROBE_HTML = `
<html><body style="margin:0;font:16px -apple-system,system-ui">
<div id="out" style="padding:12px"></div>
<script>
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.getElementById('out').textContent = 'WebView: ' + (dark ? 'dark' : 'light');
document.body.style.background = dark ? '#16181d' : '#ffffff';
document.body.style.color = dark ? '#e8eaed' : '#1f2328';
</script>
</body></html>`;
export function ColorSchemeProbe() {
const hookScheme = useColorScheme();
const [snapshot, setSnapshot] = useState(Appearance.getColorScheme());
useEffect(() => {
const sub = Appearance.addChangeListener(({ colorScheme }) => {
setSnapshot(colorScheme);
});
return () => sub.remove();
}, []);
return (
<View style={{ gap: 8, padding: 16 }}>
<Text>useColorScheme(): {String(hookScheme)}</Text>
<Text>Appearance.getColorScheme(): {String(snapshot)}</Text>
<Text>Platform: {Platform.OS}</Text>
<WebView source={{ html: PROBE_HTML }} style={{ height: 64 }} />
</View>
);
}この画面を開いたまま DevTools のトグルを操作します。上の2行が切り替わり、WebView の行が動かなければ、境界は想定どおりです。もし WebView まで追従したなら、そのバージョンでは私の前提が古いということですので、以降の4か所は改めて確認してください。
確認は1分で終わりますし、SDK を上げるたびに走らせる価値があります。バージョン間の挙動差を人づてに信じるより、手元の1画面のほうが速くて確実です。
エミュレーションでは動かない4か所
診断画面で境界を確認したうえで、実機の設定を切り替えないと見えない場所を整理しました。
| 場所 | 配色の決まり方 | 確認の手段 |
|---|---|---|
| スプラッシュ画面 | アプリ起動前に OS がネイティブリソースから描画 | 端末設定を切り替えてコールドスタート |
| ネイティブアラート・キーボード | UIKit / Android のトレイトに従う | 端末設定を切り替えて該当画面を開く |
| WebView 内の prefers-color-scheme | WebView 自身が OS 設定を参照 | 端末設定の切り替え、または明示的な注入 |
| Android のシステムバー背景 | styles.xml と edge-to-edge の設定 | 端末設定を切り替えて実機で目視 |
このうち WebView は明示的に渡してしまうのが実務的でした。OS の設定に任せると、アプリ本体だけがダークで WebView だけが白い、という不揃いが起きます。利用規約やヘルプを WebView で出しているアプリでは目立ちます。
const scheme = useColorScheme() ?? 'light';
<WebView
source={{ uri: 'https://example.com/help' }}
injectedJavaScriptBeforeContentLoaded={`
document.documentElement.dataset.scheme = ${JSON.stringify(scheme)};
true;
`}
/>受け側の CSS を prefers-color-scheme ではなく [data-scheme="dark"] で書いておけば、アプリ側の配色ソース1つに統一できます。DevTools のトグルでも追従するようになるので、確認漏れの箇所そのものが1つ減ります。
ネイティブのキーボードは、少しだけ手当ができます。TextInput の keyboardAppearance を配色ソースから渡しておけば、iOS のキーボードだけは JS 側の判断に寄せられます。
const scheme = useColorScheme() ?? 'light';
<TextInput
placeholder="検索"
keyboardAppearance={scheme === 'dark' ? 'dark' : 'light'}
/>検索欄のあるアプリでは、ここが揃っていないと入力のたびに違和感が出ます。逆に Alert.alert() のようなネイティブのダイアログには同種の指定がありませんので、そこは実機の切り替えで見るしかありません。見る対象を「直せるもの」と「見るしかないもの」に分けておくと、確認の時間配分が決めやすくなります。
スプラッシュ画面については、そもそも配色を分けない選択も現実的です。ブランドカラー1色で塗ってしまえば、ライトとダークの差を確認する対象から外れます。私は壁紙アプリでこの方針を取っていて、起動直後のちらつきに悩む時間がなくなりました。
Android のシステムバーは、SDK 57 に edge-to-edge まわりの修正が入っているため、上げた直後に一度は実機で見ておくことをおすすめします。ネイティブ設定を触っている場合は、Expo SDK 57 に上げる前に、prebuild で消えるネイティブ変更を洗い出すのほうを先に済ませておくと、原因の切り分けが楽になります。
リリース前の確認手順を書き直しました
線引きが済んだあと、App Store と Google Play へ提出する前に必ず通す手順として、こう固定しました。
DevTools でまとめて見る(全画面・切り替えは1回)
アプリを起動したまま、トグルをダークにして全画面を通します。この段階で見ているのは、JS 側の配色ソースを読んでいる部分だけです。文字色のコントラスト不足、影の見え方、区切り線が背景に溶ける箇所。私自身の感覚では、ここで見つかる不具合が全体の8割ほどでした。
端末設定を切り替えて実機で見る(4か所のみ)
そのうえで端末をダークにし、コールドスタート、アラートを1つ、WebView を含む画面を1つ、Android ならシステムバーを含む画面を1つ。4か所だけを見ます。全画面を往復していた頃に比べて、確認そのものが数分で終わるようになりました。
切り替え直後の再描画を1回だけ確認
エミュレーションでも実機でも、切り替えた瞬間に再描画が走ります。ここで白画面が挟まる、状態が飛ぶ、といった症状が出るなら別の問題です。テーマ切り替えでプロセスが再起動する構成にしている場合は、テーマ切替の白画面を recreate() なしで直すで扱った内容が近いはずです。
手順が短くなると、確認の頻度が上がります
この記事で書いたことは、機能そのものの目新しさより、確認の摩擦が下がったという話に近いかもしれません。
ただ、7分かかる確認は「今回は前回から変えていないから飛ばそう」と思いますが、1分の確認は毎回走らせられます。頻度が上がったぶん、ダークモード起因の不具合が本番へ抜ける回数は確実に減りました。
次の一歩として、まずは診断画面を1つプロジェクトに置いてみてください。自分のバージョンでの境界が5分でわかりますし、そのあとの確認手順を自分の手で短くできます。
配色と同じく「実機でしか本当のところがわからない」領域として、VoiceOver と Dynamic Type の扱いをRork で VoiceOver と Dynamic Type を本番品質に整える実装メモにまとめてあります。あわせて手を入れると、設定まわりの確認をひとまとめにできます。
お読みいただきありがとうございました。