diff --git a/src/Controls/tests/TestCases.HostApp/MauiProgram.cs b/src/Controls/tests/TestCases.HostApp/MauiProgram.cs index beae20e666d9..27b6a310e82c 100644 --- a/src/Controls/tests/TestCases.HostApp/MauiProgram.cs +++ b/src/Controls/tests/TestCases.HostApp/MauiProgram.cs @@ -89,7 +89,7 @@ public static Page CreateDefaultMainPage() { Page mainPage = null; OverrideMainPage(ref mainPage); -#if MACCATALYST +#if IOS || MACCATALYST // Check for startup test argument from environment variables (passed by test runner) var testName = System.Environment.GetEnvironmentVariable("test"); diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_CheckBox_VerifyVisualState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_CheckBox_VerifyVisualState.png index e2d0dcbae0c8..0dd14375752e 100644 Binary files a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_CheckBox_VerifyVisualState.png and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_CheckBox_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_DatePicker_VerifyVisualState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_DatePicker_VerifyVisualState.png index a0e2c660c059..097528a41d08 100644 Binary files a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_DatePicker_VerifyVisualState.png and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_DatePicker_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Editor_VerifyVisualState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Editor_VerifyVisualState.png index 7e3423af8274..43577bd4bfaa 100644 Binary files a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Editor_VerifyVisualState.png and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Editor_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_RadioButton_VerifyVisualState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_RadioButton_VerifyVisualState.png index 3b16c120a425..66364f5e0455 100644 Binary files a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_RadioButton_VerifyVisualState.png and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_RadioButton_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_SearchBar_VerifyVisualState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_SearchBar_VerifyVisualState.png index 99db1bf605db..b760f8ae5539 100644 Binary files a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_SearchBar_VerifyVisualState.png and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_SearchBar_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Slider_VerifyVisualState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Slider_VerifyVisualState.png index b59c318ce6ce..e000786031a4 100644 Binary files a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Slider_VerifyVisualState.png and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Slider_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Switch_VerifyVisualState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Switch_VerifyVisualState.png index 87757b32d8a1..de4bf54fdbe3 100644 Binary files a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Switch_VerifyVisualState.png and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_Switch_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_VerifyVisualState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_VerifyVisualState.png index dafd907cd7a1..3039b5ef9949 100644 Binary files a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_VerifyVisualState.png and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LightTheme_VerifyVisualState.png differ diff --git a/src/Core/src/Handlers/Switch/SwitchHandler.iOS.cs b/src/Core/src/Handlers/Switch/SwitchHandler.iOS.cs index 01fc9c0bb7a7..78ff3bb10c20 100644 --- a/src/Core/src/Handlers/Switch/SwitchHandler.iOS.cs +++ b/src/Core/src/Handlers/Switch/SwitchHandler.iOS.cs @@ -1,9 +1,8 @@ using System; -using System.Threading.Tasks; using CoreFoundation; using Foundation; using Microsoft.Maui.Graphics; -using ObjCRuntime; +using Microsoft.Maui.Platform; using UIKit; using RectangleF = CoreGraphics.CGRect; @@ -21,7 +20,7 @@ public partial class SwitchHandler : ViewHandler protected override UISwitch CreatePlatformView() { - return new UISwitch(RectangleF.Empty); + return new MauiSwitch(RectangleF.Empty); } protected override void ConnectHandler(UISwitch platformView) @@ -74,6 +73,7 @@ public void Connect(ISwitch virtualView, UISwitch platformView) { _virtualView = new(virtualView); _platformView = new(platformView); + (platformView as MauiSwitch)?.Connect(virtualView); platformView.ValueChanged += OnControlValueChanged; #if MACCATALYST @@ -83,6 +83,10 @@ public void Connect(ISwitch virtualView, UISwitch platformView) if (PlatformView is not null) { UpdateTrackOffColor(PlatformView); + if (OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + UpdateThumbAndTrackColor(PlatformView); + } } }); #elif IOS @@ -95,6 +99,13 @@ public void Connect(ISwitch virtualView, UISwitch platformView) } }); #endif + + // iOS/MacCatalyst 26+ can reset custom switch colors during initial UIKit styling. + // Re-apply after connect; MauiSwitch handles later trait/layout/window reapply paths. + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + UpdateThumbAndTrackColor(platformView); + } } // Ensures the Switch track "OFF" color is updated correctly after system-level UI resets. @@ -102,23 +113,33 @@ public void Connect(ISwitch virtualView, UISwitch platformView) // especially when the app enters the background and returns to the foreground. void UpdateTrackOffColor(UISwitch platformView) { - DispatchQueue.MainQueue.DispatchAsync(async () => + DispatchQueue.MainQueue.DispatchAsync(() => { - if (!platformView.On) + if (!platformView.On && VirtualView is ISwitch view && view.TrackColor is not null) { - await Task.Delay(10); // Small delay, necessary to allow UIKit to complete its internal layout and styling processes before re-applying the custom color - - if (VirtualView is ISwitch view && view.TrackColor is not null) - { - platformView.UpdateTrackColor(view); - } + platformView.UpdateTrackColor(view); + (platformView as MauiSwitch)?.SetNeedsColorReapply(); } }); } + void UpdateThumbAndTrackColor(UISwitch platformView) + { + DispatchQueue.MainQueue.DispatchAsync(() => + { + if (VirtualView is not ISwitch view || PlatformView is null || !view.HasCustomColors()) + return; + + platformView.UpdateTrackColor(view); + platformView.UpdateThumbColor(view); + (platformView as MauiSwitch)?.SetNeedsColorReapply(); + }); + } + public void Disconnect(UISwitch platformView) { platformView.ValueChanged -= OnControlValueChanged; + (platformView as MauiSwitch)?.Disconnect(); if (_willEnterForegroundObserver is not null) { @@ -141,4 +162,4 @@ void OnControlValueChanged(object? sender, EventArgs e) } } } -} \ No newline at end of file +} diff --git a/src/Core/src/Platform/iOS/MauiSwitch.cs b/src/Core/src/Platform/iOS/MauiSwitch.cs new file mode 100644 index 000000000000..3db355c2e81b --- /dev/null +++ b/src/Core/src/Platform/iOS/MauiSwitch.cs @@ -0,0 +1,133 @@ +using System; +using CoreFoundation; +using CoreGraphics; +using UIKit; + +namespace Microsoft.Maui.Platform +{ + internal class MauiSwitch : UISwitch + { + WeakReference? _virtualView; + bool _colorReapplyQueued; + bool _isReapplyingColors; + bool _needsColorReapply; + bool _hasMauiTrackColorOverride; + + public MauiSwitch(CGRect frame) : base(frame) + { + } + + public void Connect(ISwitch virtualView) + { + _virtualView = new(virtualView); + SetNeedsColorReapply(); + } + + public void Disconnect() + { + _virtualView = null; + _needsColorReapply = false; + } + + public void SetNeedsColorReapply() + { + var virtualView = VirtualView; + + if (virtualView is null || virtualView.ShouldPreserveNativeDefaults()) + { + _needsColorReapply = false; + return; + } + + _needsColorReapply = true; + SetNeedsLayout(); + QueueColorReapply(); + } + + public override void MovedToWindow() + { + base.MovedToWindow(); + if (Window is not null) + { + SetNeedsColorReapply(); + } + } + + public override void LayoutSubviews() + { + base.LayoutSubviews(); + QueueColorReapply(); + } + + public override void TraitCollectionDidChange(UITraitCollection? previousTraitCollection) + { + base.TraitCollectionDidChange(previousTraitCollection); + SetNeedsColorReapply(); + } + + void QueueColorReapply() + { + if (_colorReapplyQueued || !_needsColorReapply) + { + return; + } + + _colorReapplyQueued = true; + + DispatchQueue.MainQueue.DispatchAsync(() => + { + _colorReapplyQueued = false; + TryReapplyColors(); + }); + } + + void TryReapplyColors() + { + if (_isReapplyingColors || !_needsColorReapply) + { + return; + } + + var virtualView = VirtualView; + + if (virtualView is null || virtualView.ShouldPreserveNativeDefaults()) + { + _needsColorReapply = false; + return; + } + + if (!this.IsReadyForColorReapply()) + { + return; + } + + _isReapplyingColors = true; + + try + { + this.ApplyTrackColor(virtualView); + this.ApplyThumbColor(virtualView); + _needsColorReapply = false; + } + finally + { + _isReapplyingColors = false; + } + } + + ISwitch? VirtualView => + _virtualView is not null && _virtualView.TryGetTarget(out var virtualView) ? virtualView : null; + + internal bool HasMauiTrackColorOverride => _hasMauiTrackColorOverride; + + internal void MarkMauiTrackColorOverride() + { + _hasMauiTrackColorOverride = true; + } + + internal void ClearMauiTrackColorOverride() + { + _hasMauiTrackColorOverride = false; + } + } +} diff --git a/src/Core/src/Platform/iOS/SwitchExtensions.cs b/src/Core/src/Platform/iOS/SwitchExtensions.cs index 0133b681f7b0..e86da3e5f084 100644 --- a/src/Core/src/Platform/iOS/SwitchExtensions.cs +++ b/src/Core/src/Platform/iOS/SwitchExtensions.cs @@ -19,6 +19,24 @@ public static void UpdateTrackColor(this UISwitch uiSwitch, ISwitch view) return; } + var styleChanged = uiSwitch.UpdatePreferredStyle(view); + + if (view.ShouldPreserveNativeDefaults()) + { + uiSwitch.ClearCustomColorState(); + return; + } + + uiSwitch.ApplyTrackColor(view); + + if (styleChanged) + { + uiSwitch.ReapplyColorsAfterStyleUpdate(view); + } + } + + internal static void ApplyTrackColor(this UISwitch uiSwitch, ISwitch view) + { var uIView = GetTrackSubview(uiSwitch); if (uIView is null) @@ -26,6 +44,8 @@ public static void UpdateTrackColor(this UISwitch uiSwitch, ISwitch view) return; } + (uiSwitch as MauiSwitch)?.MarkMauiTrackColorOverride(); + var trackColor = view.TrackColor?.ToPlatform(); if (view.IsOn) @@ -62,9 +82,110 @@ public static void UpdateThumbColor(this UISwitch uiSwitch, ISwitch view) if (view == null) return; - Graphics.Color thumbColor = view.ThumbColor; - if (thumbColor != null) - uiSwitch.ThumbTintColor = thumbColor?.ToPlatform(); + var styleChanged = uiSwitch.UpdatePreferredStyle(view); + + if (view.ShouldPreserveNativeDefaults()) + { + uiSwitch.ClearCustomColorState(); + return; + } + + uiSwitch.ApplyThumbColor(view); + + if (styleChanged) + { + uiSwitch.ReapplyColorsAfterStyleUpdate(view); + } + } + + internal static void ApplyThumbColor(this UISwitch uiSwitch, ISwitch view) + { + uiSwitch.ThumbTintColor = view.ThumbColor?.ToPlatform(); + } + + static bool UpdatePreferredStyle(this UISwitch uiSwitch, ISwitch view) + { +#if IOS || MACCATALYST + if (!IsSlidingStyleRequiredForCustomColors()) + { + return false; + } + + var preferredStyle = view.HasCustomColors() + ? UISwitchStyle.Sliding + : UISwitchStyle.Automatic; + + if (uiSwitch.PreferredStyle != preferredStyle) + { + uiSwitch.PreferredStyle = preferredStyle; + uiSwitch.SetNeedsLayout(); + return true; + } +#endif + return false; + } + + static void ReapplyColorsAfterStyleUpdate(this UISwitch uiSwitch, ISwitch view) + { +#if IOS || MACCATALYST + if (!IsSlidingStyleRequiredForCustomColors() || !view.HasCustomColors()) + { + return; + } + + if (uiSwitch is MauiSwitch mauiSwitch) + { + mauiSwitch.SetNeedsColorReapply(); + } + else if (uiSwitch.IsReadyForColorReapply()) + { + uiSwitch.ApplyTrackColor(view); + uiSwitch.ApplyThumbColor(view); + } +#endif + } + + internal static bool HasCustomColors(this ISwitch view) + { + return view.TrackColor is not null || view.ThumbColor is not null; + } + + internal static bool ShouldPreserveNativeDefaults(this ISwitch view) + { + return IsSlidingStyleRequiredForCustomColors() && !view.HasCustomColors(); + } + + static void ClearCustomColorState(this UISwitch uiSwitch) + { + uiSwitch.OnTintColor = null; + uiSwitch.ThumbTintColor = null; + + if (uiSwitch is MauiSwitch mauiSwitch && mauiSwitch.HasMauiTrackColorOverride) + { + uiSwitch.GetTrackSubview()?.BackgroundColor = null; + mauiSwitch.ClearMauiTrackColorOverride(); + } + } + + internal static bool IsReadyForColorReapply(this UISwitch uiSwitch) + { + var trackSubview = uiSwitch.GetTrackSubview(); + + return uiSwitch.Window is not null + && trackSubview is not null + && uiSwitch.Bounds.Width > 0 + && uiSwitch.Bounds.Height > 0 + && trackSubview.Bounds.Width > 0 + && trackSubview.Bounds.Height > 0; + } + + static bool IsSlidingStyleRequiredForCustomColors() + { +#if IOS || MACCATALYST + return OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26); +#else + return false; +#endif } internal static UIView? GetTrackSubview(this UISwitch uISwitch) diff --git a/src/Core/tests/DeviceTests/Handlers/Switch/SwitchHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/Switch/SwitchHandlerTests.iOS.cs index 0375ae326544..e239bb53c9b4 100644 --- a/src/Core/tests/DeviceTests/Handlers/Switch/SwitchHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/Switch/SwitchHandlerTests.iOS.cs @@ -1,11 +1,15 @@ using System; using System.Threading.Tasks; +using Microsoft.Maui.Controls; using Microsoft.Maui.DeviceTests.Stubs; using Microsoft.Maui.Graphics; using Microsoft.Maui.Handlers; using ObjCRuntime; using UIKit; using Xunit; +using AppTheme = Microsoft.Maui.ApplicationModel.AppTheme; +using ControlsApplication = Microsoft.Maui.Controls.Application; +using ControlsSwitch = Microsoft.Maui.Controls.Switch; namespace Microsoft.Maui.DeviceTests { @@ -86,6 +90,76 @@ async Task ValidateTrackSubViewExists(ISwitch switchStub) Assert.NotNull(uIView); } + UIImage CaptureRenderedSwitch(SwitchHandler handler) + { + var nativeSwitch = GetNativeSwitch(handler); + + nativeSwitch.SizeToFit(); + nativeSwitch.SetNeedsLayout(); + nativeSwitch.LayoutIfNeeded(); + + UIView renderedView = handler.ContainerView is not null ? handler.ContainerView : nativeSwitch; + renderedView.SetNeedsLayout(); + renderedView.LayoutIfNeeded(); + + UIGraphics.BeginImageContextWithOptions(renderedView.Bounds.Size, false, 0); + renderedView.DrawViewHierarchy(renderedView.Bounds, true); + var bitmap = UIGraphics.GetImageFromCurrentImageContext(); + UIGraphics.EndImageContext(); + + Assert.NotNull(bitmap); + + return bitmap; + } + + async Task AssertSwitchColorsApplied(UISwitch nativeSwitch, Color trackColor, Color thumbColor, string messageSuffix) + { + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.GetTrackColor(), trackColor.ToPlatform(), tolerance: 0.1)) + .AssertEventually(message: $"Native switch track color did not apply before {messageSuffix}."); + + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.ThumbTintColor, thumbColor.ToPlatform(), tolerance: 0.1)) + .AssertEventually(message: $"Native switch thumb color did not apply before {messageSuffix}."); + } + + static bool IsIOSOrMacCatalyst26OrNewer() + { + return OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26); + } + + static UIColor GetDefaultOffTrackColor() + { + return OperatingSystem.IsIOSVersionAtLeast(13) ? UIColor.SecondarySystemFill : UIColor.FromRGBA(120, 120, 128, 40); + } + + async Task AssertDefaultSwitchDoesNotReapplyColors(UISwitch nativeSwitch) + { + var trackSubview = nativeSwitch.GetTrackSubview(); + Assert.NotNull(trackSubview); + + var preservedTrackColor = UIColor.FromRGBA(12, 34, 56, 255); + var preservedOnTintColor = UIColor.FromRGBA(78, 90, 123, 255); + var preservedThumbColor = UIColor.FromRGBA(45, 67, 89, 255); + + trackSubview.BackgroundColor = preservedTrackColor; + nativeSwitch.OnTintColor = preservedOnTintColor; + nativeSwitch.ThumbTintColor = preservedThumbColor; + + nativeSwitch.MovedToWindow(); + await Task.Delay(100); + + Assert.True( + ColorComparison.ARGBEquivalent(nativeSwitch.GetTrackColor(), preservedTrackColor, tolerance: 0.1), + "Default switch track color was unexpectedly reapplied."); + + Assert.True( + ColorComparison.ARGBEquivalent(nativeSwitch.OnTintColor, preservedOnTintColor, tolerance: 0.1), + "Default switch on tint color was unexpectedly cleared or reapplied."); + + Assert.True( + ColorComparison.ARGBEquivalent(nativeSwitch.ThumbTintColor, preservedThumbColor, tolerance: 0.1), + "Default switch thumb tint color was unexpectedly cleared or reapplied."); + } + /// /// If a UISwitch grows beyond 101 pixels it's no longer /// clickable via Voice Over @@ -130,6 +204,11 @@ await ValidatePropertyUpdatesValue( [Fact(DisplayName = "Track Color's view is default color when toggled off")] public async Task OffTrackColorSetToDefaultColor() { + if (IsIOSOrMacCatalyst26OrNewer()) + { + return; + } + var switchStub = new SwitchStub() { IsOn = true, @@ -144,9 +223,45 @@ await ValidatePropertyUpdatesValue( expectedSetValue: false, expectedUnsetValue: true); - var color = OperatingSystem.IsIOSVersionAtLeast(13) ? UIColor.SecondarySystemFill : UIColor.FromRGBA(120, 120, 128, 40); + await ValidateVisualTrackColor(switchStub, GetDefaultOffTrackColor()); + }); + } + + [Fact(DisplayName = "Default Switch Reapplies Legacy Off Track Color Before iOS/MacCatalyst 26")] + public async Task DefaultSwitchReappliesLegacyOffTrackColorBeforeiOSOrMacCatalyst26() + { + if (IsIOSOrMacCatalyst26OrNewer()) + { + return; + } + + var switchStub = new SwitchStub + { + IsOn = false + }; + + await AttachAndRun(switchStub, async (SwitchHandler handler) => + { + var nativeSwitch = GetNativeSwitch(handler); + + await new Func(() => nativeSwitch.IsReadyForColorReapply()) + .AssertEventually(message: "Native switch was not ready for legacy color reapply."); + + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.GetTrackColor(), GetDefaultOffTrackColor(), tolerance: 0.1)) + .AssertEventually(message: "Default switch did not apply the initial legacy off track color."); + + var trackSubview = nativeSwitch.GetTrackSubview(); + Assert.NotNull(trackSubview); + + trackSubview.BackgroundColor = UIColor.Purple; + Assert.True( + ColorComparison.ARGBEquivalent(nativeSwitch.GetTrackColor(), UIColor.Purple, tolerance: 0.1), + "Test setup failed to poison the default switch track color."); - await ValidateVisualTrackColor(switchStub, color); + nativeSwitch.MovedToWindow(); + + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.GetTrackColor(), GetDefaultOffTrackColor(), tolerance: 0.1)) + .AssertEventually(message: "Default switch did not reapply the legacy off track color after moving to a window."); }); } @@ -160,5 +275,263 @@ await InvokeOnMainThreadAsync(async () => await ValidateTrackSubViewExists(switchStub); }); } + + [Theory(DisplayName = "Custom Colors Use Sliding Style On iOS/MacCatalyst 26")] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task CustomColorsUseSlidingStyleOniOSOrMacCatalyst26(bool hasTrackColor, bool hasThumbColor) + { + if (!IsIOSOrMacCatalyst26OrNewer()) + { + return; + } + + var switchStub = new SwitchStub + { + TrackColor = hasTrackColor ? Colors.Red : null, + ThumbColor = hasThumbColor ? Colors.Orange : null + }; + + var styles = await GetValueAsync(switchStub, handler => + { + var nativeSwitch = GetNativeSwitch(handler); + return (nativeSwitch.PreferredStyle, nativeSwitch.Style); + }); + + Assert.Equal(UISwitchStyle.Sliding, styles.PreferredStyle); + Assert.Equal(UISwitchStyle.Sliding, styles.Style); + } + + [Fact(DisplayName = "Custom Colors Render On Initial Off State On iOS/MacCatalyst 26")] + public async Task CustomColorsRenderOnInitialOffStateOniOSOrMacCatalyst26() + { + if (!IsIOSOrMacCatalyst26OrNewer()) + { + return; + } + + var switchStub = new SwitchStub + { + IsOn = false, + TrackColor = Colors.Red, + ThumbColor = Colors.Orange + }; + + await AttachAndRun(switchStub, async (SwitchHandler handler) => + { + var nativeSwitch = GetNativeSwitch(handler); + await AssertSwitchColorsApplied(nativeSwitch, Colors.Red, Colors.Orange, "initial bitmap capture"); + + var bitmap = CaptureRenderedSwitch(handler); + await bitmap.AssertContainsColor(Colors.Red, tolerance: 0.1); + }); + } + + [Fact(DisplayName = "Default Switch Uses Automatic Style On iOS/MacCatalyst 26")] + public async Task DefaultSwitchUsesAutomaticStyleOniOSOrMacCatalyst26() + { + if (!IsIOSOrMacCatalyst26OrNewer()) + { + return; + } + + var switchStub = new SwitchStub(); + + await AttachAndRun(switchStub, async (SwitchHandler handler) => + { + var nativeSwitch = GetNativeSwitch(handler); + + Assert.Equal(UISwitchStyle.Automatic, nativeSwitch.PreferredStyle); + await AssertDefaultSwitchDoesNotReapplyColors(nativeSwitch); + }); + } + + [Fact(DisplayName = "Thumb Color Clears When Reset On iOS/MacCatalyst 26")] + public async Task ThumbColorClearsWhenResetOniOSOrMacCatalyst26() + { + if (!IsIOSOrMacCatalyst26OrNewer()) + { + return; + } + + var switchStub = new SwitchStub + { + TrackColor = Colors.Red, + ThumbColor = Colors.Orange + }; + + await AttachAndRun(switchStub, async (SwitchHandler handler) => + { + var nativeSwitch = GetNativeSwitch(handler); + + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.ThumbTintColor, Colors.Orange.ToPlatform(), tolerance: 0.1)) + .AssertEventually(message: "Native switch thumb color did not update to the custom color."); + + switchStub.ThumbColor = null; + handler.UpdateValue(nameof(ISwitch.ThumbColor)); + + Assert.Equal(UISwitchStyle.Sliding, nativeSwitch.PreferredStyle); + Assert.Null(nativeSwitch.ThumbTintColor); + }); + } + + [Fact(DisplayName = "Custom Colors Clear To Automatic Style On iOS/MacCatalyst 26")] + public async Task CustomColorsClearToAutomaticStyleOniOSOrMacCatalyst26() + { + if (!IsIOSOrMacCatalyst26OrNewer()) + { + return; + } + + var switchStub = new SwitchStub + { + IsOn = false, + TrackColor = Colors.Red, + ThumbColor = Colors.Orange + }; + + await AttachAndRun(switchStub, async (SwitchHandler handler) => + { + var nativeSwitch = GetNativeSwitch(handler); + + await AssertSwitchColorsApplied(nativeSwitch, Colors.Red, Colors.Orange, "resetting custom colors"); + + switchStub.TrackColor = null; + switchStub.ThumbColor = null; + handler.UpdateValue(nameof(ISwitch.TrackColor)); + handler.UpdateValue(nameof(ISwitch.ThumbColor)); + + Assert.Equal(UISwitchStyle.Automatic, nativeSwitch.PreferredStyle); + Assert.Null(nativeSwitch.OnTintColor); + Assert.Null(nativeSwitch.ThumbTintColor); + Assert.False( + ColorComparison.ARGBEquivalent(nativeSwitch.GetTrackColor(), Colors.Red.ToPlatform(), tolerance: 0.1), + "Native switch track color kept the stale custom color after custom colors were cleared."); + + await AssertDefaultSwitchDoesNotReapplyColors(nativeSwitch); + }); + } + + [Fact(DisplayName = "Custom Colors Reapply After Moved To Window On iOS/MacCatalyst 26")] + public async Task CustomColorsReapplyAfterMovedToWindowOniOSOrMacCatalyst26() + { + if (!IsIOSOrMacCatalyst26OrNewer()) + { + return; + } + + var switchStub = new SwitchStub + { + IsOn = false, + TrackColor = Colors.Red, + ThumbColor = Colors.Orange + }; + + await AttachAndRun(switchStub, async (SwitchHandler handler) => + { + var nativeSwitch = GetNativeSwitch(handler); + + await new Func(() => nativeSwitch.IsReadyForColorReapply()) + .AssertEventually(message: "Native switch was not ready for color reapply."); + + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.GetTrackColor(), Colors.Red.ToPlatform(), tolerance: 0.1)) + .AssertEventually(message: "Native switch track color did not initially apply."); + + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.ThumbTintColor, Colors.Orange.ToPlatform(), tolerance: 0.1)) + .AssertEventually(message: "Native switch thumb color did not initially apply."); + + var trackSubview = nativeSwitch.GetTrackSubview(); + Assert.NotNull(trackSubview); + + trackSubview.BackgroundColor = UIColor.Clear; + nativeSwitch.ThumbTintColor = UIColor.Purple; + + nativeSwitch.MovedToWindow(); + + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.GetTrackColor(), Colors.Red.ToPlatform(), tolerance: 0.1)) + .AssertEventually(message: "Native switch track color did not reapply after moving to a window."); + + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.ThumbTintColor, Colors.Orange.ToPlatform(), tolerance: 0.1)) + .AssertEventually(message: "Native switch thumb color did not reapply after moving to a window."); + }); + } + + [Fact(DisplayName = "Custom Colors Update After App Theme Change On iOS/MacCatalyst 26")] + public async Task CustomColorsUpdateAfterAppThemeChangeOniOSOrMacCatalyst26() + { + if (!IsIOSOrMacCatalyst26OrNewer()) + { + return; + } + + var previousApplication = ControlsApplication.Current; + var previousTheme = previousApplication?.UserAppTheme ?? AppTheme.Unspecified; + var application = new ControlsApplication(); + var switchView = new ControlsSwitch + { + IsToggled = false + }; + + application.UserAppTheme = AppTheme.Light; +#pragma warning disable CS0618 // MainPage is enough to parent the test element for app-theme resource propagation. + application.MainPage = new ContentPage { Content = switchView }; +#pragma warning restore CS0618 + switchView.SetAppThemeColor(ControlsSwitch.OffColorProperty, Colors.Red, Colors.Blue); + switchView.SetAppThemeColor(ControlsSwitch.ThumbColorProperty, Colors.Orange, Colors.Yellow); + application.UpdateUserInterfaceStyle(); + + try + { + await AttachAndRun(switchView, async (SwitchHandler handler) => + { + var nativeSwitch = GetNativeSwitch(handler); + + await AssertSwitchColorsApplied(nativeSwitch, Colors.Red, Colors.Orange, "initial theme bitmap capture"); + await CaptureRenderedSwitch(handler).AssertContainsColor(Colors.Red, tolerance: 0.1); + + application.UserAppTheme = AppTheme.Dark; + application.UpdateUserInterfaceStyle(); + + await new Func(() => switchView.OffColor == Colors.Blue) + .AssertEventually(message: "Switch OffColor did not update to the dark app theme color."); + + await new Func(() => switchView.ThumbColor == Colors.Yellow) + .AssertEventually(message: "Switch ThumbColor did not update to the dark app theme color."); + + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.GetTrackColor(), Colors.Blue.ToPlatform(), tolerance: 0.1)) + .AssertEventually(message: "Native switch track color did not update to the dark app theme color."); + + await new Func(() => ColorComparison.ARGBEquivalent(nativeSwitch.ThumbTintColor, Colors.Yellow.ToPlatform(), tolerance: 0.1)) + .AssertEventually(message: "Native switch thumb color did not update to the dark app theme color."); + + await new Func>(async () => + { + try + { + await CaptureRenderedSwitch(handler).AssertContainsColor(Colors.Blue, tolerance: 0.1); + return true; + } + catch + { + return false; + } + }).AssertEventuallyAsync(message: "Rendered switch track did not update to the dark app theme color."); + }); + } + finally + { + application.UserAppTheme = AppTheme.Unspecified; + application.UpdateUserInterfaceStyle(); + ControlsApplication.Current = previousApplication; + + if (previousApplication is not null) + { + previousApplication.UserAppTheme = previousTheme; + previousApplication.UpdateUserInterfaceStyle(); + } + } + } + } }