diff --git a/MaterialColorUtilities.Maui.Tests/DynamicColorServiceTests.cs b/MaterialColorUtilities.Maui.Tests/DynamicColorServiceTests.cs new file mode 100644 index 0000000..d34cb01 --- /dev/null +++ b/MaterialColorUtilities.Maui.Tests/DynamicColorServiceTests.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Options; + +namespace MaterialColorUtilities.Maui.Tests; + +[TestClass] +public class DynamicColorServiceTests +{ + private readonly ISeedColorService _seedColorService = new MockSeedColorService(); + private readonly Application _application = new(); + private readonly IPreferences _preferences = new MockPreferences(); + + [TestMethod] + public void DisableTheming() + { + IOptions options = CreateOptions(opt => + { + opt.EnableTheming = false; + }); + + DynamicColorService dynamicColorService = new(options, _seedColorService, _application, _preferences); + + dynamicColorService.Initialize(null); + + Assert.IsFalse(dynamicColorService.EnableTheming); + Assert.IsNull(dynamicColorService.CorePalette); + Assert.IsNull(dynamicColorService.SchemeMaui); + Assert.IsTrue(_application.Resources.Count == 0); + } + + [TestMethod] + public void DisableDynamicColor() + { + IOptions options = CreateOptions(opt => + { + opt.EnableDynamicColor = false; + }); + + DynamicColorService dynamicColorService = new(options, _seedColorService, _application, _preferences); + + dynamicColorService.Initialize(null); + + Assert.IsTrue(dynamicColorService.EnableTheming); + Assert.IsFalse(dynamicColorService.EnableDynamicColor); + Assert.AreEqual(unchecked((int)0xff4285F4), dynamicColorService.Seed); + Assert.AreEqual(unchecked((int)0xff005AC1), dynamicColorService.SchemeInt.Primary); + Assert.AreEqual(dynamicColorService.SchemeInt.Primary, dynamicColorService.SchemeMaui.Primary.ToInt()); + Assert.IsNotNull(_application.Resources[Schemes.Keys.Primary]); + } + + [TestMethod] + public void EnableDynamicColor_SeedColorNull() + { + IOptions options = CreateOptions(); + + DynamicColorService dynamicColorService = new(options, _seedColorService, _application, _preferences); + + dynamicColorService.Initialize(null); + + Assert.IsTrue(dynamicColorService.EnableTheming); + Assert.IsTrue(dynamicColorService.EnableDynamicColor); + Assert.AreEqual(unchecked((int)0xff4285F4), dynamicColorService.Seed); + Assert.AreEqual(unchecked((int)0xff005AC1), dynamicColorService.SchemeInt.Primary); + Assert.AreEqual(dynamicColorService.SchemeInt.Primary, dynamicColorService.SchemeMaui.Primary.ToInt()); + Assert.IsNotNull(_application.Resources[Schemes.Keys.Primary]); + } + + [TestMethod] + public void EnableDynamicColor_SeedColorAvailable() + { + IOptions options = CreateOptions(); + + ISeedColorService seedColorService = new MockSeedColorService(unchecked((int)0xFFc07d52)); + DynamicColorService dynamicColorService = new(options, seedColorService, _application, _preferences); + + dynamicColorService.Initialize(null); + + Assert.AreEqual(unchecked((int)0xFFc07d52), dynamicColorService.Seed); + Assert.AreEqual(unchecked((int)0xFF96490A), dynamicColorService.SchemeInt.Primary); + Assert.AreEqual(dynamicColorService.SchemeInt.Primary, dynamicColorService.SchemeMaui.Primary.ToInt()); + Assert.IsNotNull(_application.Resources[Schemes.Keys.Primary]); + } + + private static IOptions CreateOptions() + { + DynamicColorOptions options = new(); + return Options.Create(options); + } + + private static IOptions CreateOptions(Action configure) + { + DynamicColorOptions options = new(); + configure(options); + return Options.Create(options); + } +} \ No newline at end of file diff --git a/MaterialColorUtilities.Maui.Tests/MaterialColorUtilities.Maui.Tests.csproj b/MaterialColorUtilities.Maui.Tests/MaterialColorUtilities.Maui.Tests.csproj new file mode 100644 index 0000000..a539ba4 --- /dev/null +++ b/MaterialColorUtilities.Maui.Tests/MaterialColorUtilities.Maui.Tests.csproj @@ -0,0 +1,23 @@ + + + + net6.0 + enable + enable + false + true + + + + + + + + + + + + + + + diff --git a/MaterialColorUtilities.Maui.Tests/MockPreferences.cs b/MaterialColorUtilities.Maui.Tests/MockPreferences.cs new file mode 100644 index 0000000..61e497e --- /dev/null +++ b/MaterialColorUtilities.Maui.Tests/MockPreferences.cs @@ -0,0 +1,33 @@ +using System.Text.Json; + +namespace MaterialColorUtilities.Maui.Tests; + +public class MockPreferences : IPreferences +{ + private readonly Dictionary _container = new(); + + public bool ContainsKey(string key, string? sharedName = null) + { + return _container.ContainsKey(key); + } + + public void Remove(string key, string? sharedName = null) + { + _container.Remove(key); + } + + public void Clear(string? sharedName = null) + { + _container.Clear(); + } + + public void Set(string key, T value, string? sharedName = null) + { + _container[key] = JsonSerializer.Serialize(value); + } + + public T Get(string key, T defaultValue, string? sharedName = null) + { + return JsonSerializer.Deserialize(_container[key]) ?? throw new InvalidOperationException(); + } +} \ No newline at end of file diff --git a/MaterialColorUtilities.Maui.Tests/MockSeedColorService.cs b/MaterialColorUtilities.Maui.Tests/MockSeedColorService.cs new file mode 100644 index 0000000..f4b20f9 --- /dev/null +++ b/MaterialColorUtilities.Maui.Tests/MockSeedColorService.cs @@ -0,0 +1,14 @@ +namespace MaterialColorUtilities.Maui.Tests; + +public class MockSeedColorService : ISeedColorService +{ + public MockSeedColorService() { } + + public MockSeedColorService(int? seedColor) + { + SeedColor = seedColor; + } + + public int? SeedColor { get; } + public event Action? OnSeedColorChanged { add { } remove { } } +} \ No newline at end of file diff --git a/MaterialColorUtilities.Maui.Tests/Usings.cs b/MaterialColorUtilities.Maui.Tests/Usings.cs new file mode 100644 index 0000000..ab67c7e --- /dev/null +++ b/MaterialColorUtilities.Maui.Tests/Usings.cs @@ -0,0 +1 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; \ No newline at end of file diff --git a/MaterialColorUtilities.Maui/DynamicColorOptions.cs b/MaterialColorUtilities.Maui/DynamicColorOptions.cs index f73df71..eba58a5 100644 --- a/MaterialColorUtilities.Maui/DynamicColorOptions.cs +++ b/MaterialColorUtilities.Maui/DynamicColorOptions.cs @@ -1,18 +1,33 @@ namespace MaterialColorUtilities.Maui; -public class DynamicColorOptions +public sealed class DynamicColorOptions { /// - /// Will be used if a dynamic accent color is not available. + /// When true, updates to Application.UserAppTheme will be stored using Preferences + /// and reapplied when the app restarts. Defaults to true. /// - public int FallbackSeed { get; set; } = unchecked((int)0xff4285F4); // Google Blue + public bool RememberIsDark { get; set; } = true; + + /// + /// Determines whether to generate colors as application resources. + /// Set to false when using a custom color scheme source. + /// + /// + /// Can be updated at runtime using DynamicColorService.IsEnabled. + /// + public bool EnableTheming { get; set; } = true; /// - /// Whether to use wallpaper/accent color based dynamic theming. + /// Will be used if a dynamic seed color is not available or dynamic theming is disabled. + /// + public int FallbackSeed { get; set; } = unchecked((int)0xff4285F4); // Google Blue + + /// + /// Whether to use wallpaper/accent color based dynamic theming. Defaults to true. /// /// /// When set to , will be used as seed, /// even on platforms that expose an accent color. /// - public bool UseDynamicColor { get; set; } = true; -} + public bool EnableDynamicColor { get; set; } = true; +} \ No newline at end of file diff --git a/MaterialColorUtilities.Maui/DynamicColorService.Android.cs b/MaterialColorUtilities.Maui/DynamicColorService.Android.cs deleted file mode 100644 index b51d9e5..0000000 --- a/MaterialColorUtilities.Maui/DynamicColorService.Android.cs +++ /dev/null @@ -1,168 +0,0 @@ -using Android.App; -using Android.Graphics; -using Android.Graphics.Drawables; -using MaterialColorUtilities.ColorAppearance; -using MaterialColorUtilities.Utils; -using Microsoft.Maui.LifecycleEvents; -using System.Runtime.Versioning; - -namespace MaterialColorUtilities.Maui; - -public partial class DynamicColorService -{ - private int _prevSeedSource = -1; - private readonly WallpaperManager _wallpaperManager = WallpaperManager.GetInstance(Platform.AppContext); - - partial void PlatformInitialize() - { - if (_wallpaperManager == null) return; - if (OperatingSystem.IsAndroidVersionAtLeast(31)) - { - SetFromAndroid12AccentColors(); - _lifecycleEventService.AddAndroid(android - => android.OnResume(_ - => MainThread.BeginInvokeOnMainThread( -#pragma warning disable CA1416 - SetFromAndroid12AccentColors -#pragma warning restore CA1416 - ))); - } - else if (OperatingSystem.IsAndroidVersionAtLeast(27)) - { - SetFromAndroid8PrimaryWallpaperColor(); - _wallpaperManager.ColorsChanged += (sender, args) => - { - if (args.Which == (int)WallpaperManagerFlags.Lock) return; - - MainThread.BeginInvokeOnMainThread( -#pragma warning disable CA1416 - SetFromAndroid8PrimaryWallpaperColor -#pragma warning restore CA1416 - ); - }; - } - else - _ = TrySetFromQuantizedWallpaperColors(); - } - - [SupportedOSPlatform("android31.0")] - public void SetFromAndroid12AccentColors() - { - // We have access to the basic tones like 0, 10, 20 etc. of every tonal palette, - // but if a different tone is required, we need access to the seed color. - // Android doesn't seem to expose the seed color, so we have to get creative to get it. - - // We will use the tone of the primary color with the highest chroma as the seed, - // because it has the same hue as the actual seed and its chroma will be close enough. - int[] primaryIds = - { - Android.Resource.Color.SystemAccent1500, - Android.Resource.Color.SystemAccent110, - Android.Resource.Color.SystemAccent150, - Android.Resource.Color.SystemAccent1100, - Android.Resource.Color.SystemAccent1200, - Android.Resource.Color.SystemAccent1300, - Android.Resource.Color.SystemAccent1400, - Android.Resource.Color.SystemAccent1600, - Android.Resource.Color.SystemAccent1700, - Android.Resource.Color.SystemAccent1800, - Android.Resource.Color.SystemAccent1900, - }; - double maxChroma = -1; - int closestColor = 0; - foreach (int id in primaryIds) - { - int color = Platform.AppContext.Resources.GetColor(id, null); - - if (id == Android.Resource.Color.SystemAccent1500) - { - // If Primary50 didn't change, return - if (color == _prevSeedSource) return; - _prevSeedSource = color; - } - - Hct hct = Hct.FromInt(color); - if (hct.Chroma > maxChroma) - { - maxChroma = hct.Chroma; - closestColor = color; - } - } - - SetSeed(closestColor); - } - - [SupportedOSPlatform("android27.0")] - public void SetFromAndroid8PrimaryWallpaperColor() - { - int color = _wallpaperManager.GetWallpaperColors((int)WallpaperManagerFlags.System).PrimaryColor.ToArgb(); - SetSeed(color); - } - - /// - /// Tries to set colors by using Material Color Utilities to get colors from the system wallpaper. - /// - /// if the operation completed successfully, otherwise. - /// Requires - public async Task TrySetFromQuantizedWallpaperColors() - { - List colors = await Task.Run(async () => - { - int[] pixels = await GetWallpaperPixels(); - if (pixels == null) return null; - return ImageUtils.ColorsFromImage(pixels); - }); - if (colors == null) return false; - - SetSeed(colors.First()); - - return true; - } - - private async Task GetWallpaperPixels() - { - if (_wallpaperManager == null) return null; - - if (OperatingSystem.IsAndroidVersionAtLeast(24)) - { - int wallpaperId = _wallpaperManager.GetWallpaperId(WallpaperManagerFlags.System); - if (_prevSeedSource == wallpaperId) return null; - _prevSeedSource = wallpaperId; - } - - // Need permission to read wallpaper - if ((await Permissions.CheckStatusAsync()) != PermissionStatus.Granted) - return null; - - Drawable drawable = _wallpaperManager.Drawable; - if (drawable is not BitmapDrawable bitmapDrawable) return null; - Bitmap bitmap = bitmapDrawable.Bitmap; - if (bitmap.Height * bitmap.Width > 112 * 112) - { - Android.Util.Size optimalSize = CalculateOptimalSize(bitmap.Width, bitmap.Height); - bitmap = Bitmap.CreateScaledBitmap(bitmap, optimalSize.Width, optimalSize.Height, false); - } - int[] pixels = new int[bitmap.ByteCount / 4]; - bitmap.GetPixels(pixels, 0, bitmap.Width, 0, 0, bitmap.Width, bitmap.Height); - - return pixels; - } - - // From https://cs.android.com/android/platform/superproject/+/384d0423f9e93790e76399a5291731f6cfea40e8:frameworks/base/core/java/android/app/WallpaperColors.java - private static Android.Util.Size CalculateOptimalSize(int width, int height) - { - int requestedArea = width * height; - double scale = 1; - if (requestedArea > 112 * 112) - scale = Math.Sqrt(112 * 112 / (double)requestedArea); - int newWidth = (int)(width * scale); - int newHeight = (int)(height * scale); - - if (newWidth == 0) - newWidth = 1; - if (newHeight == 0) - newHeight = 1; - - return new Android.Util.Size(newWidth, newHeight); - } -} diff --git a/MaterialColorUtilities.Maui/DynamicColorService.Mac.cs b/MaterialColorUtilities.Maui/DynamicColorService.Mac.cs deleted file mode 100644 index 95cb850..0000000 --- a/MaterialColorUtilities.Maui/DynamicColorService.Mac.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Foundation; -using MaterialColorUtilities.Utils; -using System.Runtime.InteropServices; -using UIKit; - -namespace MaterialColorUtilities.Maui; - -// NSColor.controlAccentColor is not available because .NET MAUI uses Mac Catalyst, -// so we have to rely on a workaround to get the accent color and subscribe to its changes. -partial class DynamicColorService -{ - private readonly UIButton _dummy = new(); - - partial void PlatformInitialize() - { - SetFromAccentColor(); - - // based on https://gist.github.com/JunyuKuang/3ecc7c9374c0ba67438c9a6d06612e36 - NSNotificationCenter.DefaultCenter.AddObserver( - (NSString)"NSSystemColorsDidChangeNotification", - _ => MainThread.BeginInvokeOnMainThread(SetFromAccentColor), - null); - } - - // https://twitter.com/steipete/status/1186262035543273472 - private void SetFromAccentColor() - { - UIColor accentColor = _dummy.TintColor; - accentColor.GetRGBA( - out NFloat r, - out NFloat g, - out NFloat b, - out NFloat _); - int argb = ColorUtils.ArgbFromRgb( - (int)(r * 255), - (int)(g * 255), - (int)(b * 255)); - SetSeed(argb); - } -} diff --git a/MaterialColorUtilities.Maui/DynamicColorService.Windows.cs b/MaterialColorUtilities.Maui/DynamicColorService.Windows.cs deleted file mode 100644 index dfe3c55..0000000 --- a/MaterialColorUtilities.Maui/DynamicColorService.Windows.cs +++ /dev/null @@ -1,23 +0,0 @@ -using MaterialColorUtilities.Utils; -using Windows.UI.ViewManagement; - -namespace MaterialColorUtilities.Maui; - -public partial class DynamicColorService -{ - private readonly UISettings _uiSettings = new(); - - partial void PlatformInitialize() - { - SetSeed(GetAccentColor()); - _uiSettings.ColorValuesChanged += (_, _) - => MainThread.BeginInvokeOnMainThread(() - => SetSeed(GetAccentColor())); - } - - private int GetAccentColor() - { - Windows.UI.Color color = _uiSettings.GetColorValue(UIColorType.Accent); - return ColorUtils.ArgbFromRgb(color.R, color.G, color.B); - } -} diff --git a/MaterialColorUtilities.Maui/DynamicColorService.cs b/MaterialColorUtilities.Maui/DynamicColorService.cs index 3bd6cf4..3dafbde 100644 --- a/MaterialColorUtilities.Maui/DynamicColorService.cs +++ b/MaterialColorUtilities.Maui/DynamicColorService.cs @@ -1,21 +1,24 @@ using MaterialColorUtilities.Palettes; using MaterialColorUtilities.Schemes; using Microsoft.Extensions.Options; -using Microsoft.Maui.LifecycleEvents; using System.Diagnostics.CodeAnalysis; +using System.Reflection; namespace MaterialColorUtilities.Maui; -public sealed class DynamicColorService : DynamicColorService, Scheme, LightSchemeMapper, DarkSchemeMapper> +//States: +// Disabled +// Fallback seed +// Custom seed +// Dynamic seed +public class DynamicColorService : DynamicColorService, Scheme, LightSchemeMapper, DarkSchemeMapper> { - public DynamicColorService( - IOptions options, - IApplication application, - ILifecycleEventService lifecycleEventService - ) : base(options, application, lifecycleEventService) { } + public DynamicColorService(IOptions options, ISeedColorService seedColorService, IApplication application, IPreferences preferences) : base(options, seedColorService, application, preferences) + { + } } -public partial class DynamicColorService< +public class DynamicColorService< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCorePalette, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] @@ -24,97 +27,198 @@ public partial class DynamicColorService< TSchemeMaui, TLightSchemeMapper, TDarkSchemeMapper> - : IDynamicColorService + : IMauiInitializeService where TCorePalette : CorePalette where TSchemeInt : Scheme, new() where TSchemeMaui : Scheme, new() where TLightSchemeMapper : ISchemeMapper, new() where TDarkSchemeMapper : ISchemeMapper, new() { - private readonly DynamicColorOptions _options; + private const string SeedKey = "MaterialColorUtilities.Maui.Seed"; + private const string IsDarkKey = "MaterialColorUtilities.Maui.IsDark"; + + private readonly ISeedColorService _seedColorService; private readonly Application _application; private readonly ResourceDictionary _appResources; - private readonly LifecycleEventService _lifecycleEventService; - private int _seed; - private int _prevSeed; - private readonly WeakEventManager _weakEventManager = new(); + private readonly IPreferences _preferences; + private readonly bool _rememberIsDark; + private readonly int _fallbackSeed; + private readonly TLightSchemeMapper _lightSchemeMapper = new(); private readonly TDarkSchemeMapper _darkSchemeMapper = new(); + private bool _enableTheming; + private bool _enableDynamicColor; + private int _seed; + private int? _prevSeed; + private bool? _prevIsDark; + public DynamicColorService( IOptions options, + ISeedColorService seedColorService, IApplication application, - ILifecycleEventService lifecycleEventService) + IPreferences preferences) { - _options = options.Value; - _seed = _options.FallbackSeed; + _rememberIsDark = options.Value.RememberIsDark; + _enableTheming = options.Value.EnableTheming; + _enableDynamicColor = options.Value.EnableDynamicColor; + _fallbackSeed = options.Value.FallbackSeed; + + _seedColorService = seedColorService; + _preferences = preferences; _application = (Application)application; _appResources = _application.Resources; - _lifecycleEventService = (LifecycleEventService)lifecycleEventService; } - // Use Application.UserAppTheme to set - public bool IsDark { get; private set; } - public int Seed => _seed; - public TCorePalette CorePalette { get; protected set; } - public TSchemeInt SchemeInt { get; protected set; } - public TSchemeMaui SchemeMaui { get; protected set; } - - public void SetSeed(int value, object sender = null) + public bool EnableTheming { - if (_seed == value) return; - _seed = value; - _weakEventManager.HandleEvent(sender, value, nameof(SeedChanged)); - Apply(); + get => _enableTheming; + set + { + if (value == _enableTheming) return; + _enableTheming = value; + + OnOptionsChanged(); + } } - public event EventHandler SeedChanged + public bool EnableDynamicColor { - add => _weakEventManager.AddEventHandler(value); - remove => _weakEventManager.RemoveEventHandler(value); + get => _enableDynamicColor; + set + { + if (value == _enableDynamicColor) return; + _enableDynamicColor = value; + + if (!value) + { + _seed = _preferences.ContainsKey(SeedKey) + ? _preferences.Get(SeedKey, 0) + : _fallbackSeed; + } + + OnOptionsChanged(); + } } - public virtual void Initialize() + /// + /// Decides if a dark scheme should be generated instead of light. + /// + /// + /// To update, use . + /// + public bool IsDark => _application.RequestedTheme == AppTheme.Dark; + + /// + /// A color in ARGB format, that is used as seed when creating the color scheme. + /// + public int Seed { - if (_options.UseDynamicColor) + get => _seed; + set { - try { PlatformInitialize(); } - catch { } + if (value == _seed) return; + _seed = value; + _preferences.Set(SeedKey, value); + Update(); } + } - _application.RequestedThemeChanged += (_, _) => Apply(); - - Apply(); + public TCorePalette CorePalette { get; protected set; } + public TSchemeInt SchemeInt { get; protected set; } + public TSchemeMaui SchemeMaui { get; protected set; } + + /// + /// When the seed is set, it is stored using Preferences and will be reapplied the next time the app is launched. + /// Use this to clear the preference and use the fallback seed instead. + /// + public void ForgetSeed() + { + Seed = _fallbackSeed; + _preferences.Clear(SeedKey); } - partial void PlatformInitialize(); + // Called by MauiAppBuilder.Build() + public virtual void Initialize(IServiceProvider services) + { + if (_preferences.ContainsKey(IsDarkKey)) + _application.UserAppTheme = _preferences.Get(IsDarkKey, false) + ? AppTheme.Dark + : AppTheme.Light; + + _seed = _fallbackSeed; + + if (_preferences.ContainsKey(SeedKey)) + _seed = _preferences.Get(SeedKey, 0); + + _application.RequestedThemeChanged += (_, _) => + { + if (_rememberIsDark) + { + if (_application.UserAppTheme == AppTheme.Unspecified) + _preferences.Remove(IsDarkKey); + else + _preferences.Set(IsDarkKey, _application.UserAppTheme == AppTheme.Dark); + } + Update(); + }; + + OnOptionsChanged(); + } - protected virtual void Apply() + private void OnOptionsChanged() { + _seedColorService.OnSeedColorChanged -= Update; + if (_enableTheming && _enableDynamicColor) + _seedColorService.OnSeedColorChanged += Update; + Update(); + } + + private void Update() + { + if (!EnableTheming) return; + + if (_enableDynamicColor && _seedColorService.SeedColor != null) + _seed = (int)_seedColorService.SeedColor; + if (Seed != _prevSeed) CorePalette = CreateCorePalette(Seed); - bool isDark = _application.RequestedTheme == AppTheme.Dark; - - if (Seed == _prevSeed && isDark == IsDark) return; + if (Seed == _prevSeed && IsDark == _prevIsDark) return; _prevSeed = Seed; - IsDark = isDark; - - ISchemeMapper mapper = isDark + _prevIsDark = IsDark; + + ISchemeMapper mapper = IsDark ? _darkSchemeMapper : _lightSchemeMapper; SchemeInt = mapper.Map(CorePalette); - // We have to use reflection to access the generated method with the correct return type. - SchemeMaui = (TSchemeMaui)typeof(TSchemeInt) - .GetMethods() - .Where(m => m.Name == nameof(Scheme.ConvertTo)) - .ToList()[0] - .MakeGenericMethod(typeof(Color)) - .Invoke(SchemeInt, new[] { Color.FromInt }); + if (typeof(TSchemeMaui) == typeof(Scheme)) + { + SchemeMaui = (TSchemeMaui)SchemeInt.ConvertTo(Color.FromInt); + } + else + { + // We have to use reflection to access the generated method with the correct return type. + SchemeMaui = (TSchemeMaui)typeof(TSchemeInt) + .GetMethods() + .Where(m => m.Name == nameof(Scheme.ConvertTo)) + .ToList()[0] + .MakeGenericMethod(typeof(Color)) + .Invoke(SchemeInt, new object[] { (Func)Color.FromInt }); + } - foreach (var property in typeof(TSchemeMaui).GetProperties()) +#if PLATFORM + MainThread.BeginInvokeOnMainThread(Apply); +#else + Apply(); +#endif + } + + protected virtual void Apply() + { + foreach (PropertyInfo property in typeof(TSchemeMaui).GetProperties()) { string key = property.Name; Color value = (Color)property.GetValue(SchemeMaui); @@ -125,17 +229,13 @@ protected virtual void Apply() /// Constructs a . /// - /// C# doesn't support contructor with parameters as a generic constraint, + /// C# doesn't support constructor with parameters as a generic constraint, /// so reflection is required to access the constructor. Discussion here. /// If you replace CorePalette, make sure it has a constructor with the following parameters: int seed, bool isContent /// + // TODO: Replace with using empty constructor and method call private static TCorePalette CreateCorePalette(int seed) { return (TCorePalette)Activator.CreateInstance(typeof(TCorePalette), seed, false); } } - -public interface IDynamicColorService -{ - void Initialize(); -} \ No newline at end of file diff --git a/MaterialColorUtilities.Maui/ISeedColorService.cs b/MaterialColorUtilities.Maui/ISeedColorService.cs new file mode 100644 index 0000000..9e3c3ce --- /dev/null +++ b/MaterialColorUtilities.Maui/ISeedColorService.cs @@ -0,0 +1,7 @@ +namespace MaterialColorUtilities.Maui; + +public interface ISeedColorService +{ + int? SeedColor { get; } + event Action OnSeedColorChanged; +} \ No newline at end of file diff --git a/MaterialColorUtilities.Maui/InitializeService.cs b/MaterialColorUtilities.Maui/InitializeService.cs deleted file mode 100644 index af54f2d..0000000 --- a/MaterialColorUtilities.Maui/InitializeService.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace MaterialColorUtilities.Maui; - -internal class InitializeService : IMauiInitializeService - where TDynamicColorService : IDynamicColorService -{ - private readonly TDynamicColorService _dynamicColorService; - - public InitializeService(TDynamicColorService dynamicColorService) - { - _dynamicColorService = dynamicColorService; - } - - public void Initialize(IServiceProvider services) - { - _dynamicColorService.Initialize(); - } -} diff --git a/MaterialColorUtilities.Maui/MaterialColorUtilities.Maui.csproj b/MaterialColorUtilities.Maui/MaterialColorUtilities.Maui.csproj index c690e10..c192300 100644 --- a/MaterialColorUtilities.Maui/MaterialColorUtilities.Maui.csproj +++ b/MaterialColorUtilities.Maui/MaterialColorUtilities.Maui.csproj @@ -31,6 +31,8 @@ Copyright 2021-2022 Google LLC and project contributors true + + $(DefineConstants);PLATFORM @@ -44,28 +46,24 @@ - - - - + + - - - - + + - - - - + + - - + + + + diff --git a/MaterialColorUtilities.Maui/MauiAppBuilderExtensions.cs b/MaterialColorUtilities.Maui/MauiAppBuilderExtensions.cs index e2ef837..52664ef 100644 --- a/MaterialColorUtilities.Maui/MauiAppBuilderExtensions.cs +++ b/MaterialColorUtilities.Maui/MauiAppBuilderExtensions.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace MaterialColorUtilities.Maui; @@ -19,7 +20,7 @@ public static MauiAppBuilder UseMaterialDynamicColors< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TDynamicColorService> (this MauiAppBuilder builder) - where TDynamicColorService : class, IDynamicColorService + where TDynamicColorService : class, IMauiInitializeService => builder.UseMaterialDynamicColors(_ => { }); public static MauiAppBuilder UseMaterialDynamicColors< @@ -28,7 +29,7 @@ public static MauiAppBuilder UseMaterialDynamicColors< ( this MauiAppBuilder builder, uint fallbackSeed ) - where TDynamicColorService : class, IDynamicColorService + where TDynamicColorService : class, IMauiInitializeService => builder.UseMaterialDynamicColors(opt => opt.FallbackSeed = (int)fallbackSeed); public static MauiAppBuilder UseMaterialDynamicColors< @@ -38,11 +39,13 @@ public static MauiAppBuilder UseMaterialDynamicColors< this MauiAppBuilder builder, Action configureOptions ) - where TDynamicColorService : class, IDynamicColorService + where TDynamicColorService : class, IMauiInitializeService { builder.Services.Configure(configureOptions); + builder.Services.TryAddSingleton(_ => Preferences.Default); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton>(); + builder.Services.AddSingleton(s => s.GetRequiredService()); return builder; } } \ No newline at end of file diff --git a/MaterialColorUtilities.Maui/SeedColorService.Android.cs b/MaterialColorUtilities.Maui/SeedColorService.Android.cs new file mode 100644 index 0000000..78dca65 --- /dev/null +++ b/MaterialColorUtilities.Maui/SeedColorService.Android.cs @@ -0,0 +1,215 @@ +using System.Runtime.Versioning; +using Android; +using Android.App; +using Android.Content.PM; +using Android.Graphics; +using Android.Graphics.Drawables; +using MaterialColorUtilities.ColorAppearance; +using MaterialColorUtilities.Utils; +using Microsoft.Maui.LifecycleEvents; +using Size = Android.Util.Size; + +namespace MaterialColorUtilities.Maui; + +public class SeedColorService : ISeedColorService +{ + private const string SeedColorCacheKey = "MaterialColorUtilities.Maui.SeedClorCache"; + private const string WallpaperIdKey = "MaterialColorUtilities.Maui.WallpaperId"; + + private readonly LifecycleEventService _lifecycleEventService; + private readonly IPreferences _preferences; + private readonly WallpaperManager _wallpaperManager = WallpaperManager.GetInstance(Platform.AppContext); + + private bool _hasInitialized; + private int? _wallpaperId; + private int? _SeedColorCache; + + public SeedColorService(ILifecycleEventService lifecycleEventService, IPreferences preferences) + { + _lifecycleEventService = (LifecycleEventService)lifecycleEventService; + _preferences = preferences; + } + + private void EnsureInitialized() + { + if (_hasInitialized) return; + _hasInitialized = true; + + if (OperatingSystem.IsAndroidVersionAtLeast(27)) + { + _lifecycleEventService.AddAndroid(a => a.OnResume(_ => OnSeedColorChanged?.Invoke())); + } + else if (OperatingSystem.IsAndroidVersionAtLeast(24)) + { + if (_preferences.ContainsKey(SeedColorCacheKey)) + { + _wallpaperId = _preferences.Get(WallpaperIdKey, 0); + _SeedColorCache = _preferences.Get(SeedColorCacheKey, 0); + } + + _lifecycleEventService.AddAndroid(a => + { +#pragma warning disable CA1416 + a.OnResume(_ => CheckWallpaper()); +#pragma warning restore CA1416 + + a.OnRequestPermissionsResult((_, _, permissions, results) => + { + if (results.All(x => x == Permission.Granted) + && permissions.Contains(Manifest.Permission.ReadExternalStorage)) +#pragma warning disable CA1416 + CheckWallpaper(); +#pragma warning restore CA1416 + }); + }); + } + } + + public int? SeedColor => Environment.OSVersion.Version.Major switch + { +#pragma warning disable CA1416 + >= 31 => GuessAndroid12Seed(), + >= 27 => GetAndroid8PrimaryWallpaperColor(), +#pragma warning restore CA1416 + >= 24 => _SeedColorCache, + _ => null + }; + + private event Action OnSeedColorChanged; + + event Action ISeedColorService.OnSeedColorChanged + { + add + { + EnsureInitialized(); + OnSeedColorChanged += value; + if (OperatingSystem.IsAndroidVersionAtLeast(24) && !OperatingSystem.IsAndroidVersionAtLeast(27)) + CheckWallpaper(); + } + remove => OnSeedColorChanged -= value; + } + + [SupportedOSPlatform("android31.0")] + private int? GuessAndroid12Seed() + { + // We have access to the basic tones like 0, 10, 20 etc. of every tonal palette, + // but if a different tone is required, we need access to the seed color. + // Android doesn't seem to expose the seed color, so we have to get creative to get it. + + // We will use the tone of the primary color with the highest chroma as the seed, + // because it has the same hue as the actual seed and its chroma will be close enough. + int[] primaryIds = + { + Android.Resource.Color.SystemAccent1500, + Android.Resource.Color.SystemAccent110, + Android.Resource.Color.SystemAccent150, + Android.Resource.Color.SystemAccent1100, + Android.Resource.Color.SystemAccent1200, + Android.Resource.Color.SystemAccent1300, + Android.Resource.Color.SystemAccent1400, + Android.Resource.Color.SystemAccent1600, + Android.Resource.Color.SystemAccent1700, + Android.Resource.Color.SystemAccent1800, + Android.Resource.Color.SystemAccent1900, + }; + double maxChroma = -1; + int closestColor = 0; + foreach (int id in primaryIds) + { + int color = Platform.AppContext.Resources!.GetColor(id, null); + + if (id == Android.Resource.Color.SystemAccent1500) + { + // If Primary50 didn't change, return + if (color == _wallpaperId) return _SeedColorCache; + _wallpaperId = color; + } + + Hct hct = Hct.FromInt(color); + if (hct.Chroma > maxChroma) + { + maxChroma = hct.Chroma; + closestColor = color; + } + } + + _SeedColorCache = closestColor; + return closestColor; + } + + [SupportedOSPlatform("android27.0")] + private int? GetAndroid8PrimaryWallpaperColor() + { + WallpaperColors colors = _wallpaperManager.GetWallpaperColors((int)WallpaperManagerFlags.System); + return colors?.PrimaryColor.ToArgb(); + } + + // GetWallpaperId is only available from API 24, and without it we would have to quantize + // the wallpaper everytime the app is resumed. + [SupportedOSPlatform("android24.0")] + private async void CheckWallpaper() + { + if (OnSeedColorChanged == null) return; + + // Need permission to read wallpaper + if (await Permissions.CheckStatusAsync() != PermissionStatus.Granted) + return; + + int wallpaperId = _wallpaperManager.GetWallpaperId(WallpaperManagerFlags.System); + if (_wallpaperId == wallpaperId) return; + + _wallpaperId = wallpaperId; + + _SeedColorCache = await Task.Run(QuantizeWallpaper); + + _preferences.Set(WallpaperIdKey, wallpaperId); + + if (_SeedColorCache == null) + _preferences.Remove(SeedColorCacheKey); + else + _preferences.Set(SeedColorCacheKey, (int)_SeedColorCache); + OnSeedColorChanged?.Invoke(); + } + + /// + /// Compute a seed color using the algorithms included in MaterialColorUtilities + /// + /// Requires permission + private int? QuantizeWallpaper() + { + int[] pixels = GetWallpaperPixels(); + if (pixels == null) return null; + return ImageUtils.ColorsFromImage(pixels)[0]; + } + + private int[] GetWallpaperPixels() + { + Drawable drawable = _wallpaperManager.Drawable; + if (drawable is not BitmapDrawable bitmapDrawable || bitmapDrawable.Bitmap == null) return null; + Bitmap bitmap = bitmapDrawable.Bitmap; + if (bitmap.Height * bitmap.Width > 112 * 112) + { + Size optimalSize = CalculateOptimalSize(bitmap.Width, bitmap.Height); + bitmap = Bitmap.CreateScaledBitmap(bitmap, optimalSize.Width, optimalSize.Height, false); + } + + int[] pixels = new int[bitmap!.ByteCount / 4]; + bitmap.GetPixels(pixels, 0, bitmap.Width, 0, 0, bitmap.Width, bitmap.Height); + + return pixels; + } + + // From https://cs.android.com/android/platform/superproject/+/384d0423f9e93790e76399a5291731f6cfea40e8:frameworks/base/core/java/android/app/WallpaperColors.java + private static Size CalculateOptimalSize(int width, int height) + { + long area = width * height; + if (area > 112 * 112) + { + double scale = Math.Sqrt(112 * 112 / (double)area); + width = Math.Max((int)(width * scale), 1); + height = Math.Max((int)(height * scale), 1); + } + + return new(width, height); + } +} \ No newline at end of file diff --git a/MaterialColorUtilities.Maui/SeedColorService.Mac.cs b/MaterialColorUtilities.Maui/SeedColorService.Mac.cs new file mode 100644 index 0000000..bc5910d --- /dev/null +++ b/MaterialColorUtilities.Maui/SeedColorService.Mac.cs @@ -0,0 +1,40 @@ +using System.Runtime.InteropServices; +using Foundation; +using MaterialColorUtilities.Utils; +using UIKit; + +namespace MaterialColorUtilities.Maui; + +public class SeedColorService : ISeedColorService +{ + private readonly UIButton _dummyButton = new(); + + public SeedColorService() + { + // based on https://gist.github.com/JunyuKuang/3ecc7c9374c0ba67438c9a6d06612e36 + NSNotificationCenter.DefaultCenter.AddObserver( + (NSString)"NSSystemColorsDidChangeNotification", + _ => OnSeedColorChanged?.Invoke(), + null); + } + + public int? SeedColor + { + get + { + UIColor accentColor = _dummyButton.TintColor; + if (accentColor == null) return null; + accentColor.GetRGBA( + out NFloat r, + out NFloat g, + out NFloat b, + out NFloat _); + return ColorUtils.ArgbFromRgb( + (int)(r * 255), + (int)(g * 255), + (int)(b * 255)); + } + } + + public event Action OnSeedColorChanged; +} \ No newline at end of file diff --git a/MaterialColorUtilities.Maui/SeedColorService.Standard.cs b/MaterialColorUtilities.Maui/SeedColorService.Standard.cs new file mode 100644 index 0000000..ce8fed1 --- /dev/null +++ b/MaterialColorUtilities.Maui/SeedColorService.Standard.cs @@ -0,0 +1,7 @@ +namespace MaterialColorUtilities.Maui; + +public class SeedColorService : ISeedColorService +{ + public int? SeedColor => null; + public event Action OnSeedColorChanged{ add { } remove { } } +} \ No newline at end of file diff --git a/MaterialColorUtilities.Maui/SeedColorService.Windows.cs b/MaterialColorUtilities.Maui/SeedColorService.Windows.cs new file mode 100644 index 0000000..721c8d4 --- /dev/null +++ b/MaterialColorUtilities.Maui/SeedColorService.Windows.cs @@ -0,0 +1,25 @@ +using Windows.UI.ViewManagement; +using MaterialColorUtilities.Utils; + +namespace MaterialColorUtilities.Maui; + +public class SeedColorService : ISeedColorService +{ + private readonly UISettings _uiSettings = new(); + + public SeedColorService() + { + _uiSettings.ColorValuesChanged += (_, _) => OnSeedColorChanged?.Invoke(); + } + + public int? SeedColor + { + get + { + Windows.UI.Color color = _uiSettings.GetColorValue(UIColorType.Accent); + return ColorUtils.ArgbFromRgb(color.R, color.G, color.B); + } + } + + public event Action OnSeedColorChanged; +} \ No newline at end of file diff --git a/MaterialColorUtilities.Maui/SeedColorService.iOS.cs b/MaterialColorUtilities.Maui/SeedColorService.iOS.cs new file mode 100644 index 0000000..ce8fed1 --- /dev/null +++ b/MaterialColorUtilities.Maui/SeedColorService.iOS.cs @@ -0,0 +1,7 @@ +namespace MaterialColorUtilities.Maui; + +public class SeedColorService : ISeedColorService +{ + public int? SeedColor => null; + public event Action OnSeedColorChanged{ add { } remove { } } +} \ No newline at end of file diff --git a/MaterialColorUtilities.sln b/MaterialColorUtilities.sln index 0709f87..a1df77d 100644 --- a/MaterialColorUtilities.sln +++ b/MaterialColorUtilities.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaterialColorUtilities", "M EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialColorUtilities.Maui", "MaterialColorUtilities.Maui\MaterialColorUtilities.Maui.csproj", "{22AEE1B5-3B27-446E-B4E1-3E3D9B01DAE3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialColorUtilities.Maui.Tests", "MaterialColorUtilities.Maui.Tests\MaterialColorUtilities.Maui.Tests.csproj", "{F2F1373D-8A1C-4709-88BC-3E9CDF09A148}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaterialColorUtilities.Tests", "MaterialColorUtilities.Tests\MaterialColorUtilities.Tests.csproj", "{35FD1BCC-D495-463C-8DE8-4E18EBC1790E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaterialColorUtilities.SourceGenerators", "MaterialColorUtilities.SourceGenerators\MaterialColorUtilities.SourceGenerators.csproj", "{13AF7E9C-9F4A-4D9C-BA64-0A7BAA23C0A1}" @@ -66,6 +68,10 @@ Global {22AEE1B5-3B27-446E-B4E1-3E3D9B01DAE3}.Debug|Any CPU.Build.0 = Debug|Any CPU {22AEE1B5-3B27-446E-B4E1-3E3D9B01DAE3}.Release|Any CPU.ActiveCfg = Release|Any CPU {22AEE1B5-3B27-446E-B4E1-3E3D9B01DAE3}.Release|Any CPU.Build.0 = Release|Any CPU + {F2F1373D-8A1C-4709-88BC-3E9CDF09A148}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2F1373D-8A1C-4709-88BC-3E9CDF09A148}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2F1373D-8A1C-4709-88BC-3E9CDF09A148}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2F1373D-8A1C-4709-88BC-3E9CDF09A148}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Playground/Playground.Maui/App.xaml b/Playground/Playground.Maui/App.xaml index be7b59f..be6c928 100644 --- a/Playground/Playground.Maui/App.xaml +++ b/Playground/Playground.Maui/App.xaml @@ -1,10 +1,10 @@ - - + + - + @@ -69,8 +69,13 @@ + + - @@ -85,11 +90,40 @@ - + + + + diff --git a/Playground/Playground.Maui/CustomDynamicColorService.cs b/Playground/Playground.Maui/CustomDynamicColorService.cs index c19e6f1..9b153a1 100644 --- a/Playground/Playground.Maui/CustomDynamicColorService.cs +++ b/Playground/Playground.Maui/CustomDynamicColorService.cs @@ -1,14 +1,50 @@ using MaterialColorUtilities.Maui; using MaterialColorUtilities.Palettes; using Microsoft.Extensions.Options; -using Microsoft.Maui.LifecycleEvents; using Playground.Shared; +#if ANDROID +using Android.App; +using AndroidX.Core.View; +using Microsoft.Maui.Platform; +#endif + +#pragma warning disable CS1998 namespace Playground.Maui; public class CustomDynamicColorService : DynamicColorService, AppScheme, LightAppSchemeMapper, DarkAppSchemeMapper> { - public CustomDynamicColorService(IOptions options, IApplication application, ILifecycleEventService lifecycleEventService) : base(options, application, lifecycleEventService) + private readonly WeakEventManager _weakEventManager = new(); + + public CustomDynamicColorService(IOptions options, ISeedColorService seedColorService, IApplication application, IPreferences preferences) : base(options, seedColorService, application, preferences) + { + } + + public event EventHandler SeedChanged { + add => _weakEventManager.AddEventHandler(value); + remove => _weakEventManager.RemoveEventHandler(value); + } + + protected override async void Apply() + { + base.Apply(); + _weakEventManager.HandleEvent(null!, null!, nameof(SeedChanged)); + +#if ANDROID + Activity activity = await Platform.WaitForActivityAsync(); + + // Update status/navigation bar background color + Android.Graphics.Color androidColor = SchemeMaui.Surface2.ToPlatform(); + activity.Window!.SetNavigationBarColor(androidColor); + activity.Window!.SetStatusBarColor(androidColor); + + // Update status/navigation bar text/icon color + _ = new WindowInsetsControllerCompat(activity.Window, activity.Window.DecorView) + { + AppearanceLightStatusBars = !IsDark, + AppearanceLightNavigationBars = !IsDark + }; +#endif } } diff --git a/Playground/Playground.Maui/ViewModels/ThemeViewModel.cs b/Playground/Playground.Maui/ViewModels/ThemeViewModel.cs index 253219f..a199152 100644 --- a/Playground/Playground.Maui/ViewModels/ThemeViewModel.cs +++ b/Playground/Playground.Maui/ViewModels/ThemeViewModel.cs @@ -1,23 +1,48 @@ using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using MaterialColorUtilities.ColorAppearance; +using MaterialColorUtilities.Maui; +using Microsoft.Extensions.Options; namespace Playground.Maui.ViewModels; public partial class ThemeViewModel : ObservableObject { private readonly CustomDynamicColorService _colorService; + [ObservableProperty] private double _h; [ObservableProperty] private double _c; [ObservableProperty] private double _t; [ObservableProperty] private Color _seed; + + [ObservableProperty] private bool _enableTheming; + [ObservableProperty] private bool _enableDynamicColor; + + public AppTheme UserAppTheme + { + get => Application.Current!.UserAppTheme; + set => Application.Current!.UserAppTheme = value; + } + + public List ThemeOptions { get; } = new() + { + AppTheme.Unspecified, + AppTheme.Light, + AppTheme.Dark + }; + public Color OnSeed => _t < 49.6 ? Colors.White : Colors.Black; - public ThemeViewModel(CustomDynamicColorService colorService) + public ThemeViewModel(CustomDynamicColorService colorService, IOptions options) { _colorService = colorService; - _colorService.SeedChanged += (sender, _) => + + _enableTheming = options.Value.EnableTheming; + _enableDynamicColor = options.Value.EnableDynamicColor; + + _colorService.SeedChanged += (_, _) => { - if (sender == this) return; + if (_colorService.Seed == _seed?.ToInt()) return; MainThread.BeginInvokeOnMainThread(SetFromSeed); }; SetFromSeed(); @@ -27,12 +52,18 @@ public ThemeViewModel(CustomDynamicColorService colorService) partial void OnCChanged(double value) => SetSeed(); partial void OnTChanged(double value) => SetSeed(); partial void OnSeedChanged(Color value) => OnPropertyChanged(nameof(OnSeed)); + + partial void OnEnableThemingChanged(bool value) => _colorService.EnableTheming = value; + partial void OnEnableDynamicColorChanged(bool value) => _colorService.EnableDynamicColor = value; + [ICommand] + void ForgetSeed() => _colorService.ForgetSeed(); + void SetSeed() { Hct hct = Hct.From(H, C, T); Seed = Color.FromInt(hct.ToInt()); - _colorService.SetSeed(hct.ToInt(), this); + _colorService.Seed = hct.ToInt(); } void SetFromSeed() diff --git a/Playground/Playground.Maui/Views/ThemePage.xaml b/Playground/Playground.Maui/Views/ThemePage.xaml index 3c9481d..da93aa2 100644 --- a/Playground/Playground.Maui/Views/ThemePage.xaml +++ b/Playground/Playground.Maui/Views/ThemePage.xaml @@ -6,7 +6,7 @@ Title="Theme" x:DataType="viewModels:ThemeViewModel"> - +