הרעיון שלי איך ליישם theming ב- wpf בצורה קלה
שוב אני מוצא את עצמי מתמודד עם פתרונות מסורבלים. לכן יצרתי את הסקיצה הבאה עבור יישום themeing קליל פשוט ונוח (הצורך שלי היה מוגבל ל-Dark Mode, אבל ניתן להרחיב את הרעיון די בקלות).
בבסיס האתגר הזה יש שני נושאים עיקריים:
א. חלון WPF אינו מאפשר שינוי צבע ה-Title Bar.
ב. יצירת Binding שיכול לשמש את כל התוכנה בצורה גלובלית.
את עיצוב החלון כבר יצרתי בעבר, וכעת הפכתי אותו ל-Style. לדעתי, זו גישה הרבה יותר נוחה מאשר ליצור פקד חלון עם Theming, כיוון שהתעסקות עם ירושת פקד חלון ב-WPF היא קצת מורכבת. ישנם מדריכים רבים (עם הרבה באגים) בנושא, תכלס, חסכתי לכם הרבה עבודה אם זה מה שאתם מחפשים ה-style המצורף נקי ועובד חלק.
כמה פרטים חשובים:
ה-Style משתמש במחלקה סטטית שמארחת פקודות לסגירת חלון וכו', וגם מארחת ViewModel גלובלי. זהו פתרון קל ונוח לשימוש לצורך שינוי Theme.
מכיוון שהצורך שלי היה ב-Dark Mode, יצרתי קוד שמזהה באופן אוטומטי אם המערכת מוגדרת ל-Dark Theme.
בנוסף, הוספתי כפתור שמאפשר מעבר בין Dark Mode ל-Light Mode.
עיצוב ה-Theme
יצרתי מחלקה עם שני מאפיינים בסיסיים: Background ו-Foreground. לצרכים שלי זה היה מספיק, אבל אם אתם מחפשים משהו מורחב יותר, פשוט הוסיפו רכיבים למחלקה כיד ה' עליכם.
Theme חדש ניתן להוסיף ל-Dictionary שנמצא ב-ViewModel, והיישום שלו אינטואיטיבי וקל.
בנוסף, תוכלו לעשות Binding על ה-Dictionary ל-ListBox או ComboBox, ולאפשר למשתמש לבחור Theme בקלות.
מכיוון שהמחלקה המארחת היא סטטית, תהיה לכם גישה נוחה ופשוטה ל-Themes מכל מקום בקוד.
זהו פתרון יעיל ופשוט, ואני מאמין שיכול לחסוך לכם הרבה זמן ועבודה בהתמודדות עם אתגרי Theming ב-WPF.
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mscorlib="clr-namespace:System;assembly=mscorlib"
xmlns:window="clr-namespace:MyWpf.Themes.Window;assembly=MyWpf"
xmlns:Converters="clr-namespace:MyWpf.Converters">
<SolidColorBrush x:Key="HoverBackGroundBrush" Color="#ff646464" Opacity="0.2"/>
<SolidColorBrush x:Key="SelectedBackGroundBrush" Color="#ff646464" Opacity="0.3"/>
<SolidColorBrush x:Key="WindowBorderBrush" Color="#ff646464"/>
<SolidColorBrush x:Key="TitlebarBrush" Color="#ff646464" Opacity="0.1"/>
<mscorlib:Double x:Key="CaptionHieght">36</mscorlib:Double>
<Style x:Key="TitleBarButton" TargetType="Button">
<Setter Property="Width" Value="{Binding ActualHeight, RelativeSource={RelativeSource Mode=Self}}"/>
<Setter Property="WindowChrome.IsHitTestVisibleInChrome" Value="True"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<Border x:Name="border" Background="{TemplateBinding Background}"
Padding="10">
<ContentPresenter x:Name="contentPresenter"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="true">
<Setter Property="Background" Value="{StaticResource HoverBackGroundBrush}"/>
</Trigger>
<Trigger Property="AreAnyTouchesOver" Value="true">
<Setter Property="Background" Value="{StaticResource HoverBackGroundBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="true">
<Setter Property="Background" Value="{StaticResource SelectedBackGroundBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="TitleBarPathIcon" TargetType="Path">
<Setter Property="Stroke" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="StrokeThickness" Value="1"/>
<Setter Property="Stretch" Value="Uniform"/>
</Style>
<Style x:Key="DarkModeIconPath" TargetType="Path">
<Setter Property="Fill" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="Stretch" Value="Uniform"/>
</Style>
<Style x:Key="MaximizeRestorePath" TargetType="Path" BasedOn="{StaticResource TitleBarPathIcon}">
<Style.Triggers>
<!-- Trigger when Window is Maximized -->
<DataTrigger Binding="{Binding WindowState, RelativeSource={RelativeSource AncestorType=Window}}" Value="Maximized">
<Setter Property="Data" Value="M 13.5,12.5 H 20.5 V 19.5 H 13.5 Z M 15.5,12.5 V 10.5 H 22.5 V 17.5 H 20.5"/>
</DataTrigger>
<!-- Trigger when Window is Normal (Not Maximized) -->
<DataTrigger Binding="{Binding WindowState, RelativeSource={RelativeSource AncestorType=Window}}" Value="Normal">
<Setter Property="Data" Value="M 13.5,10.5 H 22.5 V 19.5 H 13.5 Z"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style TargetType="Window" x:Key="ThemedWindow">
<Setter Property="WindowChrome.WindowChrome">
<Setter.Value>
<WindowChrome
CornerRadius="{x:Static SystemParameters.WindowCornerRadius}"
UseAeroCaptionButtons="False"
CaptionHeight="{StaticResource CaptionHieght}"/>
</Setter.Value>
</Setter>
<!--<Setter Property="FlowDirection" Value="{Binding Path=(window:ThemeHelper.FlowDirection)}" />-->
<Setter Property="Background" Value="{Binding Source={x:Static window:ThemeHelper.ThemeModel}, Path=CurrentTheme.Background}"/>
<Setter Property="Foreground" Value="{Binding Source={x:Static window:ThemeHelper.ThemeModel}, Path=CurrentTheme.Foreground}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Window">
<Border BorderBrush="{StaticResource WindowBorderBrush}"
BorderThickness="0.8"
Background="{TemplateBinding Background}">
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition x:Name="TitleBarRow" Height="auto"/>
<RowDefinition x:Name="ContentRow" Height="*"/>
</Grid.RowDefinitions>
<Grid x:Name="TitleBarGrid"
Height="{StaticResource CaptionHieght}"
Background="{StaticResource TitlebarBrush}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" x:Name="CaptionColumn"/>
<ColumnDefinition Width="*" x:Name="PlaceHolderColumn"/>
<ColumnDefinition Width="auto" x:Name="TitleBarButtonsColumn"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="WindowTitle" Padding="10" VerticalAlignment="Center" IsHitTestVisible="False"
Text="{TemplateBinding Title}"/>
<StackPanel x:Name="TitleBarButtonsPanel" Orientation="Horizontal" Grid.Column="2" Margin="1">
<Button Style="{StaticResource TitleBarButton}"
Command="{Binding Source={x:Static window:ThemeHelper.ThemeModel}, Path=ToggleThemeCommand}"
CommandParameter="{Binding RelativeSource={RelativeSource AncestorType=Window}}">
<Path Style="{StaticResource DarkModeIconPath}"
Data="M7.5,2C5.71,3.15 4.5,5.18 4.5,7.5C4.5,9.82 5.71,11.85 7.53,13C4.46,13 2,10.54 2,7.5A5.5,5.5 0 0,1 7.5,2M19.07,3.5L20.5,4.93L4.93,20.5L3.5,19.07L19.07,3.5M12.89,5.93L11.41,5L9.97,6L10.39,4.3L9,3.24L10.75,3.12L11.33,1.47L12,3.1L13.73,3.13L12.38,4.26L12.89,5.93M9.59,9.54L8.43,8.81L7.31,9.59L7.65,8.27L6.56,7.44L7.92,7.35L8.37,6.06L8.88,7.33L10.24,7.36L9.19,8.23L9.59,9.54M19,13.5A5.5,5.5 0 0,1 13.5,19C12.28,19 11.15,18.6 10.24,17.93L17.93,10.24C18.6,11.15 19,12.28 19,13.5M14.6,20.08L17.37,18.93L17.13,22.28L14.6,20.08M18.93,17.38L20.08,14.61L22.28,17.15L18.93,17.38M20.08,12.42L18.94,9.64L22.28,9.88L20.08,12.42M9.63,18.93L12.4,20.08L9.87,22.27L9.63,18.93Z"/>
</Button>
<Button Style="{StaticResource TitleBarButton}"
Command="{Binding Source={x:Static window:ThemeHelper.MinimizeCommand}}"
CommandParameter="{Binding RelativeSource={RelativeSource AncestorType=Window}}">
<Path Style="{StaticResource TitleBarPathIcon}" Data="M 13,15 H 23"/>
</Button>
<Button Style="{StaticResource TitleBarButton}"
Command="{Binding Source={x:Static window:ThemeHelper.MaximizeRestoreCommand}}"
CommandParameter="{Binding RelativeSource={RelativeSource AncestorType=Window}}">
<Path Style="{StaticResource MaximizeRestorePath}"/>
</Button>
<Button Style="{StaticResource TitleBarButton}"
Command="{Binding Source={x:Static window:ThemeHelper.CloseWindowCommand}}"
CommandParameter="{Binding RelativeSource={RelativeSource AncestorType=Window}}">
<Path Style="{StaticResource TitleBarPathIcon}" Data="M 13,11 22,20 M 13,20 22,11"/>
</Button>
</StackPanel>
</Grid>
<ContentPresenter x:Name="PART_MainContentPresenter" Grid.Row="1"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="WindowState" Value="Maximized">
<Setter TargetName="RootGrid" Property="Margin" Value="7.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
using MyWpf.Models;
using System.Diagnostics;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
namespace MyWpf.Themes.Window
{
public static class ThemeHelper
{
private static ThemeModel _themeModel;
public static ThemeModel ThemeModel
{
get { if (_themeModel == null)
_themeModel = new ThemeModel();
return _themeModel; }
}
//public static double CaptionHeight { get => SystemParameters.WindowCaptionHeight + 10;}
//public static FlowDirection FlowDirection { get => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "he" ? FlowDirection.RightToLeft : FlowDirection.LeftToRight; }
public static ICommand CloseWindowCommand = new RelayCommand<System.Windows.Window>((window) => window.Close(), (window) => window != null);
public static ICommand MaximizeRestoreCommand = new RelayCommand<System.Windows.Window>((window) => window.WindowState = window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized, (window) => window != null);
public static ICommand MinimizeCommand = new RelayCommand<System.Windows.Window>((window) => window.WindowState = WindowState.Minimized, (window) => window != null);
}
public class ThemeModel : ViewModelBase
{
private bool _isDarkTheme;
private ThemeObject _currentTheme;
public Dictionary<string, ThemeObject> ThemeCollection = new Dictionary<string, ThemeObject>
{
{ "DarkTheme", new ThemeObject{ Background = new SolidColorBrush(Color.FromRgb(34, 34, 34)),
Foreground = new SolidColorBrush(Color.FromRgb(200, 200, 200))} },
{ "LightTheme", new ThemeObject{ Background = new SolidColorBrush(Colors.White),
Foreground = new SolidColorBrush(Colors.Black)} },
};
public ThemeObject CurrentTheme { get => _currentTheme; set => SetProperty(ref _currentTheme, value); }
public ICommand ToggleThemeCommand => new RelayCommand<System.Windows.Window>(ToggleTheme, (window) => window != null);
public ThemeModel()
{
_isDarkTheme = GetSystemTheme;
_currentTheme = _isDarkTheme ? ThemeCollection["DarkTheme"] : ThemeCollection["LightTheme"];
}
public void ToggleTheme(System.Windows.Window window)
{
CurrentTheme = _isDarkTheme ? ThemeCollection["LightTheme"] : ThemeCollection["DarkTheme"];
_isDarkTheme = !_isDarkTheme;
}
static bool GetSystemTheme
{
get
{
string registryKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
string registryValueName = "AppsUseLightTheme";
try
{
using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(registryKeyPath))
if (key?.GetValue(registryValueName) is int value)
return value == 0; // 0 means Dark Theme is enabled, 1 means Light Theme
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
return false;
}
}
public class ThemeObject
{
public SolidColorBrush Background { get; set; }
public SolidColorBrush Foreground { get; set; }
}
}
}
הקוד משתמש בשני מחלקות שימושיות המהוות בסיס טוב עבור כל viewmodel
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace MyWpf.Models
{
public class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
}
using System;
using System.Windows.Input;
namespace MyWpf.Models
{
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool> _canExecute;
public RelayCommand(Action execute, Func<bool> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object parameter) => _execute();
public event EventHandler CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
}
public class RelayCommand<T> : ICommand
{
private readonly Action<T> _execute;
private readonly Func<T, bool> _canExecute;
public RelayCommand(Action<T> execute, Func<T, bool> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object parameter) => _canExecute?.Invoke((T)parameter) ?? true;
public void Execute(object parameter) => _execute((T)parameter);
public event EventHandler CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
}
}