סקיצה איך ליישם Theming ב- wpf בצורה קלה ונוחה
-
הרעיון שלי איך ליישם 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; } } }
-
@pcinfogmach כתב בסקיצה איך ליישם Theming ב- wpf בצורה קלה ונוחה:
מכיוון שהצורך שלי היה ב-Dark Mode, יצרתי קוד שמזהה באופן אוטומטי אם המערכת מוגדרת ל-Dark Theme.
בנוסף, הוספתי כפתור שמאפשר מעבר בין Dark Mode ל-Light Mode.אגב, על AvaloniaUI שמעת? אם לא, אני ממליץ לך בחום להגר לשם (זה מאוד קל למפתחי wpf), חוץ מזה שזה חוצה פלטפורמות ועוד המון פיצ'רים, יש שם גם dark-mode שמובנה ומזוהה אוטומטית בהתאם להגדרות המחשב.
-
@קומפיונט
תודה על תגובה זו ועל שאר תגובותיך המועילות כל כך.
אני מכיר את avalonia ואני ודה לך שהסבת את שתומת לבי שוב אל זה. אכן זה נראה אופציה טובה.
תכלס, מכיוון שאני בעיקר מפתח תוספים לוורד אני תקוע עם wpf המקורי (ועם .net framework). או שאני טועה? (יש לי פרוייקטים אחרים גם אבל זה עיקר העיסוק שלי וממילא עיקר מוקד העניין שלי).סתם ככה היה לי הרגשה של "כמעט" כשהתעסקתי עם avalonia לא באתי ללכלך אבל מסקרן אותי כמה ההרגשה הזו אישית והאם יש בה ממש.
אם כבר אנחנו מדברים על spinoff של wpf מה אתה אומר על uno? לא ניסיתי את זה מעניין לשמוע ממישהו שכן ניסה אם יש?
לגופו של פוסט הסיבה שפיתחתי את החלון הנ"ל היה לצורך פיתוח UI שלא נמצא ב-avalonia ובשום מקום אחר. אז אמרתי לעצמי היי בוא נעשה מדריך יפה מזה.
-
@pcinfogmach כתב בסקיצה איך ליישם Theming ב- wpf בצורה קלה ונוחה:
אם כבר אנחנו מדברים על spinoff של wpf מה אתה אומר על uno? לא ניסיתי את זה מעניין לשמוע ממישהו שכן ניסה אם יש?
לא התנסתי ב- uno, אבל נדמה לי שזה שילוב פלטפורמות קיימות של microsoft לבסיס אחד, זה לא באמת ממשק משתמש שנכתב מאפס, יש גם את maui. אני חושב ש - avalonia מתמקדת יותר בפיתוח desktop חוצה פלטפורמות, ופחות ל - mobile (כיום יש תמיכה רחבה גם ל - mobile אבל בעבר זו לא היה יציב)
סתם ככה היה לי הרגשה של "כמעט" כשהתעסקתי עם avalonia לא באתי ללכלך אבל מסקרן אותי כמה ההרגשה הזו אישית והאם יש בה ממש.
אני כיום מפתח עבור desktop רק עם avalonia בלי מחשבה לחזור אי פעם ל wpf.
-
התוכנת תרגום שפורסמה כאן בנויה עם avalonia, (אם יהיה לי פנאי אני אפרסם בקרוב גרסה חדשה של התוכנה שכוללת גם שינוי עיצובי)
אם אתה רוצה לראות סתם גלריית theme שבנויה עם avalonia, תוריד את זה: (צריך להיות מחובר לחשבון GitHub)
https://github.com/kikipoulet/SukiUI/actions/runs/12388950546/artifacts/2336318341