Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 20 additions & 13 deletions src/Core/src/Platform/iOS/MauiDoneAccessoryView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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);
}

Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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)
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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
});
Expand Down Expand Up @@ -117,16 +170,16 @@ 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));
Assert.Equal(expected, accessoryView.DoneButton?.AccessibilityLabel);
}
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<UIToolbar>(Assert.Single(accessoryView.Subviews));
Assert.True(toolbar.Translucent);
}
Expand All @@ -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<UIButton>(Assert.Single(accessoryView.Subviews));
Assert.Null(accessoryView.HitTest(backgroundPoint, null));
}
else
{
Assert.IsType<UIToolbar>(Assert.Single(accessoryView.Subviews));
Assert.NotNull(accessoryView.HitTest(backgroundPoint, null));
}
}

static CGPoint FindDoneButtonHitPoint(MauiDoneAccessoryView accessoryView, bool doneButtonIsLeading)
{
var y = accessoryView.Bounds.GetMidY();
Expand Down
Loading