React Native Calendarをダークモードに対応させるために色々頑張った話(React Native Paper使用環境)

React Native初心者だけど、React Native Calendarをダークモードに対応させるために色々頑張った話。
顔面神経麻痺ケアアプリで使っているのだが、テーマを動的に変化させることができないのだ。

RNCの再レンダリング

まず最初にやったのは、RNCを再レンダリングする方法。
RNCはレンダリングした時点のテーマで表示されるので、アピアランスが切り替わるたびにRNCを再レンダリングするというゴリ押しな方法。

import React, { useState } from 'react';
import { View, Appearance } from 'react-native';
import { useTheme, Button } from 'react-native-paper';
import { CalendarList } from 'react-native-calendars';

const HomeScreen = ({ navigation }) => {
  const theme = useTheme();
  const [calendarKey, setCalendarKey] = useState(0);
  const reloadCalendar = () => {
    setTimeout(() => {setCalendarKey(calendarKey + 1)},100);
  };
  const changeAppearance = Appearance.addChangeListener(reloadCalendar);
  // changeAppearance.remove();

  return (
    <View>
      <CalendarList
        key={calendarKey}
        horizontal={true}
        pagingEnabled={true}
        theme={{
          calendarBackground: theme.colors.dynamic.surface,
          textSectionTitleColor: theme.colors.dynamic.onSurface,
          textSectionTitleDisabledColor: theme.colors.dynamic.onSurfaceDisabled,
          monthTextColor: theme.colors.dynamic.onSurface,
          arrowColor: theme.colors.dynamic.primary,
        }}
      />
      <Button onPress={reloadCalendar}>今日</Button>
    </View>
  );
};

export default HomeScreen;

タイミングがシビアで、普通にやるとOSがダークモードになるとRNCがライトに、OSがライトモードになるとRNCがダークになってしまうことがある。
そこでsetTimeoutを使って0.1秒遅延させるというもはやクソコードだが、他に方法が思いつかないのだ。

DynamicColorIOS

iOSの場合DynamicColorIOSを使うという方法があるらしい。
lightdarkhighContrastLightなど複数の色を指定しておくと、OS側が勝手に必要な色を選んで表示してくれる。
Appスイッチャーでも色が切り替わるし、切り替え時には美しいディゾルブトランジションが見れる。
少し実験したらRNCでも使える。
なんだこれ最高じゃないか!?

このアプリではApp.jsでMaterial Design 3を上書きする形で2種類のカラースキームを作って切り替えている。
iOS用にもう1つ作らなければならないのは正直面倒だが、DynamicColorIOSはやってみる価値がある。

import React from 'react';
import { useColorScheme, Platform, DynamicColorIOS } from 'react-native';
import { MD3LightTheme, MD3DarkTheme, adaptNavigationTheme, Provider as PaperProvider } from 'react-native-paper';
import { NavigationContainer, DarkTheme as NavigationDarkTheme, DefaultTheme as NavigationDefaultTheme, } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';

import HomeScreen from './screens/HomeScreen';

const Stack = createNativeStackNavigator();

