diff --git a/src/Core/src/Platform/iOS/MauiDoneAccessoryView.cs b/src/Core/src/Platform/iOS/MauiDoneAccessoryView.cs index 6799abd9ec0a..6efabefddd8e 100644 --- a/src/Core/src/Platform/iOS/MauiDoneAccessoryView.cs +++ b/src/Core/src/Platform/iOS/MauiDoneAccessoryView.cs @@ -11,6 +11,7 @@ internal class MauiDoneAccessoryView : UIView const double AccessoryHeight = 44; const double GlassButtonBottomSpacing = 4; const string DoneAccessoryIdentifier = "DoneAccessory"; + internal const string UseLegacyDoneAccessorySwitch = "Microsoft.Maui.Platform.iOS.UseLegacyDoneAccessory"; // UIKit's localized "Done" label, matching UIBarButtonSystemItem.Done so VoiceOver keeps reading // the affordance in the user's language on the iOS 26+ glass button path. @@ -20,12 +21,16 @@ internal class MauiDoneAccessoryView : UIView // iOS 26 gives bars a translucent Liquid Glass background, so a full-width accessory looks empty // and appears to let taps through to the field behind it (dotnet/maui#36412). On those versions // we replace the bar with a floating, pass-through close button. Earlier iOS keeps the original - // full-width touch-blocking Done toolbar, so the accessory already blocks taps behind it. - static bool UseGlassButton => OperatingSystem.IsIOSVersionAtLeast(26); + // full-width touch-blocking Done toolbar. Apps can temporarily restore that toolbar on iOS 26+ + // by setting the UseLegacyDoneAccessory AppContext switch to true before creating controls. + static bool UseGlassButton => + OperatingSystem.IsIOSVersionAtLeast(26) && + !(AppContext.TryGetSwitch(UseLegacyDoneAccessorySwitch, out var useLegacyDoneAccessory) && useLegacyDoneAccessory); readonly BarButtonItemProxy _proxy; + readonly bool _useGlassButton; - // Cache the discovered button so hit-testing (iOS 26+) doesn't walk the subview tree on every + // Cache the discovered button so glass-path hit-testing doesn't walk the subview tree on every // touch. It's held weakly because the button is already retained by the native Subviews array, // so a strong managed field here would trip the MEM0002 memory-leak analyzer for NSObject // subclasses. The weak reference effectively always resolves while the button is a subview. @@ -34,12 +39,14 @@ internal class MauiDoneAccessoryView : UIView public MauiDoneAccessoryView() : base(InitialFrame()) { _proxy = new BarButtonItemProxy(); + _useGlassButton = UseGlassButton; Initialize(_proxy.OnDataClicked); } public MauiDoneAccessoryView(Action doneClicked) : base(InitialFrame()) { _proxy = new BarButtonItemProxy(doneClicked); + _useGlassButton = UseGlassButton; Initialize(_proxy.OnClicked); } @@ -62,7 +69,7 @@ internal UIButton? DoneButton internal void SendDoneClicked() { - if (UseGlassButton && DoneButton is UIButton button) + if (_useGlassButton && DoneButton is UIButton button) button.SendActionForControlEvents(UIControlEvent.TouchUpInside); else _proxy.Invoke(); @@ -72,11 +79,11 @@ internal void SendDoneClicked() { var hitView = base.HitTest(point, uievent); - // The classic toolbar (iOS < 26) keeps its original full-width touch-blocking behavior. - if (!UseGlassButton) + // The classic toolbar keeps its original full-width touch-blocking behavior. + if (!_useGlassButton) return hitView; - // The floating glass button (iOS 26+) lets taps pass through everywhere except the button + // The floating glass button lets taps pass through everywhere except the button // itself, so the field showing behind the accessory stays tappable. if (hitView is null || Equals(hitView)) return null; @@ -94,14 +101,14 @@ void Initialize(EventHandler doneClicked) { AccessibilityIdentifier = DoneAccessoryIdentifier; - if (UseGlassButton) + if (_useGlassButton) InitializeGlassButton(doneClicked); else InitializeToolbar(doneClicked); } - // iOS < 26: the original translucent toolbar with the system Done item. It sizes itself and is - // already localized, so there are no hard-coded metrics here. + // Legacy path: the original translucent toolbar with the system Done item. It sizes itself and + // is already localized, so there are no hard-coded metrics here. void InitializeToolbar(EventHandler doneClicked) { var toolbar = new UIToolbar(Bounds) @@ -118,9 +125,9 @@ void InitializeToolbar(EventHandler doneClicked) AddSubview(toolbar); } - // iOS 26+: a floating Liquid Glass close button pinned to the trailing layout margin and lifted - // above the keyboard. Keep the original accessory height so UIKit continues scrolling focused - // input above the keyboard. + // Default iOS 26+ path: a floating Liquid Glass close button pinned to the trailing layout margin + // and lifted above the keyboard. Keep the original accessory height so UIKit continues scrolling + // focused input above the keyboard. void InitializeGlassButton(EventHandler doneClicked) { ClipsToBounds = false; diff --git a/src/Core/tests/DeviceTests/Platform/iOS/MauiDoneAccessoryViewTests.iOS.cs b/src/Core/tests/DeviceTests/Platform/iOS/MauiDoneAccessoryViewTests.iOS.cs index 4bed04582f8a..2de975131857 100644 --- a/src/Core/tests/DeviceTests/Platform/iOS/MauiDoneAccessoryViewTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Platform/iOS/MauiDoneAccessoryViewTests.iOS.cs @@ -8,12 +8,65 @@ namespace Microsoft.Maui.DeviceTests { + [CollectionDefinition(MauiDoneAccessoryViewAppContextSwitchCollection.Name, DisableParallelization = true)] + public class MauiDoneAccessoryViewAppContextSwitchCollection + { + public const string Name = "MauiDoneAccessoryView AppContext switch"; + } + [Category(TestCategory.Entry)] + [Collection(MauiDoneAccessoryViewAppContextSwitchCollection.Name)] public class MauiDoneAccessoryViewTests : TestBase { - // iOS 26+ shows the floating, pass-through Liquid Glass close button; earlier versions keep the - // original full-width touch-blocking Done toolbar. The expected behavior differs by path. - static bool UsesGlassButton => OperatingSystem.IsIOSVersionAtLeast(26); + // iOS 26+ shows the floating, pass-through Liquid Glass close button by default; earlier versions + // and apps using the compatibility switch keep the original full-width touch-blocking Done toolbar. + static bool UsesGlassButton => + OperatingSystem.IsIOSVersionAtLeast(26) && + !(AppContext.TryGetSwitch(MauiDoneAccessoryView.UseLegacyDoneAccessorySwitch, out var useLegacyDoneAccessory) && useLegacyDoneAccessory); + + [Fact] + public async Task LegacyDoneAccessorySwitchSelectsExpectedImplementation() + { + var wasConfigured = AppContext.TryGetSwitch( + MauiDoneAccessoryView.UseLegacyDoneAccessorySwitch, + out var previousValue); + + try + { + await InvokeOnMainThreadAsync(() => + { + if (!wasConfigured) + { + using var defaultAccessoryView = CreateLaidOutAccessoryView(); + AssertUsesGlassButton(defaultAccessoryView, OperatingSystem.IsIOSVersionAtLeast(26)); + } + + AppContext.SetSwitch(MauiDoneAccessoryView.UseLegacyDoneAccessorySwitch, false); + using var modernAccessoryView = CreateLaidOutAccessoryView(); + + AssertUsesGlassButton(modernAccessoryView, OperatingSystem.IsIOSVersionAtLeast(26)); + + var wasClicked = false; + AppContext.SetSwitch(MauiDoneAccessoryView.UseLegacyDoneAccessorySwitch, true); + using var legacyAccessoryView = CreateLaidOutAccessoryView(() => wasClicked = true); + + AssertUsesGlassButton(modernAccessoryView, OperatingSystem.IsIOSVersionAtLeast(26)); + AssertUsesGlassButton(legacyAccessoryView, false); + + legacyAccessoryView.SendDoneClicked(); + Assert.True(wasClicked); + + AppContext.SetSwitch(MauiDoneAccessoryView.UseLegacyDoneAccessorySwitch, false); + AssertUsesGlassButton(legacyAccessoryView, false); + }); + } + finally + { + AppContext.SetSwitch( + MauiDoneAccessoryView.UseLegacyDoneAccessorySwitch, + wasConfigured && previousValue); + } + } [Theory] [InlineData(UISemanticContentAttribute.ForceLeftToRight, false)] @@ -30,7 +83,7 @@ await InvokeOnMainThreadAsync(() => var hitView = accessoryView.HitTest(new CGPoint(transparentHitX, accessoryView.Bounds.GetMidY()), null); if (UsesGlassButton) - Assert.Null(hitView); // iOS 26+ floating button lets taps pass through to the field behind + Assert.Null(hitView); // floating button lets taps pass through to the field behind else Assert.NotNull(hitView); // classic toolbar keeps blocking taps across its full width }); @@ -117,7 +170,7 @@ await InvokeOnMainThreadAsync(() => if (UsesGlassButton) { - // iOS 26+: our floating close button supplies the localized "Done" label explicitly. + // The floating close button supplies the localized "Done" label explicitly. var expected = Foundation.NSBundle.FromIdentifier("com.apple.UIKit").GetLocalizedString("Done"); Assert.False(string.IsNullOrEmpty(accessoryView.DoneButton?.AccessibilityLabel)); @@ -125,8 +178,8 @@ await InvokeOnMainThreadAsync(() => } else { - // iOS < 26: the classic UIToolbar hosts a UIBarButtonSystemItem.Done, which UIKit - // localizes for us — so the accessory is a translucent toolbar and needs no manual label. + // The classic UIToolbar hosts a UIBarButtonSystemItem.Done, which UIKit localizes + // for us, so the accessory is a translucent toolbar and needs no manual label. var toolbar = Assert.IsType(Assert.Single(accessoryView.Subviews)); Assert.True(toolbar.Translucent); } @@ -150,6 +203,22 @@ static MauiDoneAccessoryView CreateLaidOutAccessoryView( return accessoryView; } + static void AssertUsesGlassButton(MauiDoneAccessoryView accessoryView, bool expected) + { + var backgroundPoint = new CGPoint(accessoryView.Bounds.GetMinX() + 1, accessoryView.Bounds.GetMidY()); + + if (expected) + { + Assert.IsType(Assert.Single(accessoryView.Subviews)); + Assert.Null(accessoryView.HitTest(backgroundPoint, null)); + } + else + { + Assert.IsType(Assert.Single(accessoryView.Subviews)); + Assert.NotNull(accessoryView.HitTest(backgroundPoint, null)); + } + } + static CGPoint FindDoneButtonHitPoint(MauiDoneAccessoryView accessoryView, bool doneButtonIsLeading) { var y = accessoryView.Bounds.GetMidY();