From 863e06c4694f8d7200e009eca3053938263be779 Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Tue, 21 Jan 2025 10:05:26 -0600 Subject: [PATCH 01/19] Implement iOS VoiceOver support --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 133 ++++++++++++++++++ .../Avalonia.iOS/AvaloniaView.Automation.cs | 19 +++ src/iOS/Avalonia.iOS/AvaloniaView.cs | 2 + 3 files changed, 154 insertions(+) create mode 100644 src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs create mode 100644 src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs new file mode 100644 index 00000000000..56ce21aa632 --- /dev/null +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Foundation; +using UIKit; + +namespace Avalonia.iOS +{ + public class AutomationPeerWrapper : UIAccessibilityElement, IUIAccessibilityContainer + { + private static readonly IReadOnlyDictionary> s_propertySetters = + new Dictionary>() + { + { AutomationElementIdentifiers.NameProperty, UpdateName }, + { AutomationElementIdentifiers.HelpTextProperty, UpdateHelpText }, + { AutomationElementIdentifiers.BoundingRectangleProperty, UpdateBoundingRectangle }, + }; + + private readonly AutomationPeer _peer; + + private List _childrenList; + private Dictionary _childrenMap; + + public new AvaloniaView AccessibilityContainer + { + get => (AvaloniaView)base.AccessibilityContainer!; + set => base.AccessibilityContainer = value; + } + + public UIAccessibilityContainerType AccessibilityContainerType { get; set; } + + public AutomationPeerWrapper(AvaloniaView container, AutomationPeer peer) : base(container) + { + AccessibilityContainer = container; + + _peer = peer; + _peer.ChildrenChanged += PeerChildrenChanged; + _peer.PropertyChanged += PeerPropertyChanged; + UpdateProperties(); + + _childrenList = new(); + _childrenMap = new(); + UpdateChildren(); + } + + public nint AccessibilityElementCount() => + _childrenList.Count; + + public NSObject? GetAccessibilityElementAt(nint index) + { + try + { + return _childrenMap[_childrenList[(int)index]]; + } + catch (KeyNotFoundException) { } + catch (ArgumentOutOfRangeException) { } + + return null; + } + + public nint GetIndexOfAccessibilityElement(NSObject element) + { + int indexOf = _childrenList.IndexOf(((AutomationPeerWrapper)element)._peer); + return indexOf < 0 ? NSRange.NotFound : indexOf; + } + + private void UpdateProperties() + { + foreach (AutomationProperty property in s_propertySetters.Keys) + { + s_propertySetters[property](this); + } + } + + private void UpdateProperties(params AutomationProperty[] properties) + { + foreach (AutomationProperty property in properties + .Where(s_propertySetters.ContainsKey)) + { + s_propertySetters[property](this); + } + } + + private void UpdateChildren() + { + foreach (AutomationPeer child in _peer.GetChildren()) + { + if (child.IsOffscreen()) + { + _childrenList.Remove(child); + _childrenMap.Remove(child); + } + else if (!_childrenMap.ContainsKey(child)) + { + _childrenList.Add(child); + _childrenMap.Add(child, new AutomationPeerWrapper(AccessibilityContainer, child)); + } + } + } + + private static void UpdateName(AutomationPeerWrapper self) + { + self.AccessibilityLabel = self._peer.GetName(); + } + + private static void UpdateHelpText(AutomationPeerWrapper self) + { + self.AccessibilityHint = self._peer.GetHelpText(); + } + + private static void UpdateBoundingRectangle(AutomationPeerWrapper self) + { + Rect bounds = self._peer.GetBoundingRectangle(); + PixelRect screenRect = new PixelRect( + self.AccessibilityContainer.TopLevel.PointToScreen(bounds.TopLeft), + self.AccessibilityContainer.TopLevel.PointToScreen(bounds.BottomRight) + ); + self.AccessibilityFrame = new CoreGraphics.CGRect( + screenRect.X, screenRect.Y, + screenRect.Width, screenRect.Height + ); + } + + private void PeerChildrenChanged(object? sender, EventArgs e) => UpdateChildren(); + + private void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) + { + UpdateProperties(e.Property); + } + } +} diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs new file mode 100644 index 00000000000..399c70db70f --- /dev/null +++ b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs @@ -0,0 +1,19 @@ +using Foundation; +using UIKit; + +namespace Avalonia.iOS +{ + public partial class AvaloniaView : IUIAccessibilityContainer + { + private readonly AutomationPeerWrapper _accessWrapper; + + public nint AccessibilityElementCount() => + _accessWrapper.AccessibilityElementCount(); + + public NSObject? GetAccessibilityElementAt(nint index) => + _accessWrapper.GetAccessibilityElementAt(index); + + public nint GetIndexOfAccessibilityElement(NSObject element) => + _accessWrapper.GetIndexOfAccessibilityElement(element); + } +} diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index 4248987d343..19cff01da47 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Runtime.Versioning; +using Avalonia.Automation.Peers; using Avalonia.Controls; using Avalonia.Controls.Embedding; using Avalonia.Controls.Platform; @@ -45,6 +46,7 @@ public AvaloniaView() _topLevelImpl = new TopLevelImpl(this); _input = new InputHandler(this, _topLevelImpl); _topLevel = new EmbeddableControlRoot(_topLevelImpl); + _accessWrapper = new(this, ControlAutomationPeer.CreatePeerForElement(_topLevel)); _topLevel.Prepare(); From e632ea7ce5043329e0a0c97db272a6f1b5f64723 Mon Sep 17 00:00:00 2001 From: IsaMorphic Date: Tue, 21 Jan 2025 15:13:20 -0600 Subject: [PATCH 02/19] Some investigation (it is not working) --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 12 +++++++----- src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs | 10 ++++++++++ src/iOS/Avalonia.iOS/AvaloniaView.cs | 3 +++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index 56ce21aa632..63bd96d5f3b 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -28,12 +28,14 @@ public class AutomationPeerWrapper : UIAccessibilityElement, IUIAccessibilityCon get => (AvaloniaView)base.AccessibilityContainer!; set => base.AccessibilityContainer = value; } - + + [Export("accessibilityContainerType")] public UIAccessibilityContainerType AccessibilityContainerType { get; set; } public AutomationPeerWrapper(AvaloniaView container, AutomationPeer peer) : base(container) { AccessibilityContainer = container; + IsAccessibilityElement = true; _peer = peer; _peer.ChildrenChanged += PeerChildrenChanged; @@ -45,9 +47,11 @@ public AutomationPeerWrapper(AvaloniaView container, AutomationPeer peer) : base UpdateChildren(); } + [Export("accessibilityElementCount")] public nint AccessibilityElementCount() => _childrenList.Count; + [Export("accessibilityElementAtIndex:")] public NSObject? GetAccessibilityElementAt(nint index) { try @@ -60,6 +64,7 @@ public nint AccessibilityElementCount() => return null; } + [Export("indexOfAccessibilityElement:")] public nint GetIndexOfAccessibilityElement(NSObject element) { int indexOf = _childrenList.IndexOf(((AutomationPeerWrapper)element)._peer); @@ -125,9 +130,6 @@ private static void UpdateBoundingRectangle(AutomationPeerWrapper self) private void PeerChildrenChanged(object? sender, EventArgs e) => UpdateChildren(); - private void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) - { - UpdateProperties(e.Property); - } + private void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) => UpdateProperties(e.Property); } } diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs index 399c70db70f..63408bf4802 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs @@ -6,13 +6,23 @@ namespace Avalonia.iOS public partial class AvaloniaView : IUIAccessibilityContainer { private readonly AutomationPeerWrapper _accessWrapper; + + [Export("accessibilityContainerType")] + public UIAccessibilityContainerType AccessibilityContainerType + { + get => _accessWrapper.AccessibilityContainerType; + set => _accessWrapper.AccessibilityContainerType = value; + } + [Export("accessibilityElementCount")] public nint AccessibilityElementCount() => _accessWrapper.AccessibilityElementCount(); + [Export("accessibilityElementAtIndex:")] public NSObject? GetAccessibilityElementAt(nint index) => _accessWrapper.GetAccessibilityElementAt(index); + [Export("indexOfAccessibilityElement:")] public nint GetIndexOfAccessibilityElement(NSObject element) => _accessWrapper.GetIndexOfAccessibilityElement(element); } diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index 19cff01da47..1bc4a6bf771 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -80,6 +80,9 @@ public AvaloniaView() MultipleTouchEnabled = true; #endif } + + IsAccessibilityElement = true; + AccessibilityContainerType = UIAccessibilityContainerType.SemanticGroup; } [SuppressMessage("Interoperability", "CA1422:Validate platform compatibility")] From 87376b386fa7db2e68cbd1b78a4ae4285592cd86 Mon Sep 17 00:00:00 2001 From: IsaMorphic Date: Tue, 21 Jan 2025 16:12:17 -0600 Subject: [PATCH 03/19] Progress --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 11 ++++++++++- src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index 63bd96d5f3b..2263fb3978e 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -32,6 +32,9 @@ public class AutomationPeerWrapper : UIAccessibilityElement, IUIAccessibilityCon [Export("accessibilityContainerType")] public UIAccessibilityContainerType AccessibilityContainerType { get; set; } + [Export("accessibilityElements")] + public NSObject? AccessibilityElements { get; set; } + public AutomationPeerWrapper(AvaloniaView container, AutomationPeer peer) : base(container) { AccessibilityContainer = container; @@ -90,6 +93,7 @@ private void UpdateProperties(params AutomationProperty[] properties) private void UpdateChildren() { + List wrappers = new(); foreach (AutomationPeer child in _peer.GetChildren()) { if (child.IsOffscreen()) @@ -99,10 +103,15 @@ private void UpdateChildren() } else if (!_childrenMap.ContainsKey(child)) { + var wrapper = new AutomationPeerWrapper(AccessibilityContainer, child); + wrappers.Add(wrapper); + _childrenList.Add(child); - _childrenMap.Add(child, new AutomationPeerWrapper(AccessibilityContainer, child)); + _childrenMap.Add(child, wrapper); } } + + AccessibilityElements = NSArray.FromObjects(wrappers.ToArray()); } private static void UpdateName(AutomationPeerWrapper self) diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs index 63408bf4802..1ce13fccd13 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs @@ -14,6 +14,13 @@ public UIAccessibilityContainerType AccessibilityContainerType set => _accessWrapper.AccessibilityContainerType = value; } + [Export("accessibilityElements")] + public NSObject? AccessibilityElements + { + get => _accessWrapper.AccessibilityElements; + set => _accessWrapper.AccessibilityElements = value; + } + [Export("accessibilityElementCount")] public nint AccessibilityElementCount() => _accessWrapper.AccessibilityElementCount(); From 230a542c9dfbcdd4d0005094e4ddea162b18b622 Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Wed, 22 Jan 2025 12:23:30 -0600 Subject: [PATCH 04/19] Functional prototype for iOS VoiceOver support --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 97 ++++--------------- .../Avalonia.iOS/AvaloniaView.Automation.cs | 61 ++++++++---- src/iOS/Avalonia.iOS/AvaloniaView.cs | 9 +- 3 files changed, 66 insertions(+), 101 deletions(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index 2263fb3978e..a2e79730a84 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -8,7 +8,7 @@ namespace Avalonia.iOS { - public class AutomationPeerWrapper : UIAccessibilityElement, IUIAccessibilityContainer + public class AutomationPeerWrapper : UIAccessibilityElement { private static readonly IReadOnlyDictionary> s_propertySetters = new Dictionary>() @@ -18,72 +18,32 @@ public class AutomationPeerWrapper : UIAccessibilityElement, IUIAccessibilityCon { AutomationElementIdentifiers.BoundingRectangleProperty, UpdateBoundingRectangle }, }; + private readonly AvaloniaView _view; private readonly AutomationPeer _peer; - private List _childrenList; - private Dictionary _childrenMap; - - public new AvaloniaView AccessibilityContainer - { - get => (AvaloniaView)base.AccessibilityContainer!; - set => base.AccessibilityContainer = value; - } - - [Export("accessibilityContainerType")] - public UIAccessibilityContainerType AccessibilityContainerType { get; set; } - - [Export("accessibilityElements")] - public NSObject? AccessibilityElements { get; set; } - - public AutomationPeerWrapper(AvaloniaView container, AutomationPeer peer) : base(container) + public AutomationPeerWrapper(AvaloniaView view, AutomationPeer? peer = null) : base(view) { - AccessibilityContainer = container; - IsAccessibilityElement = true; + _view = view; + _peer = peer ?? ControlAutomationPeer.CreatePeerForElement(view.TopLevel); - _peer = peer; - _peer.ChildrenChanged += PeerChildrenChanged; _peer.PropertyChanged += PeerPropertyChanged; UpdateProperties(); - _childrenList = new(); - _childrenMap = new(); - UpdateChildren(); - } - - [Export("accessibilityElementCount")] - public nint AccessibilityElementCount() => - _childrenList.Count; - - [Export("accessibilityElementAtIndex:")] - public NSObject? GetAccessibilityElementAt(nint index) - { - try - { - return _childrenMap[_childrenList[(int)index]]; - } - catch (KeyNotFoundException) { } - catch (ArgumentOutOfRangeException) { } + _peer.ChildrenChanged += PeerChildrenChanged; + _view.UpdateChildren(_peer); - return null; - } - - [Export("indexOfAccessibilityElement:")] - public nint GetIndexOfAccessibilityElement(NSObject element) - { - int indexOf = _childrenList.IndexOf(((AutomationPeerWrapper)element)._peer); - return indexOf < 0 ? NSRange.NotFound : indexOf; + AccessibilityContainer = _view; + AccessibilityIdentifier = _peer.GetAutomationId(); } private void UpdateProperties() { - foreach (AutomationProperty property in s_propertySetters.Keys) - { - s_propertySetters[property](this); - } + UpdateProperties(s_propertySetters.Keys.ToArray()); } private void UpdateProperties(params AutomationProperty[] properties) { + IsAccessibilityElement = _peer.IsContentElement() && !_peer.IsOffscreen(); foreach (AutomationProperty property in properties .Where(s_propertySetters.ContainsKey)) { @@ -91,29 +51,6 @@ private void UpdateProperties(params AutomationProperty[] properties) } } - private void UpdateChildren() - { - List wrappers = new(); - foreach (AutomationPeer child in _peer.GetChildren()) - { - if (child.IsOffscreen()) - { - _childrenList.Remove(child); - _childrenMap.Remove(child); - } - else if (!_childrenMap.ContainsKey(child)) - { - var wrapper = new AutomationPeerWrapper(AccessibilityContainer, child); - wrappers.Add(wrapper); - - _childrenList.Add(child); - _childrenMap.Add(child, wrapper); - } - } - - AccessibilityElements = NSArray.FromObjects(wrappers.ToArray()); - } - private static void UpdateName(AutomationPeerWrapper self) { self.AccessibilityLabel = self._peer.GetName(); @@ -128,17 +65,23 @@ private static void UpdateBoundingRectangle(AutomationPeerWrapper self) { Rect bounds = self._peer.GetBoundingRectangle(); PixelRect screenRect = new PixelRect( - self.AccessibilityContainer.TopLevel.PointToScreen(bounds.TopLeft), - self.AccessibilityContainer.TopLevel.PointToScreen(bounds.BottomRight) + self._view.TopLevel.PointToScreen(bounds.TopLeft), + self._view.TopLevel.PointToScreen(bounds.BottomRight) ); self.AccessibilityFrame = new CoreGraphics.CGRect( screenRect.X, screenRect.Y, screenRect.Width, screenRect.Height ); + UIAccessibility.PostNotification(UIAccessibilityPostNotification.LayoutChanged, null); } - private void PeerChildrenChanged(object? sender, EventArgs e) => UpdateChildren(); + private void PeerChildrenChanged(object? sender, EventArgs e) => _view.UpdateChildren(_peer); private void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) => UpdateProperties(e.Property); + + public static implicit operator AutomationPeer(AutomationPeerWrapper instance) + { + return instance._peer; + } } } diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs index 1ce13fccd13..44300a81210 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs @@ -1,4 +1,7 @@ -using Foundation; +using System; +using System.Collections.Generic; +using Avalonia.Automation.Peers; +using Foundation; using UIKit; namespace Avalonia.iOS @@ -6,31 +9,49 @@ namespace Avalonia.iOS public partial class AvaloniaView : IUIAccessibilityContainer { private readonly AutomationPeerWrapper _accessWrapper; - - [Export("accessibilityContainerType")] - public UIAccessibilityContainerType AccessibilityContainerType - { - get => _accessWrapper.AccessibilityContainerType; - set => _accessWrapper.AccessibilityContainerType = value; - } - [Export("accessibilityElements")] - public NSObject? AccessibilityElements - { - get => _accessWrapper.AccessibilityElements; - set => _accessWrapper.AccessibilityElements = value; - } + private readonly List _childrenList; + private readonly Dictionary _childrenMap; + + [Export("accessibilityContainerType")] + public UIAccessibilityContainerType AccessibilityContainerType { get; set; } [Export("accessibilityElementCount")] - public nint AccessibilityElementCount() => - _accessWrapper.AccessibilityElementCount(); + public nint AccessibilityElementCount() => + _childrenList.Count; [Export("accessibilityElementAtIndex:")] - public NSObject? GetAccessibilityElementAt(nint index) => - _accessWrapper.GetAccessibilityElementAt(index); + public NSObject? GetAccessibilityElementAt(nint index) + { + try + { + return _childrenMap[_childrenList[(int)index]]; + } + catch (KeyNotFoundException) { } + catch (ArgumentOutOfRangeException) { } + + return null; + } [Export("indexOfAccessibilityElement:")] - public nint GetIndexOfAccessibilityElement(NSObject element) => - _accessWrapper.GetIndexOfAccessibilityElement(element); + public nint GetIndexOfAccessibilityElement(NSObject element) + { + int indexOf = _childrenList.IndexOf((AutomationPeerWrapper)element); + return indexOf < 0 ? NSRange.NotFound : indexOf; + } + + internal void UpdateChildren(AutomationPeer peer) + { + foreach (AutomationPeer child in peer.GetChildren()) + { + if (!_childrenMap.ContainsKey(child)) + { + AutomationPeerWrapper wrapper = new (this, child); + + _childrenList.Add(child); + _childrenMap.Add(child, wrapper); + } + } + } } } diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index 1bc4a6bf771..47636e17cf1 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -46,7 +46,11 @@ public AvaloniaView() _topLevelImpl = new TopLevelImpl(this); _input = new InputHandler(this, _topLevelImpl); _topLevel = new EmbeddableControlRoot(_topLevelImpl); - _accessWrapper = new(this, ControlAutomationPeer.CreatePeerForElement(_topLevel)); + + _childrenList = new(); + _childrenMap = new(); + _accessWrapper = new(this); + AccessibilityContainerType = UIAccessibilityContainerType.SemanticGroup; _topLevel.Prepare(); @@ -80,9 +84,6 @@ public AvaloniaView() MultipleTouchEnabled = true; #endif } - - IsAccessibilityElement = true; - AccessibilityContainerType = UIAccessibilityContainerType.SemanticGroup; } [SuppressMessage("Interoperability", "CA1422:Validate platform compatibility")] From ddff15ae50a30a80ac36742ea803a08a2c6ef543 Mon Sep 17 00:00:00 2001 From: IsaMorphic Date: Wed, 22 Jan 2025 15:33:09 -0600 Subject: [PATCH 05/19] Improved VoiceOver control function --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 11 ++++----- .../Avalonia.iOS/AvaloniaView.Automation.cs | 24 ++++++++++++++----- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index a2e79730a84..784f80eac7d 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -27,23 +27,21 @@ public AutomationPeerWrapper(AvaloniaView view, AutomationPeer? peer = null) : b _peer = peer ?? ControlAutomationPeer.CreatePeerForElement(view.TopLevel); _peer.PropertyChanged += PeerPropertyChanged; - UpdateProperties(); - _peer.ChildrenChanged += PeerChildrenChanged; - _view.UpdateChildren(_peer); AccessibilityContainer = _view; AccessibilityIdentifier = _peer.GetAutomationId(); } - private void UpdateProperties() + public void UpdateProperties() { UpdateProperties(s_propertySetters.Keys.ToArray()); } - private void UpdateProperties(params AutomationProperty[] properties) + public void UpdateProperties(params AutomationProperty[] properties) { - IsAccessibilityElement = _peer.IsContentElement() && !_peer.IsOffscreen(); + AccessibilityRespondsToUserInteraction = _peer.IsEnabled(); + foreach (AutomationProperty property in properties .Where(s_propertySetters.ContainsKey)) { @@ -72,7 +70,6 @@ private static void UpdateBoundingRectangle(AutomationPeerWrapper self) screenRect.X, screenRect.Y, screenRect.Width, screenRect.Height ); - UIAccessibility.PostNotification(UIAccessibilityPostNotification.LayoutChanged, null); } private void PeerChildrenChanged(object? sender, EventArgs e) => _view.UpdateChildren(_peer); diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs index 44300a81210..e821e633560 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs @@ -17,8 +17,11 @@ public partial class AvaloniaView : IUIAccessibilityContainer public UIAccessibilityContainerType AccessibilityContainerType { get; set; } [Export("accessibilityElementCount")] - public nint AccessibilityElementCount() => - _childrenList.Count; + public nint AccessibilityElementCount() + { + UpdateChildren(_accessWrapper); + return _childrenList.Count; + } [Export("accessibilityElementAtIndex:")] public NSObject? GetAccessibilityElementAt(nint index) @@ -44,13 +47,22 @@ internal void UpdateChildren(AutomationPeer peer) { foreach (AutomationPeer child in peer.GetChildren()) { - if (!_childrenMap.ContainsKey(child)) + if (child.GetName().Length == 0 || + child.IsOffscreen()) + { + _childrenList.Remove(child); + _childrenMap.Remove(child); + } + else if (!_childrenMap.TryGetValue(child, out AutomationPeerWrapper? wrapper)) { - AutomationPeerWrapper wrapper = new (this, child); - _childrenList.Add(child); - _childrenMap.Add(child, wrapper); + _childrenMap.Add(child, new(this, child)); + } + else + { + wrapper.UpdateProperties(); } + UpdateChildren(child); } } } From bf9933d82e567e5c3c622a7e16b680958d54ac0b Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Thu, 23 Jan 2025 10:36:17 -0600 Subject: [PATCH 06/19] Implement traits and accessibility actions for VoiceOver --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 229 ++++++++++++++++-- .../Avalonia.iOS/AvaloniaView.Automation.cs | 15 +- src/iOS/Avalonia.iOS/AvaloniaView.cs | 8 +- 3 files changed, 217 insertions(+), 35 deletions(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index 784f80eac7d..01b9390223c 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -3,12 +3,14 @@ using System.Linq; using Avalonia.Automation; using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using CoreGraphics; using Foundation; using UIKit; namespace Avalonia.iOS { - public class AutomationPeerWrapper : UIAccessibilityElement + internal class AutomationPeerWrapper : UIAccessibilityElement { private static readonly IReadOnlyDictionary> s_propertySetters = new Dictionary>() @@ -16,6 +18,12 @@ public class AutomationPeerWrapper : UIAccessibilityElement { AutomationElementIdentifiers.NameProperty, UpdateName }, { AutomationElementIdentifiers.HelpTextProperty, UpdateHelpText }, { AutomationElementIdentifiers.BoundingRectangleProperty, UpdateBoundingRectangle }, + + { RangeValuePatternIdentifiers.IsReadOnlyProperty, UpdateIsReadOnly }, + { RangeValuePatternIdentifiers.ValueProperty, UpdateValue }, + + { ValuePatternIdentifiers.IsReadOnlyProperty, UpdateIsReadOnly }, + { ValuePatternIdentifiers.ValueProperty, UpdateValue }, }; private readonly AvaloniaView _view; @@ -33,50 +41,225 @@ public AutomationPeerWrapper(AvaloniaView view, AutomationPeer? peer = null) : b AccessibilityIdentifier = _peer.GetAutomationId(); } - public void UpdateProperties() - { - UpdateProperties(s_propertySetters.Keys.ToArray()); - } - - public void UpdateProperties(params AutomationProperty[] properties) - { - AccessibilityRespondsToUserInteraction = _peer.IsEnabled(); - - foreach (AutomationProperty property in properties - .Where(s_propertySetters.ContainsKey)) - { - s_propertySetters[property](this); - } - } - private static void UpdateName(AutomationPeerWrapper self) { - self.AccessibilityLabel = self._peer.GetName(); + AutomationPeer peer = self; + self.AccessibilityLabel = peer.GetName(); } private static void UpdateHelpText(AutomationPeerWrapper self) { - self.AccessibilityHint = self._peer.GetHelpText(); + AutomationPeer peer = self; + self.AccessibilityHint = peer.GetHelpText(); } private static void UpdateBoundingRectangle(AutomationPeerWrapper self) { - Rect bounds = self._peer.GetBoundingRectangle(); + AutomationPeer peer = self; + Rect bounds = peer.GetBoundingRectangle(); PixelRect screenRect = new PixelRect( self._view.TopLevel.PointToScreen(bounds.TopLeft), self._view.TopLevel.PointToScreen(bounds.BottomRight) ); - self.AccessibilityFrame = new CoreGraphics.CGRect( + self.AccessibilityFrame = new CGRect( screenRect.X, screenRect.Y, screenRect.Width, screenRect.Height ); } - private void PeerChildrenChanged(object? sender, EventArgs e) => _view.UpdateChildren(_peer); + private static void UpdateIsReadOnly(AutomationPeerWrapper self) + { + AutomationPeer peer = self; + self.AccessibilityRespondsToUserInteraction = + peer.GetProvider()?.IsReadOnly ?? + peer.GetProvider()?.IsReadOnly ?? + peer.IsEnabled(); + } + + private static void UpdateValue(AutomationPeerWrapper self) + { + AutomationPeer peer = self; + self.AccessibilityValue = + peer.GetProvider()?.Value.ToString("0.##") ?? + peer.GetProvider()?.Value; + UIAccessibility.PostNotification(UIAccessibilityPostNotification.Announcement, self); + } + + private void PeerChildrenChanged(object? sender, EventArgs e) + { + _view.UpdateChildren(_peer); + UIAccessibility.PostNotification(UIAccessibilityPostNotification.ScreenChanged, this); + } private void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) => UpdateProperties(e.Property); - public static implicit operator AutomationPeer(AutomationPeerWrapper instance) + private void UpdateProperties(params AutomationProperty[] properties) + { + Action? setter = + Delegate.Combine(properties + .Where(s_propertySetters.ContainsKey) + .Select(x => s_propertySetters[x]) + .Distinct() + .ToArray()) as Action; + setter?.Invoke(this); + } + + public void UpdateProperties() + { + bool canFocusAtAll = _peer.IsContentElement() && !_peer.IsOffscreen(); + IsAccessibilityElement = canFocusAtAll; + AccessibilityRespondsToUserInteraction = + canFocusAtAll && _peer.IsKeyboardFocusable(); + + UpdateProperties(s_propertySetters.Keys.ToArray()); + } + + public void UpdateTraits() + { + UIAccessibilityTrait traits = UIAccessibilityTrait.None; + + switch (_peer.GetAutomationControlType()) + { + case AutomationControlType.Button: + traits |= UIAccessibilityTrait.Button; + break; + case AutomationControlType.Header: + traits |= UIAccessibilityTrait.Header; + break; + case AutomationControlType.Hyperlink: + traits |= UIAccessibilityTrait.Link; + break; + case AutomationControlType.Image: + traits |= UIAccessibilityTrait.Image; + break; + } + + if (_peer.GetProvider()?.IsReadOnly == false) + { + traits |= UIAccessibilityTrait.Adjustable; + } + + if (_peer.GetProvider()?.IsSelected == true) + { + traits |= UIAccessibilityTrait.Selected; + } + + if (_peer.GetProvider()?.IsReadOnly == false) + { + traits |= UIAccessibilityTrait.UpdatesFrequently; + } + + if (_peer.IsEnabled() == false) + { + traits |= UIAccessibilityTrait.NotEnabled; + } + + AccessibilityTraits = (ulong)traits; + } + + [Export("accessibilityActivate")] + public bool AccessibilityActivate() + { + IInvokeProvider? invokeProvider = _peer.GetProvider(); + IToggleProvider? toggleProvider = _peer.GetProvider(); + if (invokeProvider is not null) + { + invokeProvider.Invoke(); + return true; + } + else if (toggleProvider is not null) + { + toggleProvider.Toggle(); + return true; + } + else + { + return false; + } + } + + public override bool AccessibilityElementIsFocused() + { + base.AccessibilityElementIsFocused(); + return _peer.HasKeyboardFocus(); + } + + public override void AccessibilityElementDidBecomeFocused() + { + base.AccessibilityElementDidBecomeFocused(); + _peer.SetFocus(); + } + + public override void AccessibilityDecrement() + { + base.AccessibilityDecrement(); + IRangeValueProvider? provider = _peer.GetProvider(); + if (provider is not null) + { + double value = provider.Value; + provider.SetValue(value - provider.SmallChange); + } + } + + public override void AccessibilityIncrement() + { + base.AccessibilityIncrement(); + IRangeValueProvider? provider = _peer.GetProvider(); + if (provider is not null) + { + double value = provider.Value; + provider.SetValue(value + provider.SmallChange); + } + } + + public override bool AccessibilityScroll(UIAccessibilityScrollDirection direction) + { + base.AccessibilityScroll(direction); + IScrollProvider? scrollProvider = _peer.GetProvider(); + if (scrollProvider is not null) + { + bool didScroll; + ScrollAmount verticalAmount, horizontalAmount; + switch (direction) + { + case UIAccessibilityScrollDirection.Up: + verticalAmount = ScrollAmount.SmallIncrement; + horizontalAmount = ScrollAmount.NoAmount; + didScroll = true; + break; + case UIAccessibilityScrollDirection.Down: + verticalAmount = ScrollAmount.SmallDecrement; + horizontalAmount = ScrollAmount.NoAmount; + didScroll = true; + break; + case UIAccessibilityScrollDirection.Left: + verticalAmount = ScrollAmount.NoAmount; + horizontalAmount = ScrollAmount.SmallIncrement; + didScroll = true; + break; + case UIAccessibilityScrollDirection.Right: + verticalAmount = ScrollAmount.NoAmount; + horizontalAmount = ScrollAmount.SmallDecrement; + didScroll = true; + break; + default: + verticalAmount = ScrollAmount.NoAmount; + horizontalAmount = ScrollAmount.NoAmount; + didScroll = false; + break; + } + + scrollProvider.Scroll(verticalAmount, horizontalAmount); + if (didScroll) + { + UIAccessibility.PostNotification(UIAccessibilityPostNotification.PageScrolled, this); + return true; + } + } + return false; + } + + public static implicit operator AutomationPeer(AutomationPeerWrapper instance) { return instance._peer; } diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs index e821e633560..a5f2af43143 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs @@ -7,14 +7,13 @@ namespace Avalonia.iOS { public partial class AvaloniaView : IUIAccessibilityContainer - { - private readonly AutomationPeerWrapper _accessWrapper; - - private readonly List _childrenList; - private readonly Dictionary _childrenMap; + { + private readonly List _childrenList = new(); + private readonly Dictionary _childrenMap = new(); [Export("accessibilityContainerType")] - public UIAccessibilityContainerType AccessibilityContainerType { get; set; } + public UIAccessibilityContainerType AccessibilityContainerType { get; set; } = + UIAccessibilityContainerType.SemanticGroup; [Export("accessibilityElementCount")] public nint AccessibilityElementCount() @@ -47,7 +46,8 @@ internal void UpdateChildren(AutomationPeer peer) { foreach (AutomationPeer child in peer.GetChildren()) { - if (child.GetName().Length == 0 || + if ((child.GetName().Length == 0 && + !child.IsKeyboardFocusable()) || child.IsOffscreen()) { _childrenList.Remove(child); @@ -61,6 +61,7 @@ internal void UpdateChildren(AutomationPeer peer) else { wrapper.UpdateProperties(); + wrapper.UpdateTraits(); } UpdateChildren(child); } diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index 47636e17cf1..5b7c0f2af18 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -32,9 +32,11 @@ internal IInputRoot InputRoot => _inputRoot ?? throw new InvalidOperationException($"{nameof(IWindowImpl.SetInputRoot)} must have been called"); internal TopLevel TopLevel => _topLevel; + private readonly AutomationPeerWrapper _accessWrapper; private readonly TopLevelImpl _topLevelImpl; private readonly EmbeddableControlRoot _topLevel; private readonly InputHandler _input; + private TextInputMethodClient? _client; private IAvaloniaViewController? _controller; private IInputRoot? _inputRoot; @@ -46,11 +48,7 @@ public AvaloniaView() _topLevelImpl = new TopLevelImpl(this); _input = new InputHandler(this, _topLevelImpl); _topLevel = new EmbeddableControlRoot(_topLevelImpl); - - _childrenList = new(); - _childrenMap = new(); - _accessWrapper = new(this); - AccessibilityContainerType = UIAccessibilityContainerType.SemanticGroup; + _accessWrapper = new AutomationPeerWrapper(this); _topLevel.Prepare(); From 928715873220d8bc5932be6f2f62a48ea7de71af Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Thu, 23 Jan 2025 12:02:27 -0600 Subject: [PATCH 07/19] Remove unused using statement --- src/iOS/Avalonia.iOS/AvaloniaView.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index 5b7c0f2af18..6673ca6c579 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Runtime.Versioning; -using Avalonia.Automation.Peers; using Avalonia.Controls; using Avalonia.Controls.Embedding; using Avalonia.Controls.Platform; From a3df775c75782369ac063e456b67141ef4b95204 Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Thu, 23 Jan 2025 13:32:09 -0600 Subject: [PATCH 08/19] Default IsOffscreenBehavior should be based on IsEffectivelyVisible --- src/Avalonia.Controls/Automation/IsOffscreenBehavior.cs | 2 +- src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/Automation/IsOffscreenBehavior.cs b/src/Avalonia.Controls/Automation/IsOffscreenBehavior.cs index 128c1e1dcc6..3c5fc77734a 100644 --- a/src/Avalonia.Controls/Automation/IsOffscreenBehavior.cs +++ b/src/Avalonia.Controls/Automation/IsOffscreenBehavior.cs @@ -6,7 +6,7 @@ namespace Avalonia.Automation public enum IsOffscreenBehavior { /// - /// The AutomationProperty IsOffscreen is calculated based on IsVisible. + /// The AutomationProperty IsOffscreen is calculated based on IsEffectivelyVisible. /// Default, /// diff --git a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs index 0dff2db3c2f..9a2aa560665 100644 --- a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs @@ -221,7 +221,7 @@ protected override bool IsOffscreenCore() IsOffscreenBehavior.FromClip => Owner.GetTransformedBounds() is not { } bounds || MathUtilities.IsZero(bounds.Clip.Width) || MathUtilities.IsZero(bounds.Clip.Height), - _ => !Owner.IsVisible, + _ => !Owner.IsEffectivelyVisible, }; } From 5aa082c79b7277766834fd134a882dabbc1f577e Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Fri, 24 Jan 2025 08:42:45 -0600 Subject: [PATCH 09/19] Use most specific activation function first --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index 01b9390223c..4530b77c16d 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -160,16 +160,16 @@ public void UpdateTraits() [Export("accessibilityActivate")] public bool AccessibilityActivate() { - IInvokeProvider? invokeProvider = _peer.GetProvider(); IToggleProvider? toggleProvider = _peer.GetProvider(); - if (invokeProvider is not null) + IInvokeProvider? invokeProvider = _peer.GetProvider(); + if (toggleProvider is not null) { - invokeProvider.Invoke(); + toggleProvider.Toggle(); return true; } - else if (toggleProvider is not null) + else if (invokeProvider is not null) { - toggleProvider.Toggle(); + invokeProvider.Invoke(); return true; } else From d62769716290460f95e96ab0e3bf70c3a49bb2ab Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Fri, 24 Jan 2025 12:41:02 -0600 Subject: [PATCH 10/19] Ensure that offscreen UIAccessibilityElements are removed --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 18 ++++++--- .../Avalonia.iOS/AvaloniaView.Automation.cs | 37 ++++++++++--------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index 4530b77c16d..4087d1d3bd2 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -79,8 +79,8 @@ private static void UpdateIsReadOnly(AutomationPeerWrapper self) private static void UpdateValue(AutomationPeerWrapper self) { AutomationPeer peer = self; - self.AccessibilityValue = - peer.GetProvider()?.Value.ToString("0.##") ?? + self.AccessibilityValue = + peer.GetProvider()?.Value.ToString("0.##") ?? peer.GetProvider()?.Value; UIAccessibility.PostNotification(UIAccessibilityPostNotification.Announcement, self); } @@ -104,14 +104,22 @@ private void UpdateProperties(params AutomationProperty[] properties) setter?.Invoke(this); } - public void UpdateProperties() + public bool UpdatePropertiesIfValid() { bool canFocusAtAll = _peer.IsContentElement() && !_peer.IsOffscreen(); IsAccessibilityElement = canFocusAtAll; AccessibilityRespondsToUserInteraction = canFocusAtAll && _peer.IsKeyboardFocusable(); - UpdateProperties(s_propertySetters.Keys.ToArray()); + if (canFocusAtAll) + { + UpdateProperties(s_propertySetters.Keys.ToArray()); + return true; + } + else + { + return false; + } } public void UpdateTraits() @@ -158,7 +166,7 @@ public void UpdateTraits() } [Export("accessibilityActivate")] - public bool AccessibilityActivate() + public bool AccessibilityActivate() { IToggleProvider? toggleProvider = _peer.GetProvider(); IInvokeProvider? invokeProvider = _peer.GetProvider(); diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs index a5f2af43143..4a1683db536 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs @@ -7,16 +7,16 @@ namespace Avalonia.iOS { public partial class AvaloniaView : IUIAccessibilityContainer - { + { private readonly List _childrenList = new(); private readonly Dictionary _childrenMap = new(); [Export("accessibilityContainerType")] - public UIAccessibilityContainerType AccessibilityContainerType { get; set; } = + public UIAccessibilityContainerType AccessibilityContainerType { get; set; } = UIAccessibilityContainerType.SemanticGroup; [Export("accessibilityElementCount")] - public nint AccessibilityElementCount() + public nint AccessibilityElementCount() { UpdateChildren(_accessWrapper); return _childrenList.Count; @@ -27,7 +27,16 @@ public nint AccessibilityElementCount() { try { - return _childrenMap[_childrenList[(int)index]]; + var wrapper = _childrenMap[_childrenList[(int)index]]; + if (wrapper.UpdatePropertiesIfValid()) + { + return wrapper; + } + else + { + _childrenList.Remove(wrapper); + _childrenMap.Remove(wrapper); + } } catch (KeyNotFoundException) { } catch (ArgumentOutOfRangeException) { } @@ -46,23 +55,17 @@ internal void UpdateChildren(AutomationPeer peer) { foreach (AutomationPeer child in peer.GetChildren()) { - if ((child.GetName().Length == 0 && - !child.IsKeyboardFocusable()) || - child.IsOffscreen()) - { - _childrenList.Remove(child); - _childrenMap.Remove(child); - } - else if (!_childrenMap.TryGetValue(child, out AutomationPeerWrapper? wrapper)) + AutomationPeerWrapper? wrapper; + if (!_childrenMap.TryGetValue(child, out wrapper) && + (child.GetName().Length > 0 || child.IsKeyboardFocusable())) { _childrenList.Add(child); _childrenMap.Add(child, new(this, child)); } - else - { - wrapper.UpdateProperties(); - wrapper.UpdateTraits(); - } + + wrapper?.UpdatePropertiesIfValid(); + wrapper?.UpdateTraits(); + UpdateChildren(child); } } From 1abc92b5ec020aaa0232eac4b8cad9f272f557d4 Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Fri, 24 Jan 2025 14:50:35 -0600 Subject: [PATCH 11/19] Fix interrupted speech upon focusing element --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index 4087d1d3bd2..5fe0557bef0 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -16,7 +16,7 @@ internal class AutomationPeerWrapper : UIAccessibilityElement new Dictionary>() { { AutomationElementIdentifiers.NameProperty, UpdateName }, - { AutomationElementIdentifiers.HelpTextProperty, UpdateHelpText }, + { AutomationElementIdentifiers.HelpTextProperty, UpdateValue }, { AutomationElementIdentifiers.BoundingRectangleProperty, UpdateBoundingRectangle }, { RangeValuePatternIdentifiers.IsReadOnlyProperty, UpdateIsReadOnly }, @@ -47,12 +47,6 @@ private static void UpdateName(AutomationPeerWrapper self) self.AccessibilityLabel = peer.GetName(); } - private static void UpdateHelpText(AutomationPeerWrapper self) - { - AutomationPeer peer = self; - self.AccessibilityHint = peer.GetHelpText(); - } - private static void UpdateBoundingRectangle(AutomationPeerWrapper self) { AutomationPeer peer = self; @@ -79,10 +73,15 @@ private static void UpdateIsReadOnly(AutomationPeerWrapper self) private static void UpdateValue(AutomationPeerWrapper self) { AutomationPeer peer = self; - self.AccessibilityValue = + string? newValue = peer.GetProvider()?.Value.ToString("0.##") ?? - peer.GetProvider()?.Value; - UIAccessibility.PostNotification(UIAccessibilityPostNotification.Announcement, self); + peer.GetProvider()?.Value ?? + peer.GetHelpText(); + if (self.AccessibilityValue != newValue) + { + self.AccessibilityValue = newValue; + UIAccessibility.PostNotification(UIAccessibilityPostNotification.Announcement, self); + } } private void PeerChildrenChanged(object? sender, EventArgs e) @@ -106,19 +105,14 @@ private void UpdateProperties(params AutomationProperty[] properties) public bool UpdatePropertiesIfValid() { - bool canFocusAtAll = _peer.IsContentElement() && !_peer.IsOffscreen(); - IsAccessibilityElement = canFocusAtAll; - AccessibilityRespondsToUserInteraction = - canFocusAtAll && _peer.IsKeyboardFocusable(); - - if (canFocusAtAll) + if (_peer.IsContentElement() && !_peer.IsOffscreen()) { UpdateProperties(s_propertySetters.Keys.ToArray()); - return true; + return IsAccessibilityElement = true; } else { - return false; + return IsAccessibilityElement = false; } } From 093cfbd33c60254c69d321570bf6bf37dcb09ad9 Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Sat, 25 Jan 2025 16:54:08 -0600 Subject: [PATCH 12/19] Accessibility improvements based on tests with low vision users --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index 5fe0557bef0..f7991481603 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -44,7 +44,15 @@ public AutomationPeerWrapper(AvaloniaView view, AutomationPeer? peer = null) : b private static void UpdateName(AutomationPeerWrapper self) { AutomationPeer peer = self; - self.AccessibilityLabel = peer.GetName(); + IReadOnlyList children = peer.GetChildren(); + if (peer.GetName().Length == 0 && children.Count == 1) + { + self.AccessibilityLabel = children[0].GetName(); + } + else + { + self.AccessibilityLabel = peer.GetName(); + } } private static void UpdateBoundingRectangle(AutomationPeerWrapper self) @@ -73,10 +81,21 @@ private static void UpdateIsReadOnly(AutomationPeerWrapper self) private static void UpdateValue(AutomationPeerWrapper self) { AutomationPeer peer = self; - string? newValue = + IReadOnlyList children = peer.GetChildren(); + + string helpText; + if (peer.GetHelpText().Length == 0 && children.Count == 1) + { + helpText = children[0].GetHelpText(); + } + else + { + helpText = peer.GetHelpText(); + } + + string newValue = peer.GetProvider()?.Value.ToString("0.##") ?? - peer.GetProvider()?.Value ?? - peer.GetHelpText(); + peer.GetProvider()?.Value ?? helpText; if (self.AccessibilityValue != newValue) { self.AccessibilityValue = newValue; @@ -189,7 +208,7 @@ public override bool AccessibilityElementIsFocused() public override void AccessibilityElementDidBecomeFocused() { base.AccessibilityElementDidBecomeFocused(); - _peer.SetFocus(); + _peer.BringIntoView(); } public override void AccessibilityDecrement() From 9b28c9c8aa1986444df259beee3ef5181085a4f3 Mon Sep 17 00:00:00 2001 From: IsaMorphic Date: Sun, 26 Jan 2025 11:16:44 -0600 Subject: [PATCH 13/19] More improvements to iOS VoiceOver access --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index f7991481603..cc08c004f28 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -103,11 +103,7 @@ private static void UpdateValue(AutomationPeerWrapper self) } } - private void PeerChildrenChanged(object? sender, EventArgs e) - { - _view.UpdateChildren(_peer); - UIAccessibility.PostNotification(UIAccessibilityPostNotification.ScreenChanged, this); - } + private void PeerChildrenChanged(object? sender, EventArgs e) => _view.UpdateChildren(_peer.GetVisualRoot()!); private void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) => UpdateProperties(e.Property); From ccdf4606c49288236a4486c032ceca8dfe1bf857 Mon Sep 17 00:00:00 2001 From: IsaMorphic Date: Sun, 26 Jan 2025 13:28:20 -0600 Subject: [PATCH 14/19] Final fixes for iOS VoiceOver --- .../Peers/ContentControlAutomationPeer.cs | 25 ++++++------- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 36 ++++++------------- 2 files changed, 24 insertions(+), 37 deletions(-) diff --git a/src/Avalonia.Controls/Automation/Peers/ContentControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ContentControlAutomationPeer.cs index df24222a0c5..40c6cd31344 100644 --- a/src/Avalonia.Controls/Automation/Peers/ContentControlAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ContentControlAutomationPeer.cs @@ -15,19 +15,20 @@ protected ContentControlAutomationPeer(ContentControl owner) protected override string? GetNameCore() { - var result = base.GetNameCore(); - - if (result is null && Owner.Presenter?.Child is TextBlock text) - { - result = text.Text; - } - - if (result is null) - { - result = Owner.Content?.ToString(); - } + Control? childControl = Owner.Presenter?.Child; + AutomationPeer? childPeer = childControl is null ? null : + CreatePeerForElement(childControl); + return base.GetNameCore() ?? (childControl as TextBlock)?.Text ?? + childPeer?.GetName() ?? Owner.Content?.ToString(); + } - return result; + protected override string? GetHelpTextCore() + { + Control? childControl = Owner.Presenter?.Child; + AutomationPeer? childPeer = childControl is null ? null : + CreatePeerForElement(childControl); + return base.GetHelpTextCore() ?? + childPeer?.GetHelpText(); } protected override bool IsContentElementCore() => false; diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index cc08c004f28..c18467f8bc2 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -16,7 +16,7 @@ internal class AutomationPeerWrapper : UIAccessibilityElement new Dictionary>() { { AutomationElementIdentifiers.NameProperty, UpdateName }, - { AutomationElementIdentifiers.HelpTextProperty, UpdateValue }, + { AutomationElementIdentifiers.HelpTextProperty, UpdateHelpText }, { AutomationElementIdentifiers.BoundingRectangleProperty, UpdateBoundingRectangle }, { RangeValuePatternIdentifiers.IsReadOnlyProperty, UpdateIsReadOnly }, @@ -44,15 +44,13 @@ public AutomationPeerWrapper(AvaloniaView view, AutomationPeer? peer = null) : b private static void UpdateName(AutomationPeerWrapper self) { AutomationPeer peer = self; - IReadOnlyList children = peer.GetChildren(); - if (peer.GetName().Length == 0 && children.Count == 1) - { - self.AccessibilityLabel = children[0].GetName(); - } - else - { - self.AccessibilityLabel = peer.GetName(); - } + self.AccessibilityLabel = peer.GetName(); + } + + private static void UpdateHelpText(AutomationPeerWrapper self) + { + AutomationPeer peer = self; + self.AccessibilityHint = peer.GetHelpText(); } private static void UpdateBoundingRectangle(AutomationPeerWrapper self) @@ -81,21 +79,9 @@ private static void UpdateIsReadOnly(AutomationPeerWrapper self) private static void UpdateValue(AutomationPeerWrapper self) { AutomationPeer peer = self; - IReadOnlyList children = peer.GetChildren(); - - string helpText; - if (peer.GetHelpText().Length == 0 && children.Count == 1) - { - helpText = children[0].GetHelpText(); - } - else - { - helpText = peer.GetHelpText(); - } - - string newValue = + string? newValue = peer.GetProvider()?.Value.ToString("0.##") ?? - peer.GetProvider()?.Value ?? helpText; + peer.GetProvider()?.Value; if (self.AccessibilityValue != newValue) { self.AccessibilityValue = newValue; @@ -103,7 +89,7 @@ private static void UpdateValue(AutomationPeerWrapper self) } } - private void PeerChildrenChanged(object? sender, EventArgs e) => _view.UpdateChildren(_peer.GetVisualRoot()!); + private void PeerChildrenChanged(object? sender, EventArgs e) => _view.UpdateChildren(_peer); private void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) => UpdateProperties(e.Property); From 3cffb123669df895680754b3f64c071afbc54cc1 Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Wed, 12 Feb 2025 09:41:34 -0600 Subject: [PATCH 15/19] Add informative comments --- src/iOS/Avalonia.iOS/AvaloniaView.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index 6673ca6c579..3d9148000fa 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -47,6 +47,8 @@ public AvaloniaView() _topLevelImpl = new TopLevelImpl(this); _input = new InputHandler(this, _topLevelImpl); _topLevel = new EmbeddableControlRoot(_topLevelImpl); + + // Init accessibility tree root _accessWrapper = new AutomationPeerWrapper(this); _topLevel.Prepare(); From b11b5afaf6edf3029a0155773ca3ab9c99423315 Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Mon, 17 Feb 2025 11:23:53 -0600 Subject: [PATCH 16/19] Implement review suggestions for iOS VoiceOver --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index c18467f8bc2..830b8238891 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -61,10 +61,15 @@ private static void UpdateBoundingRectangle(AutomationPeerWrapper self) self._view.TopLevel.PointToScreen(bounds.TopLeft), self._view.TopLevel.PointToScreen(bounds.BottomRight) ); - self.AccessibilityFrame = new CGRect( + CGRect nativeRect = new CGRect( screenRect.X, screenRect.Y, screenRect.Width, screenRect.Height ); + if (self.AccessibilityFrame != nativeRect) + { + self.AccessibilityFrame = nativeRect; + UIAccessibility.PostNotification(UIAccessibilityPostNotification.LayoutChanged, null); + } } private static void UpdateIsReadOnly(AutomationPeerWrapper self) @@ -85,23 +90,29 @@ private static void UpdateValue(AutomationPeerWrapper self) if (self.AccessibilityValue != newValue) { self.AccessibilityValue = newValue; - UIAccessibility.PostNotification(UIAccessibilityPostNotification.Announcement, self); + UIAccessibility.PostNotification(UIAccessibilityPostNotification.Announcement, null); } } - private void PeerChildrenChanged(object? sender, EventArgs e) => _view.UpdateChildren(_peer); + private void PeerChildrenChanged(object? sender, EventArgs e) => + UIAccessibility.PostNotification(UIAccessibilityPostNotification.ScreenChanged, null); - private void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) => UpdateProperties(e.Property); + private void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) => + UpdateProperties(e.Property); private void UpdateProperties(params AutomationProperty[] properties) { - Action? setter = - Delegate.Combine(properties - .Where(s_propertySetters.ContainsKey) - .Select(x => s_propertySetters[x]) - .Distinct() - .ToArray()) as Action; - setter?.Invoke(this); + HashSet> calledSetters = new(); + foreach (AutomationProperty property in properties) + { + if (s_propertySetters.TryGetValue(property, + out Action? setter) && + !calledSetters.Contains(setter)) + { + calledSetters.Add(setter); + setter.Invoke(this); + } + } } public bool UpdatePropertiesIfValid() @@ -255,7 +266,7 @@ public override bool AccessibilityScroll(UIAccessibilityScrollDirection directio scrollProvider.Scroll(verticalAmount, horizontalAmount); if (didScroll) { - UIAccessibility.PostNotification(UIAccessibilityPostNotification.PageScrolled, this); + UIAccessibility.PostNotification(UIAccessibilityPostNotification.PageScrolled, null); return true; } } From 2fb061f7a29d2b1d49faf2448f55836ae5f1bd66 Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Mon, 17 Feb 2025 16:05:39 -0600 Subject: [PATCH 17/19] Fixed bugs in determining whether controls are offscreen --- src/Avalonia.Base/Visual.cs | 2 +- src/Avalonia.Controls/Presenters/ContentPresenter.cs | 2 ++ src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs | 4 +++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index cf9a050185d..1d3749692ba 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -548,7 +548,7 @@ protected virtual void OnDetachedFromVisualTreeCore(VisualTreeAttachmentEventArg } DisableTransitions(); - UpdateIsEffectivelyVisible(true); + UpdateIsEffectivelyVisible(false); OnDetachedFromVisualTree(e); DetachFromCompositor(); diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index e57acba5cfe..04e8cd0457c 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -461,6 +461,7 @@ private void UpdateChild(object? content) if (oldChild != null) { + oldChild.IsVisible = false; VisualChildren.Remove(oldChild); logicalChildren.Remove(oldChild); ((ISetInheritanceParent)oldChild).SetParent(oldChild.Parent); @@ -492,6 +493,7 @@ private void UpdateChild(object? content) logicalChildren.Add(newChild); } + newChild.IsVisible = true; VisualChildren.Add(newChild); } diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs index 4a1683db536..717b1368b6e 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs @@ -57,10 +57,12 @@ internal void UpdateChildren(AutomationPeer peer) { AutomationPeerWrapper? wrapper; if (!_childrenMap.TryGetValue(child, out wrapper) && + child.IsContentElement() && !child.IsOffscreen() && (child.GetName().Length > 0 || child.IsKeyboardFocusable())) { + wrapper = new(this, child); _childrenList.Add(child); - _childrenMap.Add(child, new(this, child)); + _childrenMap.Add(child, wrapper); } wrapper?.UpdatePropertiesIfValid(); From 1e332137f4774f56136942820088043e45099092 Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Mon, 17 Feb 2025 16:07:39 -0600 Subject: [PATCH 18/19] Ensure that AutomationPeer children are updated --- src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs index 830b8238891..ad4c6e2c9b8 100644 --- a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -94,8 +94,11 @@ private static void UpdateValue(AutomationPeerWrapper self) } } - private void PeerChildrenChanged(object? sender, EventArgs e) => + private void PeerChildrenChanged(object? sender, EventArgs e) + { + _view.UpdateChildren(_peer); UIAccessibility.PostNotification(UIAccessibilityPostNotification.ScreenChanged, null); + } private void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) => UpdateProperties(e.Property); From 0b12e0f296c57d23327479ed8871b3e722e59e00 Mon Sep 17 00:00:00 2001 From: Isabelle Santin Date: Mon, 17 Feb 2025 18:35:46 -0600 Subject: [PATCH 19/19] Fix failing tests for IsEffectivelyVisible & CompositorHitTesting --- .../Presenters/ContentPresenter.cs | 2 -- tests/Avalonia.Base.UnitTests/VisualTests.cs | 16 ++++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index 04e8cd0457c..e57acba5cfe 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -461,7 +461,6 @@ private void UpdateChild(object? content) if (oldChild != null) { - oldChild.IsVisible = false; VisualChildren.Remove(oldChild); logicalChildren.Remove(oldChild); ((ISetInheritanceParent)oldChild).SetParent(oldChild.Parent); @@ -493,7 +492,6 @@ private void UpdateChild(object? content) logicalChildren.Add(newChild); } - newChild.IsVisible = true; VisualChildren.Add(newChild); } diff --git a/tests/Avalonia.Base.UnitTests/VisualTests.cs b/tests/Avalonia.Base.UnitTests/VisualTests.cs index aaf6b9992fd..4ff28ebf814 100644 --- a/tests/Avalonia.Base.UnitTests/VisualTests.cs +++ b/tests/Avalonia.Base.UnitTests/VisualTests.cs @@ -383,13 +383,13 @@ public void Removing_Child_Resets_IsEffectivelyVisible() { using var app = UnitTestApplication.Start(); var child = new Decorator(); - var root = new TestRoot { Child = child, IsVisible = false }; + var root = new TestRoot { Child = child, IsVisible = true }; - Assert.False(child.IsEffectivelyVisible); + Assert.True(child.IsEffectivelyVisible); root.Child = null; - Assert.True(child.IsEffectivelyVisible); + Assert.False(child.IsEffectivelyVisible); } [Fact] @@ -398,15 +398,15 @@ public void Removing_Child_Resets_IsEffectivelyVisible_Of_Grandchild() using var app = UnitTestApplication.Start(); var grandchild = new Decorator(); var child = new Decorator { Child = grandchild }; - var root = new TestRoot { Child = child, IsVisible = false }; + var root = new TestRoot { Child = child, IsVisible = true }; - Assert.False(child.IsEffectivelyVisible); - Assert.False(grandchild.IsEffectivelyVisible); + Assert.True(child.IsEffectivelyVisible); + Assert.True(grandchild.IsEffectivelyVisible); root.Child = null; - Assert.True(child.IsEffectivelyVisible); - Assert.True(grandchild.IsEffectivelyVisible); + Assert.False(child.IsEffectivelyVisible); + Assert.False(grandchild.IsEffectivelyVisible); } } }