React Native でダークモードの実装をした

📆September 19, 2021🔖 React NativeFast Notion

拙作アプリ Fast Notion でダークモードの実装をしたので、その覚書です。色々ググりながら設計方針をウンウン悩みつつ考えて最終的にどんな感じの実装になったのかを書き残しておきたいと思います。ググってみると、日本語の検索結果だと情報が古いものも沢山ヒットしたので、このあたりの情報の整理もこのブログでしておきたいと思います。

TL;DR

  • React Native の最新バージョンなら、ライブラリは使用しなくても実現可能だった
  • Context を使用してルートコンポーネントからテーマ(ライト or ダーク)と変更関数を配るようにした
  • 各コンポーネントで Context 経由で受け取ったテーマをもとに色の出し分け実装をした
  • ライブラリは使用しない

    ググると、React Native でダークモードを実現できそうな雰囲気のライブラリや記事がいくつかヒットしたりします。端末のダークモード設定を JavaScript の世界に持ってくる部分をやってくれているライブラリなのですが、ライブラリを使わずとも React Native の最新バージョンでは、Appearance という API が提供されており、端末の設定を取得することが可能です。

    とくヒットしたのは以下の2つで、一方はもう Deprecated になっています。

  • react-native-dark-mode (Deprecated)
  • react-native-dynamic
  • React Native 本体に組み込まれている API に Apprearance というものがあり、const colorScheme = Appearance.getColorScheme(); 的な感じで、端末の設定を取得可能です。

    React Native 本体で提供されている Appearance API
    React Native 本体で提供されている Appearance API

    Fast Notion では端末のモード設定の他に、Fast Notion アプリ内でもモードの設定をできるようにしており、アプリ内のモード設定が優先されるようになっています。アプリ内のモード設定情報は、 LocalStorage に保存して永続化しています。保存したデータを後述の Context を使用して各コンポーネントに配るみたいな設計で実装しました(後述)

    Context を使用してテーマ情報を各コンポーネントに配る

    各コンポーネントで設定されているテーマを受け取る方法は、

  • props のバケツリレー、
  • Redux
  • Recoil
  • 等色々あるかなと思いますが、今回は Context を使用しました。Context は明示的に情報の受け渡しを各必要はなく、親コンポーネントを Context でラップしてやることで、子コンポーネントは Context の情報に使いたいときにアクセスできるという便利なやつです。便利な反面、濫用するとコードがカオスになるので、言語設定やカラーテーマ設定などの、ビジネスロジックが絡まないグローバルな情報に使用することが推奨されています。

    (参考)Context のドキュメント https://reactjs.org/docs/context.html

    // theme.ts
    
    import React from 'react';
    export type Theme = 'light' | 'dark';
    export const createDefaultThemeContext = () => ({
      theme: 'light',
    });
    
    export interface ThemeContextInterface {
      theme: Theme;
      toggleTheme: () => void;
    }
    
    export const ThemeContext = React.createContext<ThemeContextInterface>(null);

    theme.ts は Context の初期化を行うファイルです。createContext の引数は null にしておくのが良いらしいです。当初は isDark みたいな変数名で true / false の boolean で設計していたのですが、途中で light or dark の union type に変更しました。他にモードが増えた時に汎用性がないなと感じたためです。

    // App.tsx
    
    import { Theme, ThemeContext } from './src/contexts/theme';
    
    function App() {
      const [configState, setConfigState] = React.useState(UiContext.configInitialState);
      const [theme, toggleTheme] = useTheme();
    
    return (
        <ThemeContext.Provider value={{ theme, toggleTheme }}>
          <StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
          <Routes />
        </ThemeContext.Provider>
      );
    }

    App.tsx は React Native アプリケーションのエントリポイントです。useTheme というカスタム hooks を作成して、状態の永続化や、状態の変更をこの中で行うようにしています。 ThemeContext の Provider で、テーマの状態と、テーマの切り替え用の関数を1つのオブジェクトにして渡しています。これにより、末端のコンポーネントでもテーマの使用やテーマの切り替えを自由に行うことが出来るようになります。便利。

    Fast Notion では、設定画面でダークモード切り替えができるようになっているのですが、ここのトグル部分の change イベントで、Context から受け取った変更関数が発火するようになっています。Switch がトグルのコンポーネントです。

    <Switch
      trackColor={{
        true: COLORS.TOGGLE_TRACK_ON,
        false: COLORS.TOGGLE_TRACK_OFF,
      }}
      thumbColor={theme === 'light' ? COLORS.WHITE : COLORS.TOGGLE_THUMB}
      ios_backgroundColor="#E9E9EA"
      onValueChange={toggleTheme}
      value={theme === 'dark'}
      style={styles.switch}
    />
    Fast Notion の設定画面 ダークモードの切り替えトグル
    Fast Notion の設定画面 ダークモードの切り替えトグル

    各コンポーネントで色の出し分け実装する

    スタイル部分の分岐はどう書くのが良いのか迷った挙げ句、引数でテーマを渡す感じにしました。ここはもしかしたらもうちょっと良い書き方とかあるのかもしれなくて、良い書き方あったら教えて下さい。

    import React, { useContext } from 'react';
    import { StyleSheet, Text, TextProps } from 'react-native';
    import { DARK_TEXT, TEXT } from '../../../constants/color';
    import { Theme, ThemeContext } from '../../../contexts/theme';
    
    // テーマを受け取る(union type で dark or light)
    const getStyles = (theme: Theme) =>
      StyleSheet.create({
        label: {
          fontSize: 14,
          lineHeight: 18,
          fontWeight: '400',
          color: theme === 'dark' ? DARK_TEXT : TEXT,
        },
      });
    
    interface Props extends TextProps {
      children: React.ReactNode;
    }
    
    /**
     * 通常の本文に使用するコンポーネント
     */
    export const Text4 = (props: Props) => {
      const { theme } = useContext(ThemeContext);
    
      return (
        <Text {...props} style={[getStyles(theme).label, props.style]}>
          {props.children}
        </Text>
      );
    };

    こんな感じです。このあたりの日本語記事はあんまりなかった感あったので、今後 React Native 製アプリでダークモードの実装をする方の参考になればと思います。