前言
标题灵感来源于一则经典广告词: “白天服白片,不瞌睡;晚上服黑片,睡得香。”
不知道大家是否还记得 “白加黑” ?我小时候还吃过呢!不过,这款药现在似乎已经停产了,具体原因就不多说了,免得跑题了 😄
如图所示,这是 Mac 系统的外观主题设置,主要分为三种模式:Light、Dark、Auto。
其中,Light 和 Dark 是核心主题,而 Auto 模式会根据时间自动切换:白天使用 Light 模式,晚上切换为 Dark 模式(对应我的标题,😄)。
接下来,让我们具体聊一聊 Light 和 Dark 两种主题的区别。
Light 和 Dark 对比
对比维度 | Light 主题 | Dark 主题 |
---|---|---|
颜色基调 | - 浅色为主,背景通常为白色或浅灰色 - 文本为深色,如黑色或深灰色 | - 深色为主,背景通常为黑色、深灰色 - 文本为浅色,如白色或浅灰色 |
视觉疲劳 | - 长时间使用可能在低光环境中刺眼 - 在强光环境下较为舒适 | - 暗光环境下减少亮度刺激,减轻眼睛疲劳 - 强光环境中可能显得不够清晰 |
对比度和可读性 | - 对比度较低(暗色文本对浅色背景),适合文本密集场景 - 可读性好,易于适应 | - 对比度较高(浅色文本对深色背景),适合眼睛敏感用户 - 图形或复杂界面显示更清晰 |
美观与情感 | - 传递清新、明快感 - 适合文档、办公类应用,显得正式、专业 | - 传递科技感、现代感 - 适合多媒体、编程类应用,显得酷炫、前卫 |
能耗 | - OLED/AMOLED 屏幕:白色像素消耗更多能量 - LCD 屏幕:与 Dark 主题差异不大 | - OLED/AMOLED 屏幕:深色像素更节能 - LCD 屏幕:与 Light 主题差异不大 |
使用场景 | - 日常办公、文档阅读、新闻浏览等 - 适合普通用户和光线充足的环境 | - 程序开发、设计工作、视频编辑、娱乐等 - 适合对眼睛疲劳敏感的用户和暗光环境 |
适用时间 | - 白天或光线充足的时间段 | - 夜晚或光线较暗的时间段 |
效果图 |
最佳实践:提供主题切换功能,让用户根据环境和个人偏好自由选择。
实践
用户设置界面
为了满足用户的个性化需求,我们可以设计一个设置页面,让用户根据自己的喜好自由调整界面主题。通过该设置页面,用户不仅可以选择偏好的 Light 或 Dark 主题,还可以切换到 Auto 模式,或者默认为Auto 模式。用户只需要设置电脑的主题,App 自动跟随。
代码实现部分:
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>
)
}
核心逻辑梳理
在编写代码前,先理清核心逻辑,有助于后续开发更加高效:
- 自动模式(Auto)
如果用户选择了 Auto 模式,App 将根据电脑系统的主题设置自动切换。 - 监听系统主题
在 Auto 模式下,需要监听系统主题的变化,以便同步更新 App 的主题。 - 用户自定义模式
如果用户选择了明确的主题(Light 或 Dark),App 将优先按照用户的设置渲染界面,忽略系统设置。 - 同步更新 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;
}
在前端实现主题切换时,可以通过 ThemeProvider 和 Tailwind 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 CSS 的 dark
变体,动态应用不同的主题样式:
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 项目,开发过程中也是边探索边学习。希望能与志同道合的朋友一起交流经验、共同成长!
代码中如有问题或不足之处,期待小伙伴们的宝贵建议和指导!
- 官网: coco.rs/
- 前端仓库: github.com/infinilabs/…
- 服务端仓库: github.com/infinilabs/…
非常感谢您的支持与关注!