Tauri(四)—— 白天服白片,不瞌睡;晚上服黑片,睡得香。

636 阅读6分钟

前言

标题灵感来源于一则经典广告词: “白天服白片,不瞌睡;晚上服黑片,睡得香。”

不知道大家是否还记得 “白加黑” ?我小时候还吃过呢!不过,这款药现在似乎已经停产了,具体原因就不多说了,免得跑题了 😄


image.png

如图所示,这是 Mac 系统的外观主题设置,主要分为三种模式:Light、Dark、Auto

其中,LightDark 是核心主题,而 Auto 模式会根据时间自动切换:白天使用 Light 模式,晚上切换为 Dark 模式(对应我的标题,😄)。

接下来,让我们具体聊一聊 LightDark 两种主题的区别。

Light 和 Dark 对比

对比维度Light 主题Dark 主题
颜色基调- 浅色为主,背景通常为白色或浅灰色
- 文本为深色,如黑色或深灰色
- 深色为主,背景通常为黑色、深灰色
- 文本为浅色,如白色或浅灰色
视觉疲劳- 长时间使用可能在低光环境中刺眼
- 在强光环境下较为舒适
- 暗光环境下减少亮度刺激,减轻眼睛疲劳
- 强光环境中可能显得不够清晰
对比度和可读性- 对比度较低(暗色文本对浅色背景),适合文本密集场景
- 可读性好,易于适应
- 对比度较高(浅色文本对深色背景),适合眼睛敏感用户
- 图形或复杂界面显示更清晰
美观与情感- 传递清新、明快感
- 适合文档、办公类应用,显得正式、专业
- 传递科技感、现代感
- 适合多媒体、编程类应用,显得酷炫、前卫
能耗- OLED/AMOLED 屏幕:白色像素消耗更多能量
- LCD 屏幕:与 Dark 主题差异不大
- OLED/AMOLED 屏幕:深色像素更节能
- LCD 屏幕:与 Light 主题差异不大
使用场景- 日常办公、文档阅读、新闻浏览等
- 适合普通用户和光线充足的环境
- 程序开发、设计工作、视频编辑、娱乐等
- 适合对眼睛疲劳敏感的用户和暗光环境
适用时间- 白天或光线充足的时间段- 夜晚或光线较暗的时间段
效果图image.pngimage.png

最佳实践:提供主题切换功能,让用户根据环境和个人偏好自由选择。


实践

用户设置界面

为了满足用户的个性化需求,我们可以设计一个设置页面,让用户根据自己的喜好自由调整界面主题。通过该设置页面,用户不仅可以选择偏好的 LightDark 主题,还可以切换到 Auto 模式,或者默认为Auto 模式。用户只需要设置电脑的主题,App 自动跟随。

image.png

代码实现部分:

import { Monitor, Moon, Sun } from "lucide-react";

import { useTheme } from "./ThemeContext";

export type AppTheme = "auto" | "light" | "dark"

function ThemeOption({
  icon: Icon,
  title,
  theme,
}: {
  icon: any;
  title: string;
  theme: AppTheme;
}) {
  const { theme: currentTheme, changeTheme } = useTheme();

  const isSelected = currentTheme === theme;

  return (
    <button
      onClick={() => changeTheme(theme)}
      className={`p-4 rounded-lg border-2 ${
        isSelected
          ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
          : "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
      } flex flex-col items-center justify-center space-y-2 transition-all`}
    >
      <Icon className={`w-6 h-6 ${isSelected ? "text-blue-500" : ""}`} />
      <span
        className={`text-sm font-medium ${isSelected ? "text-blue-500" : ""}`}
      >
        {title}
      </span>
    </button>
  );
}

export defalut function ThemeSetting() {
    return (
        <div className="grid grid-cols-3 gap-4">
            <ThemeOption icon={Sun} title="Light" theme="light" />
            <ThemeOption icon={Moon} title="Dark" theme="dark" />
            <ThemeOption icon={Monitor} title="Auto" theme="auto" />
        </div>
    )
}

核心逻辑梳理

在编写代码前,先理清核心逻辑,有助于后续开发更加高效:

  1. 自动模式(Auto)
    如果用户选择了 Auto 模式,App 将根据电脑系统的主题设置自动切换。
  2. 监听系统主题
    Auto 模式下,需要监听系统主题的变化,以便同步更新 App 的主题。
  3. 用户自定义模式
    如果用户选择了明确的主题(LightDark),App 将优先按照用户的设置渲染界面,忽略系统设置。
  4. 同步更新 App 窗口
    无论是 Auto 模式还是用户自定义设置,都需要将主题的变化通知到 App 的所有窗口,确保界面风格统一。

通过清晰的逻辑规划,可以更高效地实现主题切换功能,并提升用户体验。

重点文件:ThemeContext.tsx

import React, {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useState,
} from "react";
import { isTauri, invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { emit, listen } from "@tauri-apps/api/event";

import { useThemeStore } from "../stores/themeStore";

export type AppTheme = "auto" | "light" | "dark"
export type WindowTheme = "light" | "dark"

interface ThemeContextType {
  theme: AppTheme;
  changeTheme: (theme: AppTheme) => void;
}

const ThemeContext = createContext<ThemeContextType>({
  theme: "light",
  changeTheme: () => {
    throw new Error("changeTheme not implemented");
  },
});

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  // 缓存,用 zustand
  const { activeTheme: theme, setTheme } = useThemeStore();

  // 记录系统的主题变化
  const [windowTheme, setWindowTheme] = useState<WindowTheme>("light");

  useEffect(() => {
    // Initial theme setup
    const initTheme = async () => {
      const displayTheme = getDisplayTheme(theme);
      await applyTheme(displayTheme);
    };
    initTheme();
    //
    if (!isTauri()) return;
    // window theme
    let unlisten: (() => void) | undefined;
    const setupThemeListener = async () => {
      const currentWindow = getCurrentWindow();
      unlisten = await currentWindow.onThemeChanged(({ payload: w_theme }) => {
        console.log("window New theme:", w_theme);
        setWindowTheme(w_theme);
        if (theme === "auto") applyTheme(w_theme);
      });
    };
    setupThemeListener();

    return () => {
      unlisten?.();
    };
  }, [theme]);

  // 切换托盘图标的主题变化
  async function switchTrayIcon(value: "dark" | "light") {
    try {
      await invoke("switch_tray_icon", { isDarkMode: value === "dark" });
    } catch (err) {
      console.error("Failed to switch tray icon:", err);
    }
  }

  // Get the actual theme to display based on user settings and system theme
  const getDisplayTheme = useCallback(
    (userTheme: AppTheme): WindowTheme => {
      return userTheme === "auto" ? windowTheme : userTheme;
    },
    [windowTheme]
  );

  // 修改前端主题变化
  const changeClassTheme = (displayTheme: WindowTheme) => {
    const root = window.document.documentElement;
    root.classList.remove("light", "dark");
    root.classList.add(displayTheme);
  }

  // Apply theme to UI and sync with Tauri
  const applyTheme = async (displayTheme: WindowTheme) => {
    // Update DOM
    changeClassTheme(displayTheme)

    // Sync with Tauri
    if (isTauri()) {
      // Update window theme
      try {
        await invoke("plugin:theme|set_theme", { theme: displayTheme });
      } catch (err) {
        console.error("Failed to update window theme:", err);
      }

      // Update tray icon
      await switchTrayIcon(displayTheme);

      // Notify other windows to update the theme
      try {
        console.log("theme-changed", displayTheme);
        await emit("theme-changed", { theme: displayTheme });
      } catch (err) {
        console.error("Failed to emit theme-changed event:", err);
      }
    }
  };

  // Initialize theme and handle system theme changes
  useEffect(() => {
    const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

    const handleSystemThemeChange = async () => {
      // Only update if user setting is 'auto'
      if (theme === "auto") {
        const displayTheme = windowTheme;
        await applyTheme(displayTheme);
      }
    };

    // Add system theme change listener
    mediaQuery.addEventListener("change", handleSystemThemeChange);

    // Cleanup listener on unmount
    return () =>
      mediaQuery.removeEventListener("change", handleSystemThemeChange);
  }, [theme, windowTheme]); // Re-run when user theme setting changes

  // Handle theme changes from user interaction
  const changeTheme = async (newTheme: AppTheme) => {
    setTheme(newTheme);
    const displayTheme = getDisplayTheme(newTheme);
    await applyTheme(displayTheme);
  };

  useEffect(() => {
    if (!isTauri()) return;

    let unlisten: () => void;

    const setupListener = async () => {
      unlisten = await listen("theme-changed", (event: any) => {
        console.log("Theme updated to:", event.payload);
        changeClassTheme(event.payload.theme)
      });
    };

    setupListener();

    return () => {
      unlisten?.();
    };
  }, []);

  return (
    <ThemeContext.Provider value={{ theme, changeTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

在前端实现主题切换时,可以通过 ThemeProviderTailwind CSS 高效实现。以下是优化后的代码和说明:

包裹应用的 ThemeProvider

在应用的入口文件中,用 ThemeProvider 包裹整个应用,使主题设置全局生效:

// main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { ThemeProvider } from "./theme/ThemeProvider"; // 引入自定义的 ThemeProvider
import { RouterProvider } from "react-router-dom";
import router from "./router"; // 路由配置

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <ThemeProvider>
      <RouterProvider router={router} />
    </ThemeProvider>
  </React.StrictMode>
);

在页面中使用主题样式

在具体页面组件中,借助 Tailwind CSSdark 变体,动态应用不同的主题样式:

const PageComponent = () => {
  return (
    <div className="p-4 bg-gray-100 dark:bg-gray-700">
      <h1 className="text-black dark:text-white">Hello, Theme!</h1>
      <p className="text-gray-800 dark:text-gray-300">
        This text changes color based on the theme.
      </p>
    </div>
  );
};

export default PageComponent;

小结

主题开发是桌面端 APP 开发中至关重要的一环。它不仅决定了应用的外观风格,还直接影响用户的使用体验和满意度。通过精心的设计和技术实现,您可以打造出既美观又实用的主题,为您的应用在竞争激烈的市场中增添独特的魅力。

希望本文能为您在桌面端 APP 主题开发的实践中提供一些启发和帮助!


开源项目分享

最近,我正在基于 Tauri 开发一款项目,名为 Coco。目前已开源,项目仍在不断完善中,欢迎大家前往支持并为项目点亮免费的 star 🌟!

作为个人的第一个 Tauri 项目,开发过程中也是边探索边学习。希望能与志同道合的朋友一起交流经验、共同成长!

代码中如有问题或不足之处,期待小伙伴们的宝贵建议和指导!

非常感谢您的支持与关注!