export default function App() {
  const colorScheme = useColorScheme();
  const { LightTheme, DarkTheme } = adaptNavigationTheme({
    reactNavigationLight: NavigationDefaultTheme,
    reactNavigationDark: NavigationDarkTheme,
  });

  const customLightColors = {
    "primary": "rgb(0, 102, 137)",
    "onPrimary": "rgb(255, 255, 255)",
    "primaryContainer": "rgb(195, 232, 255)",
    "onPrimaryContainer": "rgb(0, 30, 44)",
    "secondary": "rgb(0, 106, 106)",
    "onSecondary": "rgb(255, 255, 255)",
    "secondaryContainer": "rgb(111, 247, 246)",
    "onSecondaryContainer": "rgb(0, 32, 32)",
    // ...
    // 省略
    // ...
    "border": "rgb(192, 199, 205)",
    "card": "#ebf2f8",
    "notification": "rgb(186, 26, 26)",
    "text": "rgb(25, 28, 30)",
  }
  const customDarkColors = {
    "primary": "rgb(76, 218, 218)",
    "onPrimary": "rgb(0, 55, 55)",
    "primaryContainer": "rgb(0, 79, 80)",
    "onPrimaryContainer": "rgb(111, 247, 246)",
    "secondary": "rgb(120, 209, 255)",
    "onSecondary": "rgb(0, 53, 73)",
    "secondaryContainer": "rgb(0, 76, 104)",
    "onSecondaryContainer": "rgb(195, 232, 255)",
    // ...
    // 省略
    // ...
    "border": "rgb(65, 72, 77)",
    "card": "#001e2c",
    "notification": "rgb(255, 180, 171)",
    "text": "rgb(225, 226, 229)",
  }

  const iosColors = Platform.OS === 'ios' ? {
    "primary": DynamicColorIOS({
      light: customLightColors.primary,
      dark: customDarkColors.primary,
    }),
    "onPrimary": DynamicColorIOS({
      light: customLightColors.onPrimary,
      dark: customDarkColors.onPrimary,
    }),
    "primaryContainer": DynamicColorIOS({
      light: customLightColors.primaryContainer,
      dark: customDarkColors.primaryContainer,
    }),
    "onPrimaryContainer": DynamicColorIOS({
      light: customLightColors.onPrimaryContainer,
      dark: customDarkColors.onPrimaryContainer,
    }),
    "secondary": DynamicColorIOS({
      light: customLightColors.secondary,
      dark: customDarkColors.secondary,
    }),
    "onSecondary": DynamicColorIOS({
      light: customLightColors.onSecondary,
      dark: customDarkColors.onSecondary,
    }),
    "secondaryContainer": DynamicColorIOS({
      light: customLightColors.secondaryContainer,
      dark: customDarkColors.secondaryContainer,
    }),
    "onSecondaryContainer": DynamicColorIOS({
      light: customLightColors.onSecondaryContainer,
      dark: customDarkColors.onSecondaryContainer,
    }),
    // ...
    // 省略
    // ...
    "border": DynamicColorIOS({
      light: customLightColors.border,
      dark: customDarkColors.border,
    }),
    "card": DynamicColorIOS({
      light: customLightColors.card,
      dark: customDarkColors.card,
    }),
    "notification": DynamicColorIOS({
      light: customLightColors.notification,
      dark: customDarkColors.notification,
    }),
    "text": DynamicColorIOS({
      light: customLightColors.text,
      dark: customDarkColors.text,
    }),
  } : {};

  const theme = Platform.OS === 'ios' && colorScheme === 'dark' ? {
    ...MD3DarkTheme,
    ...DarkTheme,
    myOwnProperty: true,
    colors: {
      ...MD3DarkTheme.colors,
      ...DarkTheme.colors,
      ...iosColors,
    },
  } : Platform.OS === 'ios' ? {
    ...MD3LightTheme,
    ...LightTheme,
    myOwnProperty: true,
    colors: {
      ...MD3LightTheme.colors,
      ...LightTheme.colors,
      ...iosColors,
    },
  } : colorScheme === 'dark' ? {
    ...MD3DarkTheme,
    ...DarkTheme,
    myOwnProperty: true,
    colors: {
      ...MD3DarkTheme.colors,
      ...DarkTheme.colors,
      ...customDarkColors,
    },
  } : {
    ...MD3LightTheme,
    ...LightTheme,
    myOwnProperty: true,
    colors: {
      ...MD3LightTheme.colors,
      ...LightTheme.colors,
      ...customLightColors,
    },
  };

  return (
    <PaperProvider theme={theme}>
      <NavigationContainer theme={theme}>
        <Stack.Navigator>
          <Stack.Screen name="Home" component={HomeScreen} />
        </Stack.Navigator>
      </NavigationContainer>
    </PaperProvider>
  );
}

エラー吐いて動かない。
なんで?・・・

React Native Paperの問題

色々やってみたが、どうやらReact Native PaperがDynamicColorIOSに対応していないと結論づけることにした。
こうなってくるともうMaterial Design 3を上書きする方法は使えない。
独自のプロパティでテーマを拡張するしかなくなってしまった。

import React from 'react';
import { useColorScheme, Platform, DynamicColorIOS } from 'react-native';
import { MD3LightTheme, MD3DarkTheme, adaptNavigationTheme, Provider as PaperProvider } from 'react-native-paper';
import { NavigationContainer, DarkTheme as NavigationDarkTheme, DefaultTheme as NavigationDefaultTheme, } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';

import HomeScreen from './screens/HomeScreen';

const Stack = createNativeStackNavigator();

export default function App() {
  const colorScheme = useColorScheme();
  const { LightTheme, DarkTheme } = adaptNavigationTheme({
    reactNavigationLight: NavigationDefaultTheme,
    reactNavigationDark: NavigationDarkTheme,
  });

  const customLightColors = {
    "primary": "rgb(0, 102, 137)",
    "onPrimary": "rgb(255, 255, 255)",
    "primaryContainer": "rgb(195, 232, 255)",
    "onPrimaryContainer": "rgb(0, 30, 44)",
    "secondary": "rgb(0, 106, 106)",
    "onSecondary": "rgb(255, 255, 255)",
    "secondaryContainer": "rgb(111, 247, 246)",
    "onSecondaryContainer": "rgb(0, 32, 32)",
    // ...
    // 省略
    // ...
    "border": "rgb(192, 199, 205)",
    "card": "#ebf2f8",
    "notification": "rgb(186, 26, 26)",
    "text": "rgb(25, 28, 30)",
  }
  const customDarkColors = {
    "primary": "rgb(76, 218, 218)",
    "onPrimary": "rgb(0, 55, 55)",
    "primaryContainer": "rgb(0, 79, 80)",
    "onPrimaryContainer": "rgb(111, 247, 246)",
    "secondary": "rgb(120, 209, 255)",
    "onSecondary": "rgb(0, 53, 73)",
    "secondaryContainer": "rgb(0, 76, 104)",
    "onSecondaryContainer": "rgb(195, 232, 255)",
    // ...
    // 省略
    // ...
    "border": "rgb(65, 72, 77)",
    "card": "#001e2c",
    "notification": "rgb(255, 180, 171)",
    "text": "rgb(225, 226, 229)",
  }

  const iosColors = Platform.OS === 'ios' ? {
    "dynamic": {
      "primary": DynamicColorIOS({
        light: customLightColors.primary,
        dark: customDarkColors.primary,
      }),
      "onPrimary": DynamicColorIOS({
        light: customLightColors.onPrimary,
        dark: customDarkColors.onPrimary,
      }),
      "primaryContainer": DynamicColorIOS({
        light: customLightColors.primaryContainer,
        dark: customDarkColors.primaryContainer,
      }),
      "onPrimaryContainer": DynamicColorIOS({
        light: customLightColors.onPrimaryContainer,
        dark: customDarkColors.onPrimaryContainer,
      }),
      "secondary": DynamicColorIOS({
        light: customLightColors.secondary,
        dark: customDarkColors.secondary,
      }),
      "onSecondary": DynamicColorIOS({
        light: customLightColors.onSecondary,
        dark: customDarkColors.onSecondary,
      }),
      "secondaryContainer": DynamicColorIOS({
        light: customLightColors.secondaryContainer,
        dark: customDarkColors.secondaryContainer,
      }),
      "onSecondaryContainer": DynamicColorIOS({
        light: customLightColors.onSecondaryContainer,
        dark: customDarkColors.onSecondaryContainer,
      }),
      // ...
      // 省略
      // ...
    },
    "border": DynamicColorIOS({
      light: customLightColors.border,
      dark: customDarkColors.border,
    }),
    "card": DynamicColorIOS({
      light: customLightColors.card,
      dark: customDarkColors.card,
    }),
    "notification": DynamicColorIOS({
      light: customLightColors.notification,
      dark: customDarkColors.notification,
    }),
    "text": DynamicColorIOS({
      light: customLightColors.text,
      dark: customDarkColors.text,
    }),
  } : {};

  const theme = Platform.OS === 'ios' && colorScheme === 'dark' ? {
    ...MD3DarkTheme,
    ...DarkTheme,
    myOwnProperty: true,
    colors: {
      ...MD3DarkTheme.colors,
      ...DarkTheme.colors,
      ...customDarkColors,
      ...iosColors,
    },
  } : Platform.OS === 'ios' ? {
    ...MD3LightTheme,
    ...LightTheme,
    myOwnProperty: true,
    colors: {
      ...MD3LightTheme.colors,
      ...LightTheme.colors,
      ...customLightColors,
      ...iosColors,
    },
  } : colorScheme === 'dark' ? {
    ...MD3DarkTheme,
    ...DarkTheme,
    myOwnProperty: true,
    colors: {
      ...MD3DarkTheme.colors,
      ...DarkTheme.colors,
      ...customDarkColors,
      "dynamic": {
        ...customDarkColors,
      }
    },
  } : {
    ...MD3LightTheme,
    ...LightTheme,
    myOwnProperty: true,
    colors: {
      ...MD3LightTheme.colors,
      ...LightTheme.colors,
      ...customLightColors,
      "dynamic": {
        ...customLightColors,
      }
    },
  };

  return (
    <PaperProvider theme={theme}>
      <NavigationContainer theme={theme}>
        <Stack.Navigator>
          <Stack.Screen name="Home" component={HomeScreen} />
        </Stack.Navigator>
      </NavigationContainer>
    </PaperProvider>
  );
}

これでstyle = {{ backgroundColor: theme.colors.dynamic.primary }}みたいな感じで使えるようになる。
非対応のReact Native Paperコンポーネント(CardとかDialogとか)では、従来通りtheme.colors.dynamic.primaryを使えばOK。

というわけで、全てのスクリーンでスタイルを書き換える地獄の作業が始まるのであった・・・

参考

関連記事

コメント

この記事へのコメントはありません。

TOP