Gatsby + Material-UI でダークモードを実装する方法です。
Gatsby, Material-UI 及び React フックに関するある程度の理解がある人向けの内容になります。

概要

Material-UI 公式による Gatsby example をベースに、ダークモード切り替えの実装例を見ます。

(裏テーマ)
コンテクストを使ってコンポーネントツリーの上層にある Material-UI のテーマ MuiTheme を下層から更新する。

ダークモード導入後のデモページ
ダークモード導入後のプロジェクト例

準備と前提知識

この記事では Material-UI 公式による Gatsby example をプロジェクト構成のベースとして最小限のコードでダークモードを実装します。 既存のプロジェクトを持っている人はこのプロジェクト構成に合致しているか確かめて(特に後述する gatsby-plugin-top-layout を使っているかどうか)、ここから始める人は Gatsby example を 手順に従ってダウンロードしてください。

1
2
3
4
curl https://codeload.github.com/mui-org/material-ui/tar.gz/master | tar -xz --strip=2  material-ui-master/examples/gatsby
mv gatsby [新しいプロジェクト名]
cd [新しいプロジェクト名]
yarn install

バージョン

1
2
3
- react@16.12.0
- gatsby@2.19.12
- @material-ui/core@4.9.1

gatsby-plugin-top-layout について

Material-UI 公式による Gatsby example では、プロジェクト内の plugins ディレクトリに gatsby-plugin-top-layout という自作プラグインが組み込まれています。

Material-UI の基本ですが、mui コンポーネントを使うには、コンポーネントツリーの上層に MuiTheme のプロバイダとなる <ThemeProvider> を必要とします。
この gatsby-plugin-top-layout という自作プラグインは開発・ビルドの際に自動で各ページのコンポーネントツリーの最上位に <ThemeProvider> を位置付けてくれます。つまり作成する全てのページで自由に mui コンポーネントを使うことができるようになります。

この記事では gatsby-plugin-top-layout を使用していることが前提となります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// plugins/gatsby-plugin-top-layout/TopLayout.js
function TopLayout(props) {
  return (
    <React.Fragment>
      <ThemeProvider theme={theme}>
        <CssBaseline /> // normarize.css のようなもの
        {props.children} // => ここで mui コンポーネントが使える
      </ThemeProvider>
    </React.Fragment>
  )
}

参考
Theming - Material-UI

MuiTheme のダークモードの仕様

MuiTheme には元々ダークモードの仕様があります。
createMuiTheme の引数オブジェクトの palette.type'dark' に設定するだけで、背景と文字の色が反転するダークモードになります。 (デフォルトは 'light')

1
2
3
4
5
const theme = createMuiTheme({
  palette: {
    type: 'dark' // 'light'(default) or 'dark'
  }
});

参考
Palette - Material-UI

サイトをただ単にダークモードにするのであれば、パレットタイプを 'dark' にすればいいのですが、実際に必要な機能はダークモードの on/off の切り替えです。

ここまでは準備と前提知識を見てきました。次の章からダークモード切り替えの実装に入ります。

広告

ダークモード切り替えを実装する

MuiTheme を使ったダークモード切り替えの実装にあたり、コンポーネントツリーの最上位(しかもプラグインの中)で扱う MuiThemeステート管理の対象とする方法が問題になります。今回は React の機能であるコンテクストを使ったステート管理の方法を紹介します。

ダークモード実装の概要

各ページのコンポーネントツリー最上位に位置する plugins/gatsby-plugin-top-layout/TopLayout.jsリデューサ (reducer) を導入し、その dispatch 関数をコンテクストとして下層のコンポーネントに受け渡す。コンテクストを使ったカスタムフックを作成し他のコンポーネント内から呼び出す。

編集するのは以下の4ファイルです。

  1. 作成: src/themeReducer.js
  2. 作成: src/DispatchContext.js
  3. 変更: plugins/gatsby-plugin-top-layout/TopLayout.js
  4. 作成: src/components/DarkModeButton.js

(デモではその他にレイアウトコンポーネントを作成していますが、本筋とは関係がないので省きます)

1. 作成: src/themeReducer.js

リデューサを作成し、テーマのステート、アクションを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/themeReducer.js
/** TypeScript を使う場合
 *  
 *  export interface ThemeState {
 *    darkMode: boolean;
 *  }
 *  export type Action = { type: 'TOGGLE_DARKMODE' };
 */

// 初期ステートを定義
export const initialState = {
  darkMode: false
};

// リデューサの定義
export const themeReducer = (state, action) => {
  switch (action.type) {
    case 'TOGGLE_DARKMODE':
      return {
        ...state,
        darkMode: !state.darkMode
      };
    default:
      throw new Error();
  }
};

2. 作成: src/DispatchContext.js

コンテクストカスタムフックの作成をおこないます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// src/DispatchContext.js
import React from 'react';

/** TypeScript を使う場合
 *
 *  import { Action } from './themeReducer';
 *  
 *  const DispatchContext =
 *  React.createContext<React.Dispatch<Action>>(...)
 */

// コンテクストの作成
export const DispatchContext = React.createContext(() => {
  throw new Error();
});

// カスタムフックの作成
export function useToggleDarkMode() {
  const dispatch = React.useContext(DispatchContext);
  return React.useCallback(() => dispatch({ type: 'TOGGLE_DARKMODE' }), [dispatch]);
}

コンテクスト については解説を省きます。なんせ自分も今回初めて使ったものですから。詳しくは React 公式のドキュメントをお読みください。

参考
コンテクスト - React
React.useContext - React

さて、ここで作成した useToggleDarkModeカスタムフックです。
コンテクストを使ったフックにすることでパレットタイプの切り替えをコンポーネントツリー内のどこからでも使うことができます。

参考
カスタムフック - React
React.useCallback - React

3. 変更: plugins/gatsby-plugin-top-layout/TopLayout.js

作成したリデューサとコンテクストを <TopLayout> コンポーネント内で使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// plugins/gatsby-plugin-top-layout/TopLayout.js
import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import CssBaseline from '@material-ui/core/CssBaseline';
import { ThemeProvider, createMuiTheme } from '@material-ui/core/styles';

import initialTheme from '../../src/theme';
import { themeReducer, initialState } from '../../src/themeReducer';
import { DispatchContext } from '../../src/DispatchContext';

export default function TopLayout(props) {
  // React.useReducer でリデューサの導入
  const [state, dispatch] = React.useReducer(themeReducer, initialState);
  const { darkMode } = state;
  // React.useMemo でステートに応じた theme を作成
  const theme = React.useMemo(() => {
    return createMuiTheme({
      ...initialTheme,
      palette: {
        type: darkMode ? 'dark' : 'light',
        primary: initialTheme.palette.primary,
        secondary: initialTheme.palette.secondary,
      }
    });
  }, [darkMode]);

  return (
    <React.Fragment>
      <Helmet>
        <meta
          name="viewport"
          content="minimum-scale=1, initial-scale=1, width=device-width"
        />
        <link
          href="https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap"
          rel="stylesheet"
        />
      </Helmet>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        {/* DispatchContext.Provider を挿入 */}
        <DispatchContext.Provider value={dispatch}>
          {props.children}
        </DispatchContext.Provider>
      </ThemeProvider>
    </React.Fragment>
  );
}

TopLayout.propTypes = {
  children: PropTypes.node,
};
  • React.useReducer を使って <TopLayout> にステートを作成
  • React.useMemo を使ってステートに応じた MuiTheme を再生産
  • <ThemeProvider> の子要素にプロバイダコンポーネントである <DispatchContext.Provider> を挿入
  • <DispatchContext.Provider> の値に dispatch 関数を設定

dispatch はコンテクストとして、子要素に渡されます。子要素からステートが変更された時に、<TopLayout> 内で MuiTheme を再生産し、ページ全体のデザインを更新します。
これでダークモードの実装、コンポーネントツリー下層から最上層の MuiTheme の更新という裏テーマを達成しました。

参考
useReducer - React
useMemo - React
Context.Provider - React

先ほど作成したカスタムフック useToggleDarkMode でパレットタイプ切り替えのアイコンボタンを作ってみましょう。

4. 作成: src/components/DarkModeButton.js

@material-ui/icons をインストールします。

1
yarn add @material-ui/icons
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/components/DarkModeButton.js
import React from 'react';
import IconButton from '@material-ui/core/IconButton';
import useTheme from '@material-ui/core/styles/useTheme';
import Brightness4 from '@material-ui/icons/Brightness4';
import Brightness5 from '@material-ui/icons/Brightness5';
import { useToggleDarkMode } from '../themeReducer';

function DarkModeButton(props) {
  // パレットタイプを取得
  const paletteType = useTheme().palette.type;
  // パレットタイプ切り替えのフックを使用
  const _toggleDarkMode = useToggleDarkMode();
  
  return (
    <IconButton onClick={_toggleDarkMode} {...props}>
      {paletteType === 'dark' ? <Brightness5 /> : <Brightness4 />}
    </IconButton>
  );
}

export default DarkModeButton;

Material-UI の MuiTheme もコンテクストです。useTheme というカスタムフックでどのコンポーネントからでも MuiTheme を取得できます。現在のパレットタイプ取得には useTheme フックを使います。
ここで定義した _toggleDarkMode という変数は、パレットタイプ切り替えを実行するコールバック関数です。

簡潔なコードで書かれたこの <DarkModeButton> を好きな場所に置くだけでダークモードの切り替えが可能になります。
デモページでは作成したレイアウトコンポーネントの中に <DarkModeButton> を設置しています。

参考
Icons - Material-UI
useTheme - Material-UI

コードの元ネタ

解説ですが、実は実装が上手くいった後に仕組みを調べているので至らない部分があると思います。

このコードの元ネタとなったのは MuiTheme のパレット変更とダークモードを実装している Material-UI ドキュメントソースです。このドキュメントは Next.js 製ですが、このコードを真似して、Gatsby での実装が上手くいった後にどのような仕組みで動いているのか調べました。

参考
material-ui/docs/src/modules/components/ThemeContext.js

今回はダークモードの切り替えのみに絞って簡略化した形で実装しましたが、元ネタのコードは、ダークモードの他にパレットの配色変更余白の変更複数の要素を変更するための合理的なフックの作成などを実装していて、大変勉強になります。

発展編

今回はシンプルなダークモードの実装をしました。
ダークモードをより良いUIにするためには様々な改良が必要になります。

パレットカラーを調整する

ダークモードでパレットをそのまま使うと視認性が悪い可能性があります。パレットタイプ切り替え時に、プライマリーカラーとアクセントカラーのメインをやや明るめにしておくと視認性が改善されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// plugins/gatsby-plugin-top-layout/TopLayout.js

const theme = React.useMemo(() => {
    return createMuiTheme({
      ...initialTheme,
      palette: {
        type: darkMode ? 'dark' : 'light',
        primary: {
          main: darkMode ? initialTheme.palette.primary[300] : initialTheme.palette.primary.main
        },
        secondary: {
          main: darkMode ? initialTheme.palette.secondary[300] : initialTheme.palette.secondary.main
        },
      }
    });
  }, [darkMode]);

参考
Color - Material-UI

メディアクエリを使う

CSS メディアクエリの prefers-color-scheme は現在のシステムがライトテーマかダークテーマかを判別します。 また、Material-UI には JavaScript から CSS のメディアクエリを使うカスタムフック useMediaQuery があります。

Material-UI のドキュメントには useMediaQueryReact.useMemo を組み合わせて MuiTheme のパレットテーマを動的に変更するサンプルコードが載っています。

参考
useMediaQuery - Material-UI
prefers-color-scheme - MDN web docs

このデモでは、ダークモードのページを一度閉じてしまうと、次に開いた場合にまたライトテーマが適用されてしまいます。前回閉じた画面と同じテーマを表示するためには、CookieLocal Storage を使って設定を保存する必要があります。

現在 Cookie に対して風当たりが強いご時世なので Local Storage の方がいいかなと思いますが、Material-UI 公式や Twitter Web App ではテーマの保存にまだ Cookie が使われているので一応紹介しておきます。ちなみに、Gatsby 公式ドキュメントは Local Storage を使っていました。

Local Storage を使うときは、 React フックのレシピ集である useHooks に載っている useLocalStorage を使うといいと思います。

参考
Cookie - MDN web docs
LocalStorage - MDN web docs
useLocalStorage - useHooks
useDarkMode - useHooks

まとめ

ダークモード実装のため、コンポーネントツリー最上位に位置する TopLayout.jsリデューサ (reducer) を導入し、その dispatch 関数をコンテクストとして下層のコンポーネントに受け渡すことで MuiTheme を下層から更新できるようにしました。また、コンテクストを利用したカスタムフックを作成することで、他のどのコンポーネント内からでもパレットタイプの切り替えが可能になりました。

props で受け渡すのではなくコンテクストとフックを使った実装なので、既存プロジェクトでも小さい改修コストで導入できます。

今まであまり使っていなかったコンテクストカスタムフックに対する知見が得られてとても勉強になりました。

参考

もう踊れない / 宇宙まお (2020)

広告