Skip to content
2 changes: 1 addition & 1 deletion src/Controls/tests/TestCases.HostApp/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
67 changes: 15 additions & 52 deletions src/Core/src/Handlers/Switch/SwitchHandler.iOS.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
using System;
using System.Threading.Tasks;
using CoreFoundation;
using Foundation;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Platform;
using ObjCRuntime;
using UIKit;
using RectangleF = CoreGraphics.CGRect;

Expand All @@ -22,7 +20,7 @@ public partial class SwitchHandler : ViewHandler<ISwitch, UISwitch>

protected override UISwitch CreatePlatformView()
{
return new UISwitch(RectangleF.Empty);
return new MauiSwitch(RectangleF.Empty);
}

protected override void ConnectHandler(UISwitch platformView)
Expand Down Expand Up @@ -70,12 +68,12 @@ class SwitchProxy

NSObject? _willEnterForegroundObserver;
NSObject? _windowDidBecomeKeyObserver;
IUITraitChangeRegistration? _traitChangeRegistration;

public void Connect(ISwitch virtualView, UISwitch platformView)
{
_virtualView = new(virtualView);
_platformView = new(platformView);
(platformView as MauiSwitch)?.Connect(virtualView);
platformView.ValueChanged += OnControlValueChanged;

#if MACCATALYST
Expand All @@ -102,25 +100,10 @@ public void Connect(ISwitch virtualView, UISwitch platformView)
});
#endif

// iOS/MacCatalyst 26+ resets ThumbTintColor when theme changes (light/dark mode).
// Register for trait changes to re-apply ThumbColor after UIKit completes its styling.
// 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))
{
if (_traitChangeRegistration is not null)
{
platformView.UnregisterForTraitChanges(_traitChangeRegistration);
}

_traitChangeRegistration = platformView.RegisterForTraitChanges<UITraitUserInterfaceStyle>(
(IUITraitEnvironment view, UITraitCollection _) =>
{
if (view is UISwitch uiSwitch)
{
UpdateThumbAndTrackColor(uiSwitch);
}
});

// iOS 26+ resets ThumbTintColor after initial layout, so re-apply the custom ThumbColor here.
UpdateThumbAndTrackColor(platformView);
}
}
Expand All @@ -130,48 +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(async () =>
DispatchQueue.MainQueue.DispatchAsync(() =>
{
if (VirtualView is null || PlatformView is null)
if (VirtualView is not ISwitch view || PlatformView is null || !view.HasCustomColors())
return;

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)
{
// iOS 26+ "Liquid Glass" resets TrackColor during post-connect layout. Re-apply both.
if (view.TrackColor is not null)
{
platformView.UpdateTrackColor(view);
}

if (view.ThumbColor is not null)
{
platformView.UpdateThumbColor(view);
}
}
platformView.UpdateTrackColor(view);
Comment thread
AdamEssenmacher marked this conversation as resolved.
platformView.UpdateThumbColor(view);
(platformView as MauiSwitch)?.SetNeedsColorReapply();
});
}

public void Disconnect(UISwitch platformView)
{
platformView.ValueChanged -= OnControlValueChanged;
(platformView as MauiSwitch)?.Disconnect();

if (_willEnterForegroundObserver is not null)
{
Expand All @@ -183,11 +151,6 @@ public void Disconnect(UISwitch platformView)
NSNotificationCenter.DefaultCenter.RemoveObserver(_windowDidBecomeKeyObserver);
_windowDidBecomeKeyObserver = null;
}
if (_traitChangeRegistration is not null)
{
platformView.UnregisterForTraitChanges(_traitChangeRegistration);
_traitChangeRegistration = null;
}
}

void OnControlValueChanged(object? sender, EventArgs e)
Expand All @@ -199,4 +162,4 @@ void OnControlValueChanged(object? sender, EventArgs e)
}
}
}
}
}
133 changes: 133 additions & 0 deletions src/Core/src/Platform/iOS/MauiSwitch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System;
using CoreFoundation;
using CoreGraphics;
using UIKit;

namespace Microsoft.Maui.Platform
{
internal class MauiSwitch : UISwitch
{
WeakReference<ISwitch>? _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);
Comment thread
AdamEssenmacher marked this conversation as resolved.
Comment thread
AdamEssenmacher marked this conversation as resolved.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Native defaults preservationTryReapplyColors reapplies track/thumb colors even when both TrackColor and ThumbColor are null. For an uncustomized off switch on iOS/MacCatalyst 26, the style can remain Automatic, but ApplyTrackColor still writes SecondarySystemFill into the internal track subview and ApplyThumbColor writes null into ThumbTintColor, so layout/trait/window reapply paths can mutate UIKit's native Liquid Glass/default rendering. Please gate reapply on custom-color presence, or make the low-level apply methods no-op when the switch is Automatic with no custom colors, and add a regression test that verifies default/native rendering is preserved rather than only checking PreferredStyle.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa5652e

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;
}
}
}
Loading
Loading