From 6c11bcdd9403f06e26ef5b42ba52cc652896fa2f Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Sun, 24 Nov 2024 18:46:57 -0800 Subject: [PATCH 1/2] Adding a new CustomColor theme option This allows for setting arbitrary primary and secondary colors for the theme rather than being limited the material design color palates. This also allows setting a Dark/Light palate color in a very similar API to the built in Fluent theme. This will hopefully be familiar to anyone looking to migrate from it. This also moves the base theme properties down in the the MaterialThemeBase to allow for more generic changing between light and dark themes. This is technically a breaking change for anyone who may be rolling their own derived classes. I also updated the demo application with commented out usages as well. This should allow for easy testing and can serve as an example for anyone looking to implement it. --- Material.Avalonia.Demo/App.axaml | 27 ++- Material.Avalonia.Demo/GlobalCommand.cs | 4 +- Material.Styles/Themes/ColorTheme.cs | 211 ++++++++++++++++++ Material.Styles/Themes/ColorThemeResources.cs | 9 + Material.Styles/Themes/MaterialTheme.cs | 17 -- Material.Styles/Themes/MaterialThemeBase.cs | 21 ++ 6 files changed, 266 insertions(+), 23 deletions(-) create mode 100644 Material.Styles/Themes/ColorTheme.cs create mode 100644 Material.Styles/Themes/ColorThemeResources.cs diff --git a/Material.Avalonia.Demo/App.axaml b/Material.Avalonia.Demo/App.axaml index fafb075d..b10d6bc6 100644 --- a/Material.Avalonia.Demo/App.axaml +++ b/Material.Avalonia.Demo/App.axaml @@ -1,10 +1,10 @@ - + xmlns:themes="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"> fonts:Inter#Inter, $Default @@ -24,6 +24,25 @@ + + + + + diff --git a/Material.Avalonia.Demo/GlobalCommand.cs b/Material.Avalonia.Demo/GlobalCommand.cs index 54cf9724..6388aa74 100644 --- a/Material.Avalonia.Demo/GlobalCommand.cs +++ b/Material.Avalonia.Demo/GlobalCommand.cs @@ -7,8 +7,8 @@ namespace Material.Avalonia.Demo; public static class GlobalCommand { - private static readonly MaterialTheme MaterialThemeStyles = - Application.Current!.LocateMaterialTheme(); + private static readonly MaterialThemeBase MaterialThemeStyles = + Application.Current!.LocateMaterialTheme(); public static void UseMaterialUIDarkTheme() { diff --git a/Material.Styles/Themes/ColorTheme.cs b/Material.Styles/Themes/ColorTheme.cs new file mode 100644 index 00000000..b81a3642 --- /dev/null +++ b/Material.Styles/Themes/ColorTheme.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using Material.Styles.Themes.Base; + +namespace Material.Styles.Themes; + +public class ColorTheme : MaterialThemeBase, IDisposable { + + public static readonly StyledProperty PrimaryColorProperty = + AvaloniaProperty.Register(nameof(PrimaryColor)); + + public static readonly StyledProperty SecondaryColorProperty = + AvaloniaProperty.Register(nameof(SecondaryColor)); + + private readonly ITheme _theme = new Theme(); + + private bool _isLoaded; + private IThemeVariantHost? _lastThemeVariantHost; + private IDisposable? _themeUpdateDisposable; + private bool _disposedValue; + + public IDictionary Palettes { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The XAML service provider. + public ColorTheme(IServiceProvider serviceProvider) : base(serviceProvider) { + var palettes = new AvaloniaDictionary(2); + palettes.ForEachItem( + (key, x) => { + if (Owner is not null) { + ((IResourceProvider)x).AddOwner(Owner); + } + + if (key != ThemeVariant.Dark && key != ThemeVariant.Light) { + throw new InvalidOperationException( + $"{nameof(ColorTheme)}.{nameof(ColorTheme.Palettes)} only supports Light and Dark variants."); + } + }, + (_, x) => { + if (Owner is not null) + ((IResourceProvider)x).RemoveOwner(Owner); + }, + () => throw new NotSupportedException("Dictionary reset not supported")); + Palettes = palettes; + + OwnerChanged += OnOwnerChanged; + } + + public Color? PrimaryColor { + get => GetValue(PrimaryColorProperty); + set => SetValue(PrimaryColorProperty, value); + } + + public Color? SecondaryColor { + get => GetValue(SecondaryColorProperty); + set => SetValue(SecondaryColorProperty, value); + } + private void OnOwnerChanged(object? sender, EventArgs e) { + RegisterActualThemeObservable(); + } + + protected override bool TryGetResource(object key, ThemeVariant? theme, out object? value) { + return base.TryGetResource(key, theme, out value) + || base.TryGetResource(key, GetVariantFromMaterialBaseThemeMode(ActualBaseTheme), out value); + } + + private static ThemeVariant GetVariantFromMaterialBaseThemeMode(BaseThemeMode variant) { + return variant switch { + BaseThemeMode.Light => Theme.MaterialLight, + BaseThemeMode.Dark => Theme.MaterialDark, + _ => throw new ArgumentOutOfRangeException(nameof(variant), variant, null) + }; + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { + base.OnPropertyChanged(change); + + if (change.Property == BaseThemeProperty) { + SetupActualTheme(); + return; + } + + if (change.Property == ActualBaseThemeProperty) { + var baseTheme = change.GetNewValue().GetBaseTheme(); + _theme.SetBaseTheme(baseTheme); + if (GetPrimaryColor() is { } primaryColor) { + _theme.SetPrimaryColor(primaryColor); + } + if (GetSecondaryColor() is { } secondaryColor) { + _theme.SetSecondaryColor(secondaryColor); + } + EnqueueThemeUpdate(); + return; + } + + if (change.Property == PrimaryColorProperty) { + if (GetPrimaryColor() is { } primaryColor) { + _theme.SetPrimaryColor(primaryColor); + EnqueueThemeUpdate(); + } + return; + } + + if (change.Property == SecondaryColorProperty) { + if (GetSecondaryColor() is { } secondaryColor) { + _theme.SetSecondaryColor(secondaryColor); + EnqueueThemeUpdate(); + } + } + } + + private Color? GetPrimaryColor() { + Color? color = null; + if (GetActualThemeVariant() is { } themeVariant && + Palettes.TryGetValue(themeVariant, out var colorPalate)) { + color = colorPalate.PrimaryColor; + } + return color ?? PrimaryColor; + } + + private Color? GetSecondaryColor() { + Color? color = null; + if (GetActualThemeVariant() is { } themeVariant && + Palettes.TryGetValue(themeVariant, out var colorPalate)) { + color = colorPalate.SecondaryColor; + } + return color ?? SecondaryColor; + } + + private void EnqueueThemeUpdate() { + if (!_isLoaded) + return; + + _themeUpdateDisposable?.Dispose(); + _themeUpdateDisposable = DispatcherTimer.RunOnce(() => CurrentTheme = _theme, TimeSpan.FromMilliseconds(100)); + } + + private void RegisterActualThemeObservable() { + if (_lastThemeVariantHost is not null) + _lastThemeVariantHost.ActualThemeVariantChanged -= HostOnActualThemeVariantChanged; + + _lastThemeVariantHost = Owner as IThemeVariantHost; + if (_lastThemeVariantHost is not null) + _lastThemeVariantHost.ActualThemeVariantChanged += HostOnActualThemeVariantChanged; + SetupActualTheme(); + } + + private void HostOnActualThemeVariantChanged(object? sender, EventArgs e) { + SetupActualTheme(); + } + + private void SetupActualTheme() { + var materialBaseThemeModeFromVariant = BaseTheme switch { + BaseThemeMode.Inherit => GetMaterialBaseThemeModeFromVariant(_lastThemeVariantHost?.ActualThemeVariant) ?? BaseThemeMode.Light, + BaseThemeMode.Light => BaseThemeMode.Light, + BaseThemeMode.Dark => BaseThemeMode.Dark, + _ => throw new ArgumentOutOfRangeException(nameof(BaseTheme), BaseTheme, null) + }; + + ActualBaseTheme = materialBaseThemeModeFromVariant; + } + + private static BaseThemeMode? GetMaterialBaseThemeModeFromVariant(ThemeVariant? variant) { + while (true) { + if (variant is null) + return null; + if (variant == ThemeVariant.Light) + return BaseThemeMode.Light; + if (variant == ThemeVariant.Dark) + return BaseThemeMode.Dark; + variant = variant.InheritVariant; + } + } + + private ThemeVariant? GetActualThemeVariant() { + return ActualBaseTheme switch { + BaseThemeMode.Light => ThemeVariant.Light, + BaseThemeMode.Dark => ThemeVariant.Dark, + _ => null + }; + } + + protected override ITheme ProvideInitialTheme() { + _isLoaded = true; + return _theme; + } + + protected virtual void Dispose(bool disposing) { + if (!_disposedValue) { + if (disposing) { + _themeUpdateDisposable?.Dispose(); + } + + _disposedValue = true; + } + } + + public void Dispose() { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/Material.Styles/Themes/ColorThemeResources.cs b/Material.Styles/Themes/ColorThemeResources.cs new file mode 100644 index 00000000..678bb2b7 --- /dev/null +++ b/Material.Styles/Themes/ColorThemeResources.cs @@ -0,0 +1,9 @@ +using Avalonia.Media; + +namespace Material.Styles.Themes; + +public class ColorThemeResources { + public Color? PrimaryColor { get; set; } + + public Color? SecondaryColor { get; set; } +} diff --git a/Material.Styles/Themes/MaterialTheme.cs b/Material.Styles/Themes/MaterialTheme.cs index 61f9e8cb..be33019c 100644 --- a/Material.Styles/Themes/MaterialTheme.cs +++ b/Material.Styles/Themes/MaterialTheme.cs @@ -13,8 +13,6 @@ namespace Material.Styles.Themes { /// You need to setup all these properties: , , /// public class MaterialTheme : MaterialThemeBase, IDisposable { - public static readonly StyledProperty BaseThemeProperty = - AvaloniaProperty.Register(nameof(BaseTheme)); public static readonly StyledProperty PrimaryColorProperty = AvaloniaProperty.Register(nameof(PrimaryColor)); @@ -22,13 +20,8 @@ public class MaterialTheme : MaterialThemeBase, IDisposable { public static readonly StyledProperty SecondaryColorProperty = AvaloniaProperty.Register(nameof(SecondaryColor)); - public static readonly DirectProperty ActualBaseThemeProperty = - AvaloniaProperty.RegisterDirect( - nameof(ActualBaseTheme), - o => o.ActualBaseTheme); private readonly ITheme _theme = new Theme(); - private BaseThemeMode _actualBaseTheme; private bool _isLoaded; private IThemeVariantHost? _lastThemeVariantHost; private IDisposable? _themeUpdateDisposable; @@ -41,16 +34,6 @@ public MaterialTheme(IServiceProvider serviceProvider) : base(serviceProvider) { OwnerChanged += OnOwnerChanged; } - public BaseThemeMode BaseTheme { - get => GetValue(BaseThemeProperty); - set => SetValue(BaseThemeProperty, value); - } - - public BaseThemeMode ActualBaseTheme { - get => _actualBaseTheme; - private set => SetAndRaise(ActualBaseThemeProperty, ref _actualBaseTheme, value); - } - public PrimaryColor PrimaryColor { get => GetValue(PrimaryColorProperty); set => SetValue(PrimaryColorProperty, value); diff --git a/Material.Styles/Themes/MaterialThemeBase.cs b/Material.Styles/Themes/MaterialThemeBase.cs index 856eb2d9..c20444c5 100644 --- a/Material.Styles/Themes/MaterialThemeBase.cs +++ b/Material.Styles/Themes/MaterialThemeBase.cs @@ -12,10 +12,20 @@ using Avalonia.Styling; using Avalonia.Threading; using Material.Styles.Internal; +using Material.Styles.Themes.Base; namespace Material.Styles.Themes; public class MaterialThemeBase : Avalonia.Styling.Styles, IResourceNode { + + public static readonly StyledProperty BaseThemeProperty = + AvaloniaProperty.Register(nameof(BaseTheme)); + + public static readonly DirectProperty ActualBaseThemeProperty = + AvaloniaProperty.RegisterDirect( + nameof(ActualBaseTheme), + o => o.ActualBaseTheme); + public static readonly DirectProperty CurrentThemeProperty = AvaloniaProperty.RegisterDirect( nameof(CurrentTheme), @@ -25,6 +35,7 @@ public class MaterialThemeBase : Avalonia.Styling.Styles, IResourceNode { private readonly IServiceProvider? _serviceProvider; private readonly LightweightSubject _themeChangedEndSubject = new(); private IReadOnlyTheme _currentTheme = new ReadOnlyTheme(); + private BaseThemeMode _actualBaseTheme; private Task? _currentThemeUpdateTask; private IResourceDictionary? _internalResources; @@ -41,6 +52,16 @@ public MaterialThemeBase(IServiceProvider? serviceProvider) { _serviceProvider = serviceProvider; } + public BaseThemeMode BaseTheme { + get => GetValue(BaseThemeProperty); + set => SetValue(BaseThemeProperty, value); + } + + public BaseThemeMode ActualBaseTheme { + get => _actualBaseTheme; + protected set => SetAndRaise(ActualBaseThemeProperty, ref _actualBaseTheme, value); + } + /// /// Get or set current applied theme /// From 920142291f79371dfbeefaf87054822f20d78d52 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Mon, 25 Nov 2024 22:08:21 -0800 Subject: [PATCH 2/2] Rename to CustomMaterialTheme --- Material.Avalonia.Demo/App.axaml | 18 +++++++++--------- .../{ColorTheme.cs => CustomMaterialTheme.cs} | 10 +++++----- ...rces.cs => CustomMaterialThemeResources.cs} | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) rename Material.Styles/Themes/{ColorTheme.cs => CustomMaterialTheme.cs} (93%) rename Material.Styles/Themes/{ColorThemeResources.cs => CustomMaterialThemeResources.cs} (78%) diff --git a/Material.Avalonia.Demo/App.axaml b/Material.Avalonia.Demo/App.axaml index b10d6bc6..d3e1341f 100644 --- a/Material.Avalonia.Demo/App.axaml +++ b/Material.Avalonia.Demo/App.axaml @@ -25,24 +25,24 @@ - + - + + --> diff --git a/Material.Styles/Themes/ColorTheme.cs b/Material.Styles/Themes/CustomMaterialTheme.cs similarity index 93% rename from Material.Styles/Themes/ColorTheme.cs rename to Material.Styles/Themes/CustomMaterialTheme.cs index b81a3642..d0f2c740 100644 --- a/Material.Styles/Themes/ColorTheme.cs +++ b/Material.Styles/Themes/CustomMaterialTheme.cs @@ -10,7 +10,7 @@ namespace Material.Styles.Themes; -public class ColorTheme : MaterialThemeBase, IDisposable { +public class CustomMaterialTheme : MaterialThemeBase, IDisposable { public static readonly StyledProperty PrimaryColorProperty = AvaloniaProperty.Register(nameof(PrimaryColor)); @@ -25,14 +25,14 @@ public class ColorTheme : MaterialThemeBase, IDisposable { private IDisposable? _themeUpdateDisposable; private bool _disposedValue; - public IDictionary Palettes { get; } + public IDictionary Palettes { get; } /// /// Initializes a new instance of the class. /// /// The XAML service provider. - public ColorTheme(IServiceProvider serviceProvider) : base(serviceProvider) { - var palettes = new AvaloniaDictionary(2); + public CustomMaterialTheme(IServiceProvider serviceProvider) : base(serviceProvider) { + var palettes = new AvaloniaDictionary(2); palettes.ForEachItem( (key, x) => { if (Owner is not null) { @@ -41,7 +41,7 @@ public ColorTheme(IServiceProvider serviceProvider) : base(serviceProvider) { if (key != ThemeVariant.Dark && key != ThemeVariant.Light) { throw new InvalidOperationException( - $"{nameof(ColorTheme)}.{nameof(ColorTheme.Palettes)} only supports Light and Dark variants."); + $"{nameof(CustomMaterialTheme)}.{nameof(CustomMaterialTheme.Palettes)} only supports Light and Dark variants."); } }, (_, x) => { diff --git a/Material.Styles/Themes/ColorThemeResources.cs b/Material.Styles/Themes/CustomMaterialThemeResources.cs similarity index 78% rename from Material.Styles/Themes/ColorThemeResources.cs rename to Material.Styles/Themes/CustomMaterialThemeResources.cs index 678bb2b7..1038240d 100644 --- a/Material.Styles/Themes/ColorThemeResources.cs +++ b/Material.Styles/Themes/CustomMaterialThemeResources.cs @@ -2,7 +2,7 @@ namespace Material.Styles.Themes; -public class ColorThemeResources { +public class CustomMaterialThemeResources { public Color? PrimaryColor { get; set; } public Color? SecondaryColor { get; set; }