Files
ShrlAlgoToolkit/NeuWPF/NeoUI/Appearance/ThemeManager.cs
ShrlAlgo 955a01f564 整理
2025-08-20 12:10:35 +08:00

520 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Reflection;
using System.Windows.Media.Animation;
using System.Collections;
namespace NeoUI.Appearance;
/// <summary>
/// 主题管理核心类:负责
/// 1. 维护当前明暗模式 + 调色板状态_activeThemeMode/_activeThemePalette
/// 2. 通过替换 Application.Resources.MergedDictionaries 中的主题/调色板 ResourceDictionary 实现运行时切换
/// 3. 可选颜色平滑动画(针对已有 SolidColorBrush 实例)
/// 4. 确保同一时刻仅存在一个主题字典 + 一个调色板字典,避免覆盖顺序错误
///
/// 重要概念:
/// - “主题字典” (Light.xaml / Dark.xaml) 定义基础背景 / 文本 / 语义色等
/// - “调色板字典” (LightBlue.xaml / DarkBlue.xaml 等) 定义品牌主色系Primary*
/// - WPF 资源解析为“后添加优先覆盖前面”,因此顺序必须:主题 -> 调色板 -> 其它控件样式
/// - 控件 XAML 需使用 DynamicResource 才能在字典替换时刷新StaticResource 仅解析一次
/// </summary>
public class ThemeManager
{
/// <summary>当前库程序集名称,用于组合 pack:// 路径</summary>
public static string LibraryNamespace => Assembly.GetExecutingAssembly().GetName().Name;
/// <summary>主题资源根路径(基础 + 调色板都基于此)</summary>
public static string ThemesDictionaryPath => $"pack://application:,,,/{LibraryNamespace};component/Themes/";
/// <summary>内部缓存:当前明暗模式(避免重复加载)</summary>
private static ThemeMode _activeThemeMode = ThemeMode.Light;
/// <summary>内部缓存:当前调色板</summary>
private static ThemePalette _activeThemePalette = ThemePalette.Blue;
// 持久化开关:通过 EnablePersistence 控制是否写入文件
private static bool _persistenceEnabled;
/// <summary>主题模式变更事件(成功应用后才触发)</summary>
public static event Action<ThemeMode>? ThemeModeChanged;
/// <summary>主题调色板变更事件(成功应用后才触发)</summary>
public static event Action<ThemePalette>? ThemePaletteChanged;
#region Animation
/// <summary>
/// 对已有 SolidColorBrush 做颜色渐变动画。
/// 注意:它仅改变 Brush 实例的 Color不负责替换资源字典动画完成后再真正替换字典可保证
/// - 已绑定此 Brush 的控件看到平滑过渡
/// - 替换字典后新增键/非 Brush 资源被更新
/// 限制:冻结 (Frozen) 的 Brush 无法动画(会直接跳过)
/// </summary>
private static Task AnimateBrushColorAsync(SolidColorBrush brush, Color toColor, int durationMs = 500)
{
if (brush.IsFrozen) return Task.CompletedTask; // 设计或某些场景可能出现冻结,如手动 Freeze()
var tcs = new TaskCompletionSource<bool>();
var anim = new ColorAnimation
{
To = toColor,
Duration = TimeSpan.FromMilliseconds(durationMs),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut },
FillBehavior = FillBehavior.HoldEnd // 保留最终值(防止回弹)
};
// Completed 总是回到 UI 线程上下文
anim.Completed += (_, _) => tcs.TrySetResult(true);
brush.BeginAnimation(SolidColorBrush.ColorProperty, anim);
return tcs.Task;
}
#endregion
#region Public Query
/// <summary>
/// 解析 Application 资源中当前主题字典(文件名包含 light.xaml / dark.xaml以同步内部缓存。
/// 说明:外部调用可能绕过本类直接替换资源,因此这里以资源实际状态为准。
/// </summary>
public static ThemeMode GetAppThemeMode()
{
var dict = new ResourceDictionaryManager().LookupDictionary("themes");
if (dict?.Source != null)
{
var uri = dict.Source.ToString();
if (uri.IndexOf("light.xaml", StringComparison.OrdinalIgnoreCase) >= 0)
_activeThemeMode = ThemeMode.Light;
else if (uri.IndexOf("dark.xaml", StringComparison.OrdinalIgnoreCase) >= 0)
_activeThemeMode = ThemeMode.Dark;
}
return _activeThemeMode;
}
/// <summary>
/// 同上:根据当前调色板字典文件名更新内部缓存。
/// 注意:调色板文件名包含 Light/Dark 前缀 + 颜色名,例如 LightBlue.xaml / DarkBlue.xaml
/// </summary>
public static ThemePalette GetAppThemePalette()
{
var dict = new ResourceDictionaryManager().LookupDictionary("ColorPalette");
if (dict?.Source != null)
{
var uri = dict.Source.ToString();
if (uri.IndexOf("Blue.xaml", StringComparison.OrdinalIgnoreCase) >= 0)
_activeThemePalette = ThemePalette.Blue;
else if (uri.IndexOf("Green.xaml", StringComparison.OrdinalIgnoreCase) >= 0)
_activeThemePalette = ThemePalette.Green;
else if (uri.IndexOf("Purple.xaml", StringComparison.OrdinalIgnoreCase) >= 0)
_activeThemePalette = ThemePalette.Purple;
}
return _activeThemePalette;
}
#endregion
/// <summary>
/// 将当前 Application 级主题资源合并到某个单独窗口/控件的 Resources用于隔离场景提前绑定
/// 通常不需要调用;只有在局部字典需要显式继承应用级主题时使用。
/// </summary>
public static void ApplyResourcesToElement(FrameworkElement frameworkElement)
{
if (frameworkElement == null) return;
var appMerged = Current.Resources.MergedDictionaries;
var target = frameworkElement.Resources.MergedDictionaries;
// 简单判断:数量少于应用就尝试补齐(不会去重深比较)
if (target.Count < appMerged.Count)
{
foreach (var d in appMerged)
if (!target.Contains(d))
target.Add(d);
}
}
#region Unified Theme API
/// <summary>
/// 异步统一入口:可同时改变明暗模式 + 调色板。只传 mode 或 palette 代表单独改变。
/// animate=true 启用颜色渐变(注意:仅对已经存在的 SolidColorBrush 有效果)。
/// </summary>
public static Task ApplyThemeAsync(ThemeMode? mode = null,
ThemePalette? palette = null,
bool animate = false,
int animationDurationMs = 350)
=> InternalApplyThemeAsync(mode, palette, animate, animationDurationMs);
/// <summary>
/// 同步封装(内部实际仍走异步,只是阻塞等待)。
/// UI 线程中调用时不建议长动画阻塞;如果需要平滑体验请直接使用 await ApplyThemeAsync。
/// </summary>
public static void ApplyTheme(ThemeMode? mode = null,
ThemePalette? palette = null,
bool animate = false,
int animationDurationMs = 350)
=> InternalApplyThemeAsync(mode, palette, animate, animationDurationMs).GetAwaiter().GetResult();
/// <summary>
/// 主题/调色板应用核心逻辑:
/// 1. 计算是否真的发生变化(避免重复加载)
/// 2. 预加载新字典(不合并)用于取目标颜色参与动画
/// 3. 可选对现有 Brush 做动画(不创建新实例,避免引用断开)
/// 4. 替换 ResourceDictionary删除旧 -> 添加新,保证唯一顺序)
/// 5. 更新内部状态并触发事件
/// </summary>
private static async Task InternalApplyThemeAsync(ThemeMode? mode,
ThemePalette? palette,
bool animate,
int durationMs)
{
// Step1: 获取真实当前状态(可能被外部改动过)
var currentMode = GetAppThemeMode();
var currentPalette = GetAppThemePalette();
// Step2: 计算目标(未传则沿用现值)
var newMode = mode ?? currentMode;
var newPalette = palette ?? currentPalette;
// Step3: 判断变化
// 明暗模式改变时对应调色板文件LightBlue -> DarkBlue必然需要重新加载即便颜色名相同
bool modeChanged = newMode != currentMode;
bool paletteChanged = newPalette != currentPalette || modeChanged;
if (!modeChanged && !paletteChanged) return; // 无变化直接结束
// Step4: 组装文件名当前命名约定Dark/Light + 调色板名)
string modeName = newMode == ThemeMode.Dark ? "Dark" : "Light";
string paletteName = newPalette switch
{
ThemePalette.Blue => "Blue",
ThemePalette.Green => "Green",
ThemePalette.Purple => "Purple",
_ => "Blue" // 兜底,理论不应走到
};
// Step5: 构造 Uripack://
var themeUri = new Uri($"{ThemesDictionaryPath}{modeName}.xaml", UriKind.Absolute);
var paletteUri = new Uri($"{ThemesDictionaryPath}ColorPalette/{modeName}{paletteName}.xaml", UriKind.Absolute);
// Step6: 预加载字典(此时资源已经解析,可取得目标 Brush 颜色)
ResourceDictionary? newThemeDict = null;
ResourceDictionary? newPaletteDict = null;
if (modeChanged) newThemeDict = new ResourceDictionary { Source = themeUri };
if (paletteChanged) newPaletteDict = new ResourceDictionary { Source = paletteUri };
// Step7: 动画(仅针对已有 Brush
if (animate)
{
var tasks = new List<Task>();
if (newThemeDict != null) CollectBrushAnimations(newThemeDict, tasks, durationMs);
if (newPaletteDict != null) CollectBrushAnimations(newPaletteDict, tasks, durationMs);
if (tasks.Count > 0) await Task.WhenAll(tasks);
// 动画完成后,将已动画的旧 Brush 实例复用到新字典,避免字典替换产生新实例导致“跳变”
if (newThemeDict != null) ReuseAnimatedBrushInstances(newThemeDict);
if (newPaletteDict != null) ReuseAnimatedBrushInstances(newPaletteDict);
}
// Step8: 真正替换 / 添加字典(保证唯一 + 顺序)
if (modeChanged) ReplaceOrAddDictionary(themeUri, isTheme: true);
if (paletteChanged) ReplaceOrAddDictionary(paletteUri, isTheme: false);
// Step9: 更新内部状态 & 发事件(只在实际变更时触发)
if (modeChanged)
{
_activeThemeMode = newMode;
ThemeModeChanged?.Invoke(newMode);
}
if (paletteChanged)
{
_activeThemePalette = newPalette;
ThemePaletteChanged?.Invoke(newPalette);
}
// Step10: 可选持久化(只要实际有改变才写)
if (_persistenceEnabled && (modeChanged || paletteChanged))
{
try { ThemePreferenceStore.Save(_activeThemeMode, _activeThemePalette); } catch { /* 忽略 */ }
}
#if DEBUG
// 调试辅助:输出当前合并字典列表,便于确认顺序是否正确,以及是否重复遗留
DumpMerged("AfterApplyTheme");
#endif
}
/// <summary>
/// 为动画阶段收集“可以做颜色过渡”的画刷任务:仅当新字典中存在相同 Key 且旧资源中对应是 SolidColorBrush。
/// 注意:这里不替换 Brush 实例,只修改 Color保持绑定引用稳定。
/// </summary>
private static void CollectBrushAnimations(ResourceDictionary? newDict, List<Task> tasks, int durationMs)
{
var existing = Current.Resources;
foreach (DictionaryEntry entry in newDict)
{
if (entry.Value is SolidColorBrush newBrush && existing[entry.Key] is SolidColorBrush oldBrush && !ReferenceEquals(oldBrush, newBrush))
tasks.Add(AnimateBrushColorAsync(oldBrush, newBrush.Color, durationMs));
}
}
/// <summary>
/// 动画后复用已经过渡完成的旧 Brush 实例,避免替换字典时创建新实例导致闪烁或动画丢失。
/// 仅覆盖同 Key SolidColorBrush其它资源仍使用新字典的对象确保新增键/不同类型可生效)。
/// </summary>
private static void ReuseAnimatedBrushInstances(ResourceDictionary? newDict)
{
var existing = Current.Resources;
var keys = new List<object>();
if (newDict == null) return;
foreach (DictionaryEntry entry in newDict)
{
if (entry.Value is SolidColorBrush && existing[entry.Key] is SolidColorBrush existingBrush)
{
keys.Add(entry.Key);
}
}
foreach (var key in keys)
{
newDict[key] = existing[key]; // 复用经过动画的旧实例
}
}
/// <summary>
/// 核心替换策略:
/// 1. isTheme=true → 删除所有旧的 Light.xaml/Dark.xaml 后再插入新主题(插在所有调色板之前)
/// 2. isTheme=false → 删除所有旧调色板后,在主题后面插入新调色板
/// 保证:不会出现多个主题或多个调色板同时存在;顺序固定;避免旧的 Light 覆盖新 Dark。
/// </summary>
private static void ReplaceOrAddDictionary(Uri uri, bool isTheme)
{
var merged = Current.Resources.MergedDictionaries;
// 判定函数:主题字典(不包含 /ColorPalette/ 且文件名为 Light.xaml 或 Dark.xaml
bool IsThemeDict(ResourceDictionary d)
{
var s = d.Source?.ToString();
return s != null &&
s.IndexOf("/Themes/", StringComparison.OrdinalIgnoreCase) >= 0 &&
s.IndexOf("/ColorPalette/", StringComparison.OrdinalIgnoreCase) < 0 &&
(s.EndsWith("Light.xaml", StringComparison.OrdinalIgnoreCase) ||
s.EndsWith("Dark.xaml", StringComparison.OrdinalIgnoreCase));
}
// 判定函数:调色板字典(路径包含 /Themes/ColorPalette/
bool IsPaletteDict(ResourceDictionary d)
{
var s = d.Source?.ToString();
return s != null &&
s.IndexOf("/Themes/ColorPalette/", StringComparison.OrdinalIgnoreCase) >= 0;
}
if (isTheme)
{
// A. 先删除所有旧主题字典(倒序防止索引错位)
for (int i = merged.Count - 1; i >= 0; i--)
if (IsThemeDict(merged[i]))
merged.RemoveAt(i);
// B. 找到第一个调色板位置(主题应在其前面)
int firstPaletteIndex = -1;
for (int i = 0; i < merged.Count; i++)
if (IsPaletteDict(merged[i]))
{
firstPaletteIndex = i;
break;
}
// C. 插入新主题(在调色板前,保证调色板覆盖可能重复键)
var newTheme = new ResourceDictionary { Source = uri };
if (firstPaletteIndex >= 0)
merged.Insert(firstPaletteIndex, newTheme);
else
merged.Add(newTheme);
}
else
{
// 调色板替换流程:
// 1. 删除旧调色板
for (int i = merged.Count - 1; i >= 0; i--)
if (IsPaletteDict(merged[i]))
merged.RemoveAt(i);
// 2. 定位主题字典(插在它后面)
int themeIndex = -1;
for (int i = 0; i < merged.Count; i++)
if (IsThemeDict(merged[i]))
{
themeIndex = i;
break;
}
// 3. 插入新调色板
var newPalette = new ResourceDictionary { Source = uri };
if (themeIndex >= 0 && themeIndex + 1 <= merged.Count)
merged.Insert(themeIndex + 1, newPalette);
else
merged.Add(newPalette);
}
}
#endregion
#region Backward Compatible APIs
/// <summary>
/// 保留旧命名接口:仅换调色板(内部仍走统一流程)。
/// </summary>
public static void ApplyThemePalette(ThemePalette themePalette) => ApplyTheme(null, themePalette, false);
/// <summary>
/// 保留旧命名接口:仅换明暗模式。
/// </summary>
public static void ApplyThemeMode(ThemeMode themeMode) => ApplyTheme(themeMode, null, false);
/// <summary>
/// 快捷切换 Light Dark保持当前调色板颜色名称但会加载对应模式版本
/// </summary>
public static void SwitchThemeMode()
{
var target = GetAppThemeMode() == ThemeMode.Light ? ThemeMode.Dark : ThemeMode.Light;
ApplyTheme(target, _activeThemePalette, false);
}
// === PATCH START: 新增带动画便捷方法 ===
/// <summary>
/// 仅切换明暗模式(带动画)。
/// </summary>
public static Task SwitchThemeModeAnimatedAsync(int durationMs = 350)
{
var target = GetAppThemeMode() == ThemeMode.Light ? ThemeMode.Dark : ThemeMode.Light;
return ApplyThemeAsync(target, _activeThemePalette, true, durationMs);
}
/// <summary>
/// 同步封装:仅切换明暗模式(带动画)。
/// </summary>
public static void SwitchThemeModeAnimated(int durationMs = 350)
{
SwitchThemeModeAnimatedAsync(durationMs).GetAwaiter().GetResult();
}
/// <summary>
/// 指定模式 + 调色板动画切换(语义糖)。
/// </summary>
public static Task ApplyThemeAnimatedAsync(ThemeMode mode, ThemePalette palette, int durationMs = 350)
=> ApplyThemeAsync(mode, palette, true, durationMs);
// === PATCH END ===
/// <summary>
/// 启用主题持久化(后续每次成功切换会写入本地文件)。
/// 需在 RestorePersistedTheme 之后调用(避免第一次加载又覆盖未应用的老值)。
/// </summary>
public static void EnablePersistence() => _persistenceEnabled = true;
/// <summary>
/// 禁用主题持久化(停止写入文件)。
/// </summary>
public static void DisablePersistence() => _persistenceEnabled = false;
/// <summary>
/// 启动时调用:读取上次用户选择并应用(不启用动画,不触发保存)。
/// 在 App.xaml.cs OnStartup 或 MainWindow 构造最早调用。
/// </summary>
public static void RestorePersistedTheme()
{
ThemeMode? mode;
ThemePalette? palette;
if (ThemePreferenceStore.TryLoad(out mode, out palette) && (mode.HasValue || palette.HasValue))
{
var wasEnabled = _persistenceEnabled;
_persistenceEnabled = false; // 防止恢复时立刻写文件
try { ApplyTheme(mode, palette, animate: false); }
finally { _persistenceEnabled = wasEnabled; }
}
}
#endregion
#region Debug
#if DEBUG
/// <summary>
/// 调试辅助:输出合并字典当前顺序及其 Source用于确认是否存在重复主题或顺序错误。
/// </summary>
private static void DumpMerged(string tag)
{
System.Diagnostics.Debug.WriteLine("==== " + tag + " MergedDictionaries ====");
int i = 0;
foreach (var d in Current.Resources.MergedDictionaries)
System.Diagnostics.Debug.WriteLine($"{i++}: {d.Source}");
}
#endif
#endregion
#region UiApplication ()
private static ThemeManager? _themeManagerInstance;
private readonly Application? application;
private Window mainWindow;
private ResourceDictionary resources;
/// <summary>
/// 私有构造:封装对 Application 的引用;不在此进行主题逻辑(避免启动时副作用)。
/// </summary>
private ThemeManager(Application? application)
{
if (application == null) return;
this.application = application;
System.Diagnostics.Debug.WriteLine($"INFO | {typeof(ThemeManager)} application is {this.application}", ThemeManager.LibraryNamespace);
}
/// <summary>关闭应用(与主题无直接关系,仅做统一封装)</summary>
public void Shutdown() => application?.Shutdown();
/// <summary>尝试获取资源(简单封装)</summary>
public object TryFindResource(object resourceKey) => Resources[resourceKey];
/// <summary>单例访问(懒加载)</summary>
public static ThemeManager? Current
{
get
{
if (_themeManagerInstance == null)
_themeManagerInstance = new ThemeManager(System.Windows.Application.Current);
return _themeManagerInstance;
}
}
/// <summary>主窗口引用(不影响主题;可用于窗口内调用 ApplyResourcesToElement</summary>
public Window MainWindow
{
get => application?.MainWindow ?? mainWindow;
set
{
if (application != null) application.MainWindow = value;
mainWindow = value;
}
}
/// <summary>
/// 返回应用级 ResourceDictionary
/// - 初次访问时构建一个包含 ThemesDictionary(默认 Light) + ControlsDictionary 的集合
/// - ThemesDictionary 会在首次真正切换时被 ReplaceOrAddDictionary 移除,替换为精确主题字典
/// - 这样设计允许:设计器/启动阶段有默认资源,后续切换保证唯一
/// </summary>
public ResourceDictionary Resources
{
get
{
if (resources != null) return application?.Resources ?? resources;
resources = new ResourceDictionary();
try
{
// 注意ThemesDictionary 内部会加载 Light.xaml设计期友好
// 后续切换会删除它并插入新的主题字典。
var themesDictionary = new ThemesDictionary();
var controlsDictionary = new ControlsDictionary();
resources.MergedDictionaries.Add(themesDictionary);
resources.MergedDictionaries.Add(controlsDictionary);
}
catch
{
// 忽略初始化失败,避免启动崩溃(可根据需要添加日志)
}
return application?.Resources ?? resources;
}
set
{
if (application != null) application.Resources = value;
resources = value;
}
}
#endregion
}