diff --git a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs index 4c45a6b1581a..bdf750546762 100644 --- a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs +++ b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs @@ -29,6 +29,9 @@ class GesturePlatformManager : IDisposable WeakReference? _platformView; UIAccessibilityTrait _addedFlags; bool? _defaultAccessibilityRespondsToUserInteraction; + bool? _defaultShouldGroupAccessibilityChildren; + bool _setShouldGroupAccessibilityChildren; + bool _setAccessibilityActivateCallback; double _previousScale = 1.0; ShouldReceiveTouchProxy? _proxy; @@ -117,6 +120,11 @@ public void Dispose() _interactions.Clear(); _gestureRecognizers.Clear(); + if (PlatformView is not null) + { + ResetAccessibilityPromotionFlags(); + } + _dragAndDropDelegate?.Disconnect(); _dragAndDropDelegate = null; @@ -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(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) @@ -879,6 +964,8 @@ void GestureRecognizersOnCollectionChanged(object? sender, NotifyCollectionChang { PlatformView.AccessibilityTraits &= ~_addedFlags; + ResetAccessibilityPromotionFlags(); + if (OperatingSystem.IsIOSVersionAtLeast(13) || OperatingSystem.IsMacCatalystVersionAtLeast(13)) { if (_defaultAccessibilityRespondsToUserInteraction != null) @@ -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) diff --git a/src/Controls/tests/DeviceTests/Elements/Accessibility/AccessibilityTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Accessibility/AccessibilityTests.iOS.cs new file mode 100644 index 000000000000..800e7e5e8793 --- /dev/null +++ b/src/Controls/tests/DeviceTests/Elements/Accessibility/AccessibilityTests.iOS.cs @@ -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(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(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(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."); + }); + } + } + } +} + diff --git a/src/Core/src/Platform/iOS/MauiView.cs b/src/Core/src/Platform/iOS/MauiView.cs index addbaed6dac8..bbebd615a76c 100644 --- a/src/Core/src/Platform/iOS/MauiView.cs +++ b/src/Core/src/Platform/iOS/MauiView.cs @@ -907,5 +907,58 @@ public override void DidUpdateFocus(UIFocusUpdateContext context, UIFocusAnimati } } } + + /// + /// When true, 's getter synthesizes the label on demand + /// from the layout's children instead of returning a stored snapshot. Set by + /// 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. + /// + internal bool SynthesizeAccessibilityLabelFromChildren { get; set; } + + /// + 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; + } + + /// + /// Optional callback invoked by 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. + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Memory", "MEM0002", + Justification = "Callback captures only a WeakReference, which is not an NSObject. No circular strong NSObject reference is created.")] + internal Func? AccessibilityActivateCallback { get; set; } + + /// + 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(); + } } } diff --git a/src/Core/src/Platform/iOS/SemanticExtensions.cs b/src/Core/src/Platform/iOS/SemanticExtensions.cs index 3a5a50cede03..60cf0efacca0 100644 --- a/src/Core/src/Platform/iOS/SemanticExtensions.cs +++ b/src/Core/src/Platform/iOS/SemanticExtensions.cs @@ -1,4 +1,5 @@ -using UIKit; +using System.Text; +using UIKit; namespace Microsoft.Maui.Platform { @@ -76,8 +77,105 @@ internal static void PostAccessibilityFocusNotification(this UIView platformView } } - public static void UpdateSemantics(this UIView platformView, IView view) => - UpdateSemantics(platformView, view?.Semantics); + public static void UpdateSemantics(this UIView platformView, IView view) + { + var semantics = view?.Semantics; + + // Null semantics (e.g. ClearValue): reverse any previous promotion so the layout + // stops announcing stale content and its children become navigable again. + if (semantics is null) + { + if (view is ILayout && platformView is not UIControl && platformView.IsAccessibilityElement) + { + platformView.IsAccessibilityElement = false; + platformView.AccessibilityLabel = null; + platformView.AccessibilityHint = null; + if (platformView is MauiView clearedMauiView) + { + clearedMauiView.SynthesizeAccessibilityLabelFromChildren = false; + } + } + + return; + } + + // For layout containers with a Hint set, decide on an AccessibilityLabel that gives + // VoiceOver the children's content (matching Android TalkBack behavior). + // - If the developer explicitly set Description, respect it as the label — don't + // second-guess by appending children. This preserves the pre-fix contract for + // apps that deliberately set Description as a curated summary. + // - If only Hint is set (no Description), synthesize a label from the children's + // text so VoiceOver reads "[children], [hint]" instead of just "[hint]". + // We gate on Hint only (not Description) so Description-only layouts retain their + // existing legacy-path behavior. + if (view is ILayout layout && + !string.IsNullOrWhiteSpace(semantics.Hint)) + { + if (!string.IsNullOrWhiteSpace(semantics.Description)) + { + // Explicit Description wins — honor the developer's curated label. + platformView.AccessibilityLabel = semantics.Description; + if (platformView is MauiView mauiViewWithDesc) + { + mauiViewWithDesc.SynthesizeAccessibilityLabelFromChildren = false; + } + } + else if (platformView is MauiView mauiViewLayout) + { + // Defer synthesis to MauiView.AccessibilityLabel's getter so child text changes + // are picked up on each VoiceOver focus (avoids stale one-shot snapshot). + mauiViewLayout.SynthesizeAccessibilityLabelFromChildren = true; + mauiViewLayout.AccessibilityLabel = null; + } + else + { + // Non-MauiView platform view (rare for ILayout): fall back to one-shot snapshot. + var synthesizedLabel = SynthesizeAccessibilityLabelFromChildren(layout); + platformView.AccessibilityLabel = !string.IsNullOrWhiteSpace(synthesizedLabel) + ? synthesizedLabel + : null; + } + + platformView.AccessibilityHint = semantics.Hint; + + // Make the container the primary accessibility element so VoiceOver announces + // "[label], [hint]" as a single focus unit. + platformView.IsAccessibilityElement = true; + + // If a TapGestureRecognizer was wired up first, GesturePlatformManager may have + // already set ShouldGroupAccessibilityChildren=true. Clear it now: once the layout + // is a leaf accessibility element the grouping flag is redundant, and some iOS + // versions silently drop the parent's accessibilityLabel/hint when both are set. + platformView.ShouldGroupAccessibilityChildren = false; + + // Safe to early-return: an ILayout MAUI view never produces a UISearchBar/UIControl/ + // UIStepper/UIPageControl platform view, so the internal UpdateSemantics branches for + // those types are not applicable here. Heading trait is explicitly applied below. + UpdateSemanticsHeading(platformView, semantics); + return; + } + + // If a layout previously had Hint set (triggering the synthesis branch above which + // promotes the layout to an accessibility element) and the Hint/Description was later + // cleared, restore the layout to a non-leaf so VoiceOver can navigate into its children + // again. Layouts default to IsAccessibilityElement=false; the legacy path below only + // flips it to true and never back to false, so without this reset the layout would + // remain a silent leaf. + if (view is ILayout + && platformView is not UIControl + && string.IsNullOrWhiteSpace(semantics.Hint) + && string.IsNullOrWhiteSpace(semantics.Description) + && platformView.IsAccessibilityElement) + { + platformView.IsAccessibilityElement = false; + if (platformView is MauiView demotedMauiView) + { + demotedMauiView.SynthesizeAccessibilityLabelFromChildren = false; + } + } + + UpdateSemantics(platformView, semantics); + } internal static void UpdateSemantics(this UIView platformView, Semantics? semantics) { @@ -108,6 +206,11 @@ internal static void UpdateSemantics(this UIView platformView, Semantics? semant platformView.IsAccessibilityElement = true; } + UpdateSemanticsHeading(platformView, semantics); + } + + static void UpdateSemanticsHeading(UIView platformView, Semantics semantics) + { var accessibilityTraits = platformView.AccessibilityTraits; var hasHeader = (accessibilityTraits & UIAccessibilityTrait.Header) == UIAccessibilityTrait.Header; @@ -126,5 +229,72 @@ internal static void UpdateSemantics(this UIView platformView, Semantics? semant } } } + + /// + /// Maximum recursion depth for . Caps traversal of deeply + /// nested layouts so pathological hierarchies cannot stall accessibility updates. + /// + const int MaxChildTextRecursionDepth = 10; + + /// + /// Synthesizes an accessibility label by collecting text from all IText children in the layout. + /// Uses the MAUI virtual view tree (not platform subviews) to avoid timing issues. + /// + internal static string? SynthesizeAccessibilityLabelFromChildren(ILayout layout) + { + var sb = new StringBuilder(); + CollectChildrenText(layout, sb, depth: 0); + return sb.Length > 0 ? sb.ToString() : null; + } + + static void CollectChildrenText(ILayout layout, StringBuilder sb, int depth) + { + if (depth >= MaxChildTextRecursionDepth) + { + return; + } + + for (int i = 0; i < layout.Count; i++) + { + var child = layout[i]; + + // Skip non-visible children: once the parent is a leaf accessibility element, + // the platform only sees the synthesized string, so hidden text must be filtered here. + if (child.Visibility != Visibility.Visible) + { + continue; + } + + // Prefer explicit SemanticProperties.Description over raw text + if (child.Semantics?.Description is string childDesc && !string.IsNullOrWhiteSpace(childDesc)) + { + if (sb.Length > 0) + { + sb.Append(", "); + } + + sb.Append(childDesc); + } + else if (child is IText textElement + && child is not ITextInput + && !string.IsNullOrWhiteSpace(textElement.Text)) + { + // Skip ITextInput (Entry/Editor/SearchBar): their .Text is user input which + // would leak into the layout's accessibility label and never refresh as the + // user types. Entries/editors are independently focusable anyway. + if (sb.Length > 0) + { + sb.Append(", "); + } + + sb.Append(textElement.Text); + } + else if (child is ILayout childLayout) + { + // Recurse into nested layouts to collect text from their children + CollectChildrenText(childLayout, sb, depth + 1); + } + } + } } } \ No newline at end of file diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 46f10522b960..0ca481fc522c 100644 --- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -4,5 +4,8 @@ override Microsoft.Maui.Handlers.ShapeViewHandler.GetDesiredSize(double widthCon override Microsoft.Maui.Handlers.StepperHandler.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size override Microsoft.Maui.Platform.MauiTextView.TextAlignment.get -> UIKit.UITextAlignment override Microsoft.Maui.Platform.MauiTextView.TextAlignment.set -> void +override Microsoft.Maui.Platform.MauiView.AccessibilityActivate() -> bool +override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.get -> string? +override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.set -> void override Microsoft.Maui.Platform.MauiView.DidUpdateFocus(UIKit.UIFocusUpdateContext! context, UIKit.UIFocusAnimationCoordinator! coordinator) -> void static Microsoft.Maui.Handlers.DatePickerHandler.MapFlowDirection(Microsoft.Maui.Handlers.IDatePickerHandler! handler, Microsoft.Maui.IDatePicker! datePicker) -> void diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 46f10522b960..0ca481fc522c 100644 --- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -4,5 +4,8 @@ override Microsoft.Maui.Handlers.ShapeViewHandler.GetDesiredSize(double widthCon override Microsoft.Maui.Handlers.StepperHandler.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size override Microsoft.Maui.Platform.MauiTextView.TextAlignment.get -> UIKit.UITextAlignment override Microsoft.Maui.Platform.MauiTextView.TextAlignment.set -> void +override Microsoft.Maui.Platform.MauiView.AccessibilityActivate() -> bool +override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.get -> string? +override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.set -> void override Microsoft.Maui.Platform.MauiView.DidUpdateFocus(UIKit.UIFocusUpdateContext! context, UIKit.UIFocusAnimationCoordinator! coordinator) -> void static Microsoft.Maui.Handlers.DatePickerHandler.MapFlowDirection(Microsoft.Maui.Handlers.IDatePickerHandler! handler, Microsoft.Maui.IDatePicker! datePicker) -> void