如何在反应应用程序中设置系统偏好暗模式,但还允许用户来回切换当前主题

Posted

技术标签:

【中文标题】如何在反应应用程序中设置系统偏好暗模式,但还允许用户来回切换当前主题【英文标题】:How do I set system preference dark mode in a react app but also allow users to toggle back and forth the current theme 【发布时间】:2020-07-21 20:43:29 【问题描述】:

我有一个在导航上带有主题切换的 React Web 应用程序。我有一个ThemeProvider Context,它具有自动检测用户的系统主题偏好并设置它的逻辑。但是,我觉得用户应该能够在网站上来回切换主题,尽管他们有系统偏好。这是包含所有主题逻辑的 ThemeContext.js 文件,包括 toggle 方法。

import React,  useState, useLayoutEffect  from 'react';

const ThemeContext = React.createContext(
    dark: false,
    toggle: () => ,
);

export default ThemeContext;

export function ThemeProvider( children ) 
    // keeps state of the current theme
    const [dark, setDark] = useState(false);

    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
        .matches;
    const prefersLight = window.matchMedia('(prefers-color-scheme: light)')
        .matches;
    const prefersNotSet = window.matchMedia(
        '(prefers-color-scheme: no-preference)'
    ).matches;

    // paints the app before it renders elements
    useLayoutEffect(() => 
        // Media Hook to check what theme user prefers
        if (prefersDark) 
            setDark(true);
        

        if (prefersLight) 
            setDark(false);
        

        if (prefersNotSet) 
            setDark(true);
        

        applyTheme();

        // if state changes, repaints the app
        // eslint-disable-next-line react-hooks/exhaustive-deps
    , [dark]);

    // rewrites set of css variablels/colors
    const applyTheme = () => 
        let theme;
        if (dark) 
            theme = darkTheme;
        
        if (!dark) 
            theme = lightTheme;
        

        const root = document.getElementsByTagName('html')[0];
        root.style.cssText = theme.join(';');
    ;

    const toggle = () => 
        console.log('Toggle Method Called');

        // A smooth transition on theme switch
        const body = document.getElementsByTagName('body')[0];
        body.style.cssText = 'transition: background .5s ease';

        setDark(!dark);
    ;

    return (
        <ThemeContext.Provider
            value=
                dark,
                toggle,
            >
            children
        </ThemeContext.Provider>
    );


// styles
const lightTheme = [
    '--bg-color: var(--color-white)',
    '--text-color-primary: var(--color-black)',
    '--text-color-secondary: var(--color-prussianBlue)',
    '--text-color-tertiary:var(--color-azureRadiance)',
    '--fill-switch: var(--color-prussianBlue)',
    '--fill-primary:var(--color-prussianBlue)',
];

const darkTheme = [
    '--bg-color: var(--color-mirage)',
    '--text-color-primary: var(--color-white)',
    '--text-color-secondary: var(--color-iron)',
    '--text-color-tertiary: var(--color-white)',
    '--fill-switch: var(--color-gold)',
    '--fill-primary:var(--color-white)',
];

因此,当页面加载时,显示用户的系统首选它们,但也允许用户通过单击触发toggle 功能的切换按钮来切换主题。在我当前的代码中,当调用toggle 时,状态更改似乎发生了两次,因此主题保持不变。如何确保toggle 方法正常工作?

这是有问题的web app

【问题讨论】:

我已经回答完了你的问题,这应该可以让你继续前进:) 【参考方案1】:

虽然 Barry 的解决方案有效,但请注意,您可以通过略读获得相同的结果,而不是添加更多代码:

关键是将用户的偏好设置为初始状态,并在效果中停止检查:

export function ThemeProvider( children ) 
    /* Because you are setting the initial theme to non-dark, 
    you can assume that your initial state should be dark only 
    when the user's preference is set to dark. */
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
        .matches;

    // True if preference is set to dark, false otherwise.
    const [dark, setDark] = useState(prefersDark);
    /* Note: Initial state is set upon mounting, hence is better 
    to put the <ThemeProvider> up in your tree, close to the root <App> 
    to avoid unmounting it with the result of reverting to the default user 
    preference when and if re-mounting (unless you want that behaviour) */

    useLayoutEffect(() => 
        /* You end up here only when the user takes action 
        to change the theme, hence you can just apply the new theme. */
        applyTheme();
, [dark]);
...

CodeSandbox example

【讨论】:

这样更干净也很聪明。【参考方案2】:

问题是useLayoutEffect 的整个块在每次dark 值更改时运行。因此,当用户切换dark 时,prefers... if 语句运行,setDark 回到系统偏好设置。

要解决此问题,您需要跟踪用户手动切换主题,然后阻止 prefers... if 语句运行。

在您的ThemeProvider 中执行以下操作:

添加状态以监控用户是否使用了切换
const [userPicked, setUserPicked] = useState(false);
更新您的toggle 函数:
const toggle = () => 
  console.log('Toggle Method Called');

  const body = document.getElementsByTagName('body')[0];
  body.style.cssText = 'transition: background .5s ease';

  setUserPick(true) // Add this line
  setDark(!dark);
;
最后,将useLayout 更新为如下所示:
useLayoutEffect(() => 
  if (!userPicked)  // This will stop the system preferences from taking place if the user manually toggles the them
    if (prefersDark) 
      setDark(true);
    

    if (prefersLight) 
      setDark(false);
    

    if (prefersNotSet) 
      setDark(true);
    
  

  applyTheme();
, [dark]);

您的切换组件不必更改。

更新:

Sal 的回答是一个很好的选择。我指出了现有代码中的缺陷以及如何添加它。这指出了如何更有效地执行您的代码。

export function ThemeProvider( children ) 
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

  const [dark, setDark] = useState(prefersDark);

  useLayoutEffect(() => 
    applyTheme();
  , [dark]);

  ...


【讨论】:

【参考方案3】:

为什么不直接使用useEffect

useEffect(() => 
  const prefersDark = window.matchMedia(
    "(prefers-color-scheme: dark)"
  ).matches;

  if (prefersDark) 
    setIsDark(true);
  
, []);

useEffect访问window的原因:Window is not defined in Next.js React app。

【讨论】:

以上是关于如何在反应应用程序中设置系统偏好暗模式,但还允许用户来回切换当前主题的主要内容,如果未能解决你的问题,请参考以下文章

在 Xamarin Android 中设置布局背景 - 覆盖暗模式

如何以编程方式在系统偏好设置中设置 macOS 键盘快捷键?

Android偏好总结。如何在摘要中设置 3 行?

如何允许快速后端 REST API 在使用 axios 的反应前端中设置 cookie?

如何根据暗/亮模式设置默认标签颜色(在 Swift 中)

在 React 本机应用程序中设置文本的最大大小