【React Native】Contextを利用して複数テーマを簡単にきりかえる

# はじめに
React Nativeのアプリ開発で、将来的なダークテーマの追加などに対応できるように、複数テーマを動的にきりかえることができるようなしくみを実装しました。

以下の記事と我が師匠のアドバイスをもとに、自分なりにカスタマイズしてみました。

https://littlebylittle.work/2021/09/theme-introduce/

# 実装
テーマの定義、ContextとProviderの作成、カスタムフックの作成などについて記載します。

## テーマ(色)の定義
まず、`ColorTheme`に各カテゴリー(Textなど)に含まれる特徴(primaryなど)の型を定義します。
カテゴリーの分け方はアプリ次第ですが、TextやButton以外にBackgroundやDividerなども考えられるかと思います。

次に、`~Emphasis`として、各カテゴリの特徴をリテラル型として定義します。
これは、例えば`Button`のコンポーネントを作成する際、テーマを決めるProps(emphasis)を追加するときに必要な型となります。

最後に、テーマを定義します。
`DEFAULT_THEME`を基本テーマとして、新しいテーマが必要であれば`DARK_THEME`などを追加していきます。

```ts:src/styles/theme/ColorTheme.ts
import { ColorValue } from "react-native";

// 型定義
export type ColorTheme = {
  Text: {
    primary: ColorValue;
    secondary: ColorValue;
    inverse: ColorValue;
    disabled: ColorValue;
    alert: ColorValue;
  };
  Button: {
    primary: ColorValue;
    secondary: ColorValue;
    inverse: ColorValue;
    disabled: ColorValue;
    pressed: ColorValue;
    alert: ColorValue;
  };
};

// プロパティ名のリテラル
export type TextEmphasis = keyof ColorTheme["Text"];
export type ButtonEmphasis = keyof ColorTheme["Button"];

// テーマ定義
export const DEFAULT_THEME: ColorTheme = {
  Text: {
    primary: "#000000",
    secondary: "#808080",
    inverse: "#FFFFFF",
    disabled: "#C7C7C7",
    alert: "#FF0000",
  },
  Button: {
    primary: "#0000FF",
    secondary: "#000000",
    inverse: "#FFFFFF",
    disabled: "#C7C7C7",
    pressed: "rgba( 255, 255, 255, 0.5)",
    alert: "#FF0000",
  },
};
```

## ThemeContextとThemeProviderの作成
テーマ`theme`とテーマきりかえ`toggleTheme`について、Context APIでグローバルに保持するようにします。

```tsx:src/styles/themeProvider/ThemeContext.tsx
import React, { ReactNode, createContext, useMemo, useState } from "react";
import { ColorTheme, DEFAULT_THEME } from "src/styles/theme/ColorTheme";

export type ContextValue<T extends ColorTheme> = {
  theme: T;
  toggleTheme: (theme: T) => void;
};

const defaultValue = {
  theme: DEFAULT_THEME,
  toggleTheme: () => undefined,
};

export const ThemeContext = createContext<ContextValue<any>>(defaultValue);

type Props = {
  initialTheme: ColorTheme;
  children: ReactNode;
};

export const ThemeProvider = React.memo(({ initialTheme, children }: Props) => {
  const [currentTheme, setCurrentTheme] = useState<ColorTheme>(initialTheme);
  const contextValue = useMemo<ContextValue<any>>(() => {
    return {
      theme: currentTheme,
      toggleTheme: theme => setCurrentTheme(theme),
    };
  }, [currentTheme]);

  return <ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>;
});

ThemeProvider.displayName = "ThemeProvider";
```
作成した`ThemeProvider`はRootに設置します。

```tsx
export const App = () => {
  return (
    <ThemeProvider initialTheme={DEFAULT_THEME}>
      <Root />
    </ThemeProvider>
  );
};
```

## カスタムフックuseThemeの作成
StyleSheetでContextの`theme`をつかえるように、カスタムフックを作成します。
引数なしの場合(stylesを使わずにthemeやtoggleThemeだけを使う場合)、stylesは空オブジェクトを返すように工夫しています。

```ts:src/styles/themeProvider/useTheme.ts
import { useContext, useMemo } from "react";
import { StyleSheet } from "react-native";
import { ColorTheme, ThemeContext } from "src/styles/theme/ColorTheme";
import { ContextValue } from "src/styles/themeProvider/ThemeContext";

type Generator<T extends ColorTheme> = (theme: T) => ReturnType<typeof StyleSheet.create>;

export const useTheme = <T extends ColorTheme, U = {}>(fn?: Generator<T>) => {
  const { theme, toggleTheme }: ContextValue<T> = useContext(ThemeContext);
  const styles = useMemo<ReturnType<U>>(() => (fn ? fn(theme) : {}), [fn, theme]);
  
  return { styles, theme, toggleTheme };
};
```
themeProviderディレクトリ内につくったものは、一応バレル化します。

```tsx:src/styles/themeProvider/index.tsx
export { ThemeProvider, ThemeContext } from "./ThemeContext";
export { useTheme } from "./useTheme";
```

## useThemeの使用例
以下のように、StyleSheetのオブジェクトを返す`_styles`を引数にとって、返した`styles`をコンポーネントのstyleに適用することができます。

```tsx:Example.tsx
export const Example = ({ children }: Props) => {
  const { styles } = useTheme<ColorTheme, typeof _styles>(_styles);

  return (
    <View>
      <Text style={styles.text}>{children}</Text>
    </View>
  );
};

const _styles = (theme: ColorTheme) => {
  const styles = StyleSheet.create({
    text: {
      color: theme.Text.inverse,
    },
  });
  return styles;
};
```

また、StyleSheetを使わない場合は、useThemeから`theme`を呼んで、直接コンポーネントのstyleに適用することもできます。

```tsx
export const Example = ({ children }: Props) => {
  const { theme } = useTheme();

  return (
    <View>
      <Text style={{ color: theme.Text.inverse }}>{children}</Text>
    </View>
  );
};
```