Dark Mode в React Native

Не скерет, что начиная с версии 10 для Android и 13 для iOS в этих операционных системах появился тёмный режим. В React Native взаимодействие с этой штукой строится через модуль Appearance.

Без долгих вступлений отправимся в бой. Для начала получение копии React Native и styled-components (по желанию):

$ npx react-native init DarkModeApp
$ cd DarkModeApp && yarn install
$ yarn add styled-components
$ cd ios && pod install && cd ../

Структура проекта. Потребуется создать директорию src и недостающие файлы в ней. О каждом из них поговорим ниже.

$ tree -L 4
.
├── android
├── app.json
├── babel.config.js
├── index.js
├── ios
├── metro.config.js
├── package.json
└── src
    ├── components
    │   └── StatusBarComponent.js
    ├── constants
    │   └── colors.js
    ├── providers
    │   └── ThemeProvider.js
    └── screens
        └── WelcomeScreen.js

Константы

Указание цветовой палитры для каждой из тем. Dark mode будем проверять на яблочной платформе. Можно свериться с официальным guideline для iOS, если есть желание.

// constants/colors.js
export const LIGHT_COLORS = {
  layout: 'rgb(242, 242, 247)',
  textColor: 'rgb(28, 28, 30)',
  thumbColor: 'rgb(255, 255, 255)',
};

export const DARK_COLORS = {
  layout: 'rgb(28, 28, 30)',
  textColor: 'rgb(242, 242, 247)',
  thumbColor: 'rgb(255, 255, 255)',
};

Контекст

Самое простое решение — хранить текущие параметры темы в React Context. Таким образом данные хранятся на уровне всего приложения и их не требуется прокидывать в каждый компонент: доступ есть у всех компонентов, каким это потребуется.

Создание Context и Provider.

// providers/ThemeProvider.js
import React, { useState } from 'react';
import { Appearance } from 'react-native';
import { DARK_COLORS, LIGHT_COLORS } from '../constants/colors';

// контекст с параметрами по-умолчанию
export const ThemeContext = React.createContext({
  isDark: false,
  colors: LIGHT_COLORS,
  setColorScheme: () => {},
});

// провайдер
export const ThemeProvider = ({ children }) => {
  const colorScheme = Appearance.getColorScheme();
  // храним флаг isDark
  const [isDark, setIsDark] = useState(colorScheme === 'dark');
  const defaultTheme = {
    isDark,
    colors: isDark ? DARK_COLORS : LIGHT_COLORS,
    // будем менять флаг isDark по требованию
    setColorScheme: (scheme) => {
      setIsDark(scheme === 'dark');
    },
  };

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

Screen

Скрин — экран приложения. Как страница в вебе.

Получение контекста может отличаться в зависимости от того используете вы компонент-класс или компонент-функцию. Пожалуй, можно привести оба примера.

Для функции легче и короче применить хук useContext. Для класса классчиеская передача через Context.Consumer.

// screens/WelcomeScreen.jsx
import React, { useContext } from 'react';
import { ScrollView, Switch, Text } from 'react-native';
import styled from 'styled-components/native';

import { ThemeContext } from '../providers/ThemeProvider';
import { LIGHT_COLORS, DARK_COLORS } from '../constants/colors';

const Section = styled.View`
  padding: 30px 16px;
  background-color: ${(props) =>
    props.isDark ? DARK_COLORS.layout : LIGHT_COLORS.layout};
`;

const SectionText = styled.Text`
  margin: 12px 0;
  color: ${(props) =>
    props.isDark ? DARK_COLORS.textColor : LIGHT_COLORS.textColor};
`;

export const WelcomeScreen = () => {
  // получить контекст
  const { colors, isDark, setColorScheme } = useContext(ThemeContext);
  // изменить тему
  const handleChangeColorTheme = isTrue => setColorScheme(isTrue ? 'dark' : 'light');

  return (
    <ScrollView contentInsetAdjustmentBehavior="automatic">
      <Section isDark={isDark}>
        <SectionText isDark={isDark}>
          {isDark ? 'Dark' : 'Light'} Mode
        </SectionText>
        <Switch
          thumbColor={colors.thumbColor}
          value={isDark}
          onValueChange={handleChangeColorTheme} />
      </Section>
    </ScrollView>
  );
};

В данном случае styled-components делает код чище, а будни радостнее. Всё это благодаря конструкции вида
background-color: ${(props) => props.isDark ? 'gray' : 'white'};.
При изменении темы цвета пересчитаются, компоненты перерендерятся.

Но у всего есть своя цена, не увлекайтесь. Для веба при подходе CSS-in-JS можно порекомендовать библиотеку linaria или другие zero-runtime решения. Увы, на момент написания заметки для RN такой возможности нет.

Перерисовка происходит и при использовании контекста, что в сейчас нам только на руку. В целом лучше передавать в Provider примитивные типы.

Что ж, логика готова. Осталось изменить настройки и не забывать после этого перезапускать приложение (достаточно reload, cmd + r). Автоматически при смене времени суток мы ничего не пересчитываем.

ios dev appearance

Статус-бар

После проделанной работы цветовая схема будет меняться. Но есть ещё одна небольшая проблема: статус-бар отсанется неизменным.

Создадим компонент с доступом к контексту и по той же логике, что и раньше, будем менять его цвет. Кстати, для iOS такой возможности не предусмотрено, поэтому придётся делать обёртку над родным StatusBar.

// components/StatusBarComponent
import React, { useContext } from 'react';
import { StatusBar, View, StyleSheet } from 'react-native';
import { ThemeContext } from '../providers/ThemeProvider';

const STATUS_BAR_HEIGHT = 50;

export const StatusBarComponent = () => {
  const { isDark, colors } = useContext(ThemeContext);

  const styles = StyleSheet.create({
    statusBar: {
      backgroundColor: colors.layout,
      height: STATUS_BAR_HEIGHT,
    },
  });

  // задать высоту статус-бара
  StatusBar.currentHeight = STATUS_BAR_HEIGHT;

  return (
    <View style={styles.statusBar}>
      <StatusBar barStyle={isDark ? 'light-content' : 'dark-content'} />
    </View>
  );
};

Собрать всё вместе

Направляемся в точку входа приложения и подключаем все компоненты, предварительно обернув их провайдером.

// index.js
import React from 'react';
import { AppRegistry, SafeAreaView } from 'react-native';
import { name as appName } from './app.json';

import { ThemeProvider } from './src/providers/ThemeProvider';
import { WelcomeScreen } from './src/screens/WelcomeScreen';
import { StatusBarComponent } from './src/components/StatusBarComponent';

const App = () => (
  <ThemeProvider>
    <StatusBarComponent />
    <SafeAreaView>
      <WelcomeScreen />
    </SafeAreaView>
  </ThemeProvider>
);

AppRegistry.registerComponent(appName, () => App);