Skip to content

Implement iOS VoiceOver support - #18016

Merged
jmacato merged 47 commits into
AvaloniaUI:mainfrom
IsaMorphic:feature/ios-voiceover
Aug 27, 2026
Merged

jmacato merged 47 commits into
AvaloniaUI:mainfrom
IsaMorphic:feature/ios-voiceover

Conversation

@IsaMorphic

@IsaMorphic IsaMorphic commented Jan 21, 2025 •

Copy link
Copy Markdown
Contributor

What does the pull request do?

This is a twin PR to #17704 that implements support for VoiceOver on iOS devices. Importantly, the UIAccessibility & UIAccessibilityContainer informal protocols are leveraged to create cohesion between Avalonia's automation system and that of the iOS ecosystem. Fixes #20125.

What is the current behavior?

Currently, Avalonia apps targeting the iOS platform do not expose user controls to the operating system's accessibility pipeline. This makes it impossible for blind and visually impaired users to use those apps with a screen reader or braille display.

What is the updated/expected behavior with this PR?

This PR enables users reliant on VoiceOver features to properly navigate Avalonia's view heirarchy using the appropriate, well-known shortcuts and gestures.

How was the solution implemented (if it's not obvious)?

The PR implements support for VoiceOver via Avalonia's AutomationPeer API by wrapping those instances in a subclass of UIAccessibilityElement that implements all the necessary formal and informal protocols to create a fully realized experience for low vision users.

Checklist

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054393-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054403-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054407-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054449-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054459-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054504-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@IsaMorphic
IsaMorphic marked this pull request as ready for review January 23, 2025 17:33
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054514-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054524-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054546-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054548-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054550-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054554-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054556-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054558-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054706-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054801-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054816-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054856-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@IsaMorphic

Copy link
Copy Markdown
Contributor Author

I don't think my code caused those tests to fail... anyone know if there's a known CI issue that could have caused it?

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054884-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 11.3.999-cibuild0054946-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@maxkatz6

maxkatz6 commented Feb 16, 2025 •

Copy link
Copy Markdown
Member

I finally tested this branch on a simulator using Accessibility Inspector and have several questions:

  1. Voiceover part works great.
  2. Actions like "Activate" don't seem to work (for example, on Repeat Button). accessibilityActivate is not invoked as I tried to debug it. Other actions, like scroll on focus does work.
  3. For some elements, boundaries seem to be off. But for the majority of other elements (like simple buttons), it's correct.
  4. ControlCatalog has TabControl-based navigation, where only a single page is actually part of the visual tree. But it seems once tab was opened, it never gets removed from the voice-over tree. For example, if I open Buttons page and then go to AutoCompleteBox page, Accessibility Inspector still goes through buttons from the previous page.
Screenshot 2025-02-16 at 2 23 57 AM

A little rant: it's frustrating how sometimes difficult it is to get mobile workloads working after XCode update, and almost every time it's easier with vscode than rider.

MathUtilities.IsZero(bounds.Clip.Width) ||
MathUtilities.IsZero(bounds.Clip.Height),
_ => !Owner.IsVisible,
_ => !Owner.IsEffectivelyVisible,

@maxkatz6 maxkatz6 Feb 16, 2025 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

cc @grokys, is it fine to switch to Effectively Visible? Or do other backends handle inherited visibility on their own?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Other backends handle this only in-so-far as they handle the hit testing in Avalonia code. This is in contrast to iOS which is the only target platform that performs the hit testing on the OS side. Therefore, I argue that this adaptation is necessary.

Comment on lines +56 to +71
foreach (AutomationPeer child in peer.GetChildren())
{
AutomationPeerWrapper? wrapper;
if (!_childrenMap.TryGetValue(child, out wrapper) &&
(child.GetName().Length > 0 || child.IsKeyboardFocusable()))
{
_childrenList.Add(child);
_childrenMap.Add(child, new(this, child));
}

wrapper?.UpdatePropertiesIfValid();
wrapper?.UpdateTraits();

UpdateChildren(child);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am not familiar with iOS accessibility, but shouldn't this backend map avalonia automation onto accessibilityContainer?

In other words, it should have a tree of peer wrappers, implementing IUIAccessibilityContainer, on wrapping children only on request. Instead of processing every element in the app into a single dictionary, updating it on each accessibility request. Otherwise, it needs to be carefully tested for performance and memory leaks, and potentially that problem with TabControl I mentioned above. At the moment, cleanup is only happening on GetAccessibilityElementAt request for a specific element, and if iOS doesn't call this method, no element is cleared, holding it in the memory.

By implementing IUIAccessibilityContainer on every node it would make it possible to properly handle accessibilityContainerType.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The issue I encountered trying to implement what you're describing is that iOS then doesn't traverse the tree when using touch exploration. In other words, it ends up just settling on the root element because, like you are saying, it doesn't traverse deeper due to the OS not requesting that traversal in the first place.

Unless I were to do some kind of implicit aggregation in the accessibilityContainer methods that does this traversal for the OS (which would again bring up the performance concerns), this is the only way I was able to get touch exploration to (mostly) work.

All this said, I can definitely double check the cleanup code to make sure that we don't get the weird TabControl behavior you're describing.

@IsaMorphic

IsaMorphic commented Feb 17, 2025 •

Copy link
Copy Markdown
Contributor Author

Thanks @maxkatz6 for the in-depth code review. Below are responses to your comments in numerical order:

  1. Thanks! :D
  2. I noticed this too but wasn't sure if it was just an issue on my end. I'll make sure that those overrides work on the native side of things.
    Edit: I've confirmed that this is just a bug with the iOS simulator and that the OS properly delegates this event on a physical iOS device.
  3. See 4.
  4. I've observed this behavior only occurs up until the OS requests a refresh of the accessibility tree. For example, if you navigate via touch exploration directly after the page switch, the outdated elements disappear as they should. So, I'll make sure to notify the OS of these view changes as soon as they occur.
    Edit: the refresh event never arrives on the Avalonia side 🤔 opened TabControl does not call AutomationPeer.RaiseChildrenChangedEvent #18227
    Edit 2: after some more digging it turns out that this wasn't the root issue. Instead it was regarding an implementation issue with IsEffectivelyVisible where orphaned trees would assume they are still visible (which doesn't make much sense in the grand scheme of things)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds iOS VoiceOver integration by bridging Avalonia automation peers to UIKit accessibility APIs.

Changes:

  • Adds iOS accessibility containers, traits, actions, and provider mappings.
  • Improves automation naming and effective visibility handling.
  • Updates visibility tests and documentation.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/Avalonia.Base.UnitTests/VisualTests.cs Updates detached-visibility expectations.
src/iOS/Avalonia.iOS/AvaloniaView.cs Initializes the accessibility bridge.
src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs Exposes UIKit container callbacks.
src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs Implements VoiceOver peer wrapping and actions.
src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs Uses effective visibility for off-screen state.
src/Avalonia.Controls/Automation/Peers/ContentControlAutomationPeer.cs Propagates child names and help text.
src/Avalonia.Controls/Automation/IsOffscreenBehavior.cs Updates visibility documentation.
src/Avalonia.Base/Visual.cs Marks detached visuals effectively invisible.
Suppressed comments (2)

src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs:210

  • AccessibilityFrame requires screen coordinates, but the iOS PointToScreen implementation currently returns the root-local point unchanged (AvaloniaView.cs:226). When AvaloniaView is embedded anywhere other than the screen origin, every VoiceOver focus rectangle and hit target is offset by the view's window position. Convert the root-local rectangle through the native view/window, or expose it via the appropriate container-space frame property.
            PixelRect screenRect = new PixelRect(
                root?.PointToScreen(bounds.TopLeft) ?? default,
                root?.PointToScreen(bounds.BottomRight) ?? default
                );
            CGRect nativeRect = new CGRect(
                screenRect.X, screenRect.Y,
                screenRect.Width, screenRect.Height

src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs:236

  • Toggle providers are actionable below, but their ToggleState is never represented in AccessibilityValue. A checkbox therefore has the same VoiceOver state before and after Toggle(), and ToggleStateProperty events are also ignored by the setter map. Expose the toggle value (including indeterminate state) in the iOS representation and map TogglePatternIdentifiers.ToggleStateProperty so it refreshes after changes.
        private static void UpdateValue(AutomationPeerWrapper self)
        {
            AutomationPeer peer = self;
            string? newValue =
                peer.GetProvider<IRangeValueProvider>()?.Value.ToString("0.##") ??
                peer.GetProvider<IValueProvider>()?.Value;

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Avalonia.Base/Visual.cs Resolved
Comment thread src/iOS/Avalonia.iOS/AvaloniaView.cs Resolved
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0068993-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@IsaMorphic

Copy link
Copy Markdown
Contributor Author

@jmacato All looks good, the changes you committed are functional on a real iOS device. I also just now addressed the comments by Copilot. Cheers!

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0069009-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@jmacato

jmacato commented Aug 27, 2026

Copy link
Copy Markdown
Member

Let's get this show on the road, merging in a bit! Just need to double check some stuff locally

@jmacato jmacato left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you very much for your work on this @IsaMorphic ! Avalonia's a11y story is gonna be more fantastic going forward!

@jmacato
jmacato enabled auto-merge August 27, 2026 17:20
@grokys

grokys commented Aug 27, 2026

Copy link
Copy Markdown
Member

Thanks so much for implementing this and keeping on top of it @IsaMorphic !

@IsaMorphic

Copy link
Copy Markdown
Contributor Author

You're very welcome! As a sibling of visually impaired people, it's important to me that they get to enjoy Avalonia apps, too :) consider hiring me, haha!

@jmacato
jmacato added this pull request to the merge queue Aug 27, 2026
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0069019-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@maxbrousseau

Copy link
Copy Markdown

@IsaMorphic Thank you for including support for AutomationId in your PR — I can confirm it works.

@NicholasLachapelle

NicholasLachapelle commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Out of curiosity, considering Android was backported to version 12.1.1, do you plan on doing the same for iOS or will it have to wait for a later version @jmacato ? Thank you again for this contribution @IsaMorphic 🙂

Merged via the queue into AvaloniaUI:main with commit 0442ba1 Aug 27, 2026
10 checks passed
@IsaMorphic
IsaMorphic deleted the feature/ios-voiceover branch August 27, 2026 18:46
@jmacato

jmacato commented Aug 27, 2026

Copy link
Copy Markdown
Member

@NicholasLachapelle It's @IsaMorphic that needs to be thanked for this effort :D But I'll have to consult with the others if this can be backported.

@IsaMorphic

Copy link
Copy Markdown
Contributor Author

You're all so kind! :D I'm happy to have this finally committed!

@LyleRolleman-plst

Copy link
Copy Markdown

As a dev working on applications that very much care about accessibility and a blind iOS user besides, this is great news!

Sooner we can get this the better :)

@IsaMorphic

Copy link
Copy Markdown
Contributor Author

But I'll have to consult with the others if this can be backported.

@jmacato If needed I can take point on adapting this patch for a backport. This is a bugfix, not a feature! :)

@MrJul MrJul added the backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch label Sep 1, 2026
MrJul pushed a commit to MrJul/Avalonia that referenced this pull request Sep 2, 2026
* Implement iOS VoiceOver support

* Some investigation (it is not working)

* Progress

* Functional prototype for iOS VoiceOver support

* Improved VoiceOver control function

* Implement traits and accessibility actions for VoiceOver

* Remove unused using statement

* Default IsOffscreenBehavior should be based on IsEffectivelyVisible

* Use most specific activation function first

* Ensure that offscreen UIAccessibilityElements are removed

* Fix interrupted speech upon focusing element

* Accessibility improvements based on tests with low vision users

* More improvements to iOS VoiceOver access

* Final fixes for iOS VoiceOver

* Add informative comments

* Implement review suggestions for iOS VoiceOver

* Fixed bugs in determining whether controls are offscreen

* Ensure that AutomationPeer children are updated

* Fix failing tests for IsEffectivelyVisible & CompositorHitTesting

* Implement AccessibilityContainer for all peer wrappers
Addresses performance concerns regarding the accessibility tree

* Fix iOS VoiceOver not giving touch feedback for some labeled elements

* More garbage collection for iOS VoiceOver

* Consolidate garbage collection code for iOS VoiceOver feature

* Final fixes for new iOS VoiceOver implementation

* Pass informative parameters to UIAccessibility notifications

* Fix crashing bug with iOS VoiceOver scroll gesture

* Fix: use `IInputRoot.RootElement` to compute screen coordinates

* Fix: use more precise logic for distinguishing groups from controls

* Fix: iOS VoiceOver recognizes all container elements

* Fix: recognize `SplitButton` as container type

* Fix: include custom controls under container category

* Fix: recognize commonly templated containers

* Fix: use `HashSet` for determining whether control type maps to a container

* Fix: optimizations for iOS VoiceOver

* Fix: update accessibility properties unconditionally

* Fix: use cached state for containers

* Fix: use basic `HashSet` type for container type lookup

* Fix: include `ScrollViewer` as group control type

* Fix: remove references to `ImmutableDictionary`

* Fix: VoiceOver should read page headers

* Fix: implement support for `AutomationId` for iOS automation tree

* Fix iOS accessibility peer updates

* Fix: include recommended fixes and changes from Copilot review

* Fix: revert formatting and modified tests

* Fix iOS accessibility trait and view disposal

---------

Co-authored-by: IsaMorphic <me@isamorphic.dev>
Co-authored-by: Jumar Macato <16554748+jmacato@users.noreply.github.com>
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] iOS screen reader is not supported