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/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/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/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, }; } diff --git a/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs new file mode 100644 index 00000000000..ad4c6e2c9b8 --- /dev/null +++ b/src/iOS/Avalonia.iOS/AutomationPeerWrapper.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using CoreGraphics; +using Foundation; +using UIKit; + +namespace Avalonia.iOS +{ + internal class AutomationPeerWrapper : UIAccessibilityElement + { + private static readonly IReadOnlyDictionary> s_propertySetters = + new Dictionary>() + { + { 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; + private readonly AutomationPeer _peer; + + public AutomationPeerWrapper(AvaloniaView view, AutomationPeer? peer = null) : base(view) + { + _view = view; + _peer = peer ?? ControlAutomationPeer.CreatePeerForElement(view.TopLevel); + + _peer.PropertyChanged += PeerPropertyChanged; + _peer.ChildrenChanged += PeerChildrenChanged; + + AccessibilityContainer = _view; + AccessibilityIdentifier = _peer.GetAutomationId(); + } + + private static void UpdateName(AutomationPeerWrapper self) + { + AutomationPeer peer = 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; + Rect bounds = peer.GetBoundingRectangle(); + PixelRect screenRect = new PixelRect( + self._view.TopLevel.PointToScreen(bounds.TopLeft), + self._view.TopLevel.PointToScreen(bounds.BottomRight) + ); + 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) + { + AutomationPeer peer = self; + self.AccessibilityRespondsToUserInteraction = + peer.GetProvider()?.IsReadOnly ?? + peer.GetProvider()?.IsReadOnly ?? + peer.IsEnabled(); + } + + private static void UpdateValue(AutomationPeerWrapper self) + { + AutomationPeer peer = self; + string? newValue = + peer.GetProvider()?.Value.ToString("0.##") ?? + peer.GetProvider()?.Value; + if (self.AccessibilityValue != newValue) + { + self.AccessibilityValue = newValue; + UIAccessibility.PostNotification(UIAccessibilityPostNotification.Announcement, null); + } + } + + 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); + + private void UpdateProperties(params AutomationProperty[] properties) + { + 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() + { + if (_peer.IsContentElement() && !_peer.IsOffscreen()) + { + UpdateProperties(s_propertySetters.Keys.ToArray()); + return IsAccessibilityElement = true; + } + else + { + return IsAccessibilityElement = false; + } + } + + 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() + { + IToggleProvider? toggleProvider = _peer.GetProvider(); + IInvokeProvider? invokeProvider = _peer.GetProvider(); + if (toggleProvider is not null) + { + toggleProvider.Toggle(); + return true; + } + else if (invokeProvider is not null) + { + invokeProvider.Invoke(); + return true; + } + else + { + return false; + } + } + + public override bool AccessibilityElementIsFocused() + { + base.AccessibilityElementIsFocused(); + return _peer.HasKeyboardFocus(); + } + + public override void AccessibilityElementDidBecomeFocused() + { + base.AccessibilityElementDidBecomeFocused(); + _peer.BringIntoView(); + } + + 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, null); + 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 new file mode 100644 index 00000000000..717b1368b6e --- /dev/null +++ b/src/iOS/Avalonia.iOS/AvaloniaView.Automation.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using Avalonia.Automation.Peers; +using Foundation; +using UIKit; + +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; } = + UIAccessibilityContainerType.SemanticGroup; + + [Export("accessibilityElementCount")] + public nint AccessibilityElementCount() + { + UpdateChildren(_accessWrapper); + return _childrenList.Count; + } + + [Export("accessibilityElementAtIndex:")] + public NSObject? GetAccessibilityElementAt(nint index) + { + try + { + var wrapper = _childrenMap[_childrenList[(int)index]]; + if (wrapper.UpdatePropertiesIfValid()) + { + return wrapper; + } + else + { + _childrenList.Remove(wrapper); + _childrenMap.Remove(wrapper); + } + } + catch (KeyNotFoundException) { } + catch (ArgumentOutOfRangeException) { } + + return null; + } + + [Export("indexOfAccessibilityElement:")] + 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()) + { + 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, wrapper); + } + + wrapper?.UpdatePropertiesIfValid(); + wrapper?.UpdateTraits(); + + UpdateChildren(child); + } + } + } +} diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index 4248987d343..3d9148000fa 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -31,9 +31,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,6 +48,9 @@ public AvaloniaView() _input = new InputHandler(this, _topLevelImpl); _topLevel = new EmbeddableControlRoot(_topLevelImpl); + // Init accessibility tree root + _accessWrapper = new AutomationPeerWrapper(this); + _topLevel.Prepare(); _topLevel.StartRendering(); 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); } } }