Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class GesturePlatformManager : IDisposable
WeakReference<PlatformView>? _platformView;
UIAccessibilityTrait _addedFlags;
bool? _defaultAccessibilityRespondsToUserInteraction;
bool? _defaultShouldGroupAccessibilityChildren;
bool _setShouldGroupAccessibilityChildren;
bool _setAccessibilityActivateCallback;

double _previousScale = 1.0;
ShouldReceiveTouchProxy? _proxy;
Expand Down Expand Up @@ -117,6 +120,11 @@ public void Dispose()
_interactions.Clear();
_gestureRecognizers.Clear();

if (PlatformView is not null)
{
ResetAccessibilityPromotionFlags();
}

_dragAndDropDelegate?.Disconnect();
_dragAndDropDelegate = null;

Expand Down Expand Up @@ -658,6 +666,83 @@ _handler.VirtualView is View v &&
{
PlatformView.AccessibilityTraits |= UIAccessibilityTrait.Button;
_addedFlags |= UIAccessibilityTrait.Button;

// Ensure container views are marked as accessibility elements so VoiceOver
// can announce the Button trait and make the container focusable for VoiceOver.
// Skip if IsAccessibilityElement is already true (e.g. set by SemanticExtensions for a
// Hint/Description on this layout) — when the container is already a leaf accessibility
// element, ShouldGroupAccessibilityChildren is ignored by UIKit and would be redundant.
//
// LIMITATION (Path A — gesture-only, no Hint/Description): Setting
// ShouldGroupAccessibilityChildren = true alone does NOT make the layout focusable to
// VoiceOver; UIKit only focuses views whose IsAccessibilityElement is true. So for a
// tappable layout without any Semantics set, VoiceOver still navigates directly to the
// individual children, the Button trait above is silenced, and the
// AccessibilityActivateCallback registered below is never reached via VoiceOver.
// This is a pre-existing UIKit constraint, intentionally not addressed by this fix
// (which targets the Hint scenario from issue #34380).
if (!PlatformView.ShouldGroupAccessibilityChildren
&& !PlatformView.IsAccessibilityElement
&& _handler.VirtualView is global::Microsoft.Maui.ILayout)
{
// Capture the pre-existing value so cleanup can restore it (mirrors the
// _defaultAccessibilityRespondsToUserInteraction pattern below).
_defaultShouldGroupAccessibilityChildren = PlatformView.ShouldGroupAccessibilityChildren;
PlatformView.ShouldGroupAccessibilityChildren = true;
_setShouldGroupAccessibilityChildren = true;
}

// UIKit's default accessibilityActivate() simulates touch events which are intermittently
// unreliable for UITapGestureRecognizer (especially on macOS Catalyst Ctrl+Option+Space).
// Bypass that path by directly invoking SendTapped on the MAUI TapGestureRecognizer for
// both iOS and Catalyst. VoiceOver activation is a single semantic event — UIKit's
// simulated-touch path also does not honor NumberOfTapsRequired > 1 from a VoiceOver
// activation, so the direct path does not regress that scenario and makes activation
// reliable across both platforms.
// This block is decoupled from the grouping block above so it also registers when
// SemanticExtensions already promoted the container (layout with Hint + gesture).
// Note: Only Microsoft.Maui.Platform.MauiView exposes AccessibilityActivateCallback,
// so layouts whose platform view is not a MauiView (e.g. Border, ScrollView, custom
// container handlers) fall back to UIKit's default simulated-touch activation path.
if (PlatformView is Microsoft.Maui.Platform.MauiView mauiView &&
_handler.VirtualView is global::Microsoft.Maui.ILayout)
{
var weakThis = new WeakReference<GesturePlatformManager>(this);

mauiView.AccessibilityActivateCallback = () =>
{
if (!weakThis.TryGetTarget(out var manager))
{
return false;
}

var view = manager._handler?.VirtualView as View;

if (view is null)
{
return false;
}

// Honor the same gates UIKit's touch dispatch would have applied. VoiceOver
// activation bypasses UIKit's hit-testing/touch path, so without these
// guards a disabled or input-transparent layout would still fire its tap.
if (!view.IsEnabled || view.InputTransparent)
{
return false;
}

if (view.HasAccessibleTapGesture(out var tap))
{
tap.SendTapped(view);
return true;
}

return false;
};

_setAccessibilityActivateCallback = true;
}

if (OperatingSystem.IsIOSVersionAtLeast(13) || OperatingSystem.IsMacCatalystVersionAtLeast(13)
#if TVOS
|| OperatingSystem.IsTvOSVersionAtLeast(11)
Expand Down Expand Up @@ -879,6 +964,8 @@ void GestureRecognizersOnCollectionChanged(object? sender, NotifyCollectionChang
{
PlatformView.AccessibilityTraits &= ~_addedFlags;

ResetAccessibilityPromotionFlags();

if (OperatingSystem.IsIOSVersionAtLeast(13) || OperatingSystem.IsMacCatalystVersionAtLeast(13))
{
if (_defaultAccessibilityRespondsToUserInteraction != null)
Expand All @@ -891,6 +978,31 @@ void GestureRecognizersOnCollectionChanged(object? sender, NotifyCollectionChang
LoadRecognizers();
}

// Reverts any accessibility-related state this manager set on the platform view when a
// tap gesture was wired up. Called from both Disconnect() and the gesture-collection-changed
// path so the two stay in sync.
void ResetAccessibilityPromotionFlags()
{
if (PlatformView is null)
{
return;
}

if (_setShouldGroupAccessibilityChildren)
{
PlatformView.ShouldGroupAccessibilityChildren = _defaultShouldGroupAccessibilityChildren ?? false;
}

if (_setAccessibilityActivateCallback && PlatformView is Microsoft.Maui.Platform.MauiView mv)
{
mv.AccessibilityActivateCallback = null;
}

_setShouldGroupAccessibilityChildren = false;
_setAccessibilityActivateCallback = false;
_defaultShouldGroupAccessibilityChildren = null;
}

void OnElementChanged(object sender, VisualElementChangedEventArgs e)
{
if (e.OldElement != null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Hosting;
using Microsoft.Maui.DeviceTests.Stubs;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Hosting;
using Microsoft.Maui.Platform;
using UIKit;
using Xunit;

namespace Microsoft.Maui.DeviceTests
{
// Regression tests for https://github.com/dotnet/maui/issues/34380
// [iOS] VoiceOver does not correctly describe a View with GestureRecognizers when it has
// SemanticProperties.Hint and child Labels — should synthesize an accessibility label from
// children, promote the layout to an accessibility element, and route VoiceOver activation
// to the layout's TapGestureRecognizer.
public partial class AccessibilityTests
{
[Category(TestCategory.Accessibility)]
[Collection(ControlsHandlerTestBase.RunInNewWindowCollection)]
public class Issue34380Tests : ControlsHandlerTestBase
{
void SetupBuilder()
{
EnsureHandlerCreated(builder =>
{
builder.ConfigureMauiHandlers(handlers =>
{
handlers.AddMauiControlsHandlers();
handlers.AddHandler(typeof(Window), typeof(WindowHandlerStub));
});
});
}

[Fact("Layout with Hint and child Labels synthesizes a combined AccessibilityLabel")]
public async Task LayoutWithHintAndChildLabels_SynthesizesAccessibilityLabel()
{
SetupBuilder();

var layout = new VerticalStackLayout();
layout.Add(new Label { Text = "First line" });
layout.Add(new Label { Text = "Second line" });
SemanticProperties.SetHint(layout, "Activates the action");

var page = new ContentPage { Content = layout };

await CreateHandlerAndAddToWindow<IWindowHandler>(page, async (handler) =>
{
await Task.Delay(100);

var platformView = (UIView)layout.Handler.PlatformView;

Assert.True(platformView.IsAccessibilityElement,
"Layout with Hint should be promoted to an accessibility element.");

Assert.False(string.IsNullOrEmpty(platformView.AccessibilityLabel),
"AccessibilityLabel should be synthesized from child Labels.");

Assert.Contains("First line", platformView.AccessibilityLabel, StringComparison.Ordinal);
Assert.Contains("Second line", platformView.AccessibilityLabel, StringComparison.Ordinal);

Assert.Equal("Activates the action", platformView.AccessibilityHint);
});
}

[Fact("Layout with TapGestureRecognizer (no Hint) sets ShouldGroupAccessibilityChildren")]
public async Task LayoutWithTapGesture_SetsShouldGroupAccessibilityChildren()
{
SetupBuilder();

var layout = new VerticalStackLayout();
layout.Add(new Label { Text = "Tap me" });

var tap = new TapGestureRecognizer();
layout.GestureRecognizers.Add(tap);

var page = new ContentPage { Content = layout };

await CreateHandlerAndAddToWindow<IWindowHandler>(page, async (handler) =>
{
await Task.Delay(100);

var platformView = (UIView)layout.Handler.PlatformView;

Assert.True(platformView.ShouldGroupAccessibilityChildren,
"Layout with TapGestureRecognizer should group accessibility children so the tap target is reachable by VoiceOver.");
});
}

[Fact("AccessibilityActivate on a layout with TapGestureRecognizer fires the gesture")]
public async Task AccessibilityActivate_InvokesTapGesture()
{
SetupBuilder();

var layout = new VerticalStackLayout();
layout.Add(new Label { Text = "Tap" });
SemanticProperties.SetHint(layout, "Tap to act");

bool tapped = false;
var tap = new TapGestureRecognizer();
tap.Tapped += (s, e) => tapped = true;
layout.GestureRecognizers.Add(tap);

var page = new ContentPage { Content = layout };

await CreateHandlerAndAddToWindow<IWindowHandler>(page, async (handler) =>
{
await Task.Delay(100);

var platformView = (UIView)layout.Handler.PlatformView;
var activated = platformView.AccessibilityActivate();

Assert.True(activated, "AccessibilityActivate should report that the activation was handled.");
Assert.True(tapped, "TapGestureRecognizer.Tapped should fire when VoiceOver activates the layout.");
});
}
}
}
}

53 changes: 53 additions & 0 deletions src/Core/src/Platform/iOS/MauiView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -907,5 +907,58 @@ public override void DidUpdateFocus(UIFocusUpdateContext context, UIFocusAnimati
}
}
}

/// <summary>
/// When true, <see cref="AccessibilityLabel"/>'s getter synthesizes the label on demand
/// from the layout's children instead of returning a stored snapshot. Set by
/// <see cref="SemanticExtensions.UpdateSemantics(UIView, IView)"/> when this layout was
/// promoted to an accessibility element with a Hint but no explicit Description, so that
/// child text changes are picked up on each VoiceOver focus. See PR #35590 review
/// comment r3291149230.
/// </summary>
internal bool SynthesizeAccessibilityLabelFromChildren { get; set; }

/// <inheritdoc/>
public override string? AccessibilityLabel
{
get
{
if (SynthesizeAccessibilityLabelFromChildren
&& CrossPlatformLayout is ILayout layout)
{
var synthesized = SemanticExtensions.SynthesizeAccessibilityLabelFromChildren(layout);
if (!string.IsNullOrWhiteSpace(synthesized))
{
return synthesized;
}
}

return base.AccessibilityLabel;
}
set => base.AccessibilityLabel = value;
}

/// <summary>
/// Optional callback invoked by <see cref="AccessibilityActivate"/> when VoiceOver activates this view.
/// Set by GesturePlatformManager for container layouts with tap gestures to bypass UIKit's
/// simulated-touch path, which can be intermittently unreliable on macOS Catalyst.
/// </summary>
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Memory", "MEM0002",
Justification = "Callback captures only a WeakReference<GesturePlatformManager>, which is not an NSObject. No circular strong NSObject reference is created.")]
internal Func<bool>? AccessibilityActivateCallback { get; set; }

/// <inheritdoc/>
public override bool AccessibilityActivate()
{
// Prefer direct MAUI gesture invocation over UIKit's simulated-touch mechanism.
// On macOS Catalyst, the simulated-touch path for UITapGestureRecognizer is
// intermittently unreliable when VoiceOver activates a container with Ctrl+Option+Space.
if (AccessibilityActivateCallback?.Invoke() == true)
{
return true;
}

return base.AccessibilityActivate();
}
}
}
Loading
Loading