From 376a6e2f0d871ffbaa6b502f7db8315efa76402c Mon Sep 17 00:00:00 2001 From: Jumar Macato <16554748+jmacato@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:32:21 +0800 Subject: [PATCH] Fix macOS AXLayoutChanged notifications dropped for template-internal peers (#21595) * Post macOS AXLayoutChanged on the changed peer's templated parent The changed peer is usually a template-internal layout container that AppKit will not deliver notifications for; post on its templated parent (the owning control), falling back to the nearest exposed ancestor when there is none. * Update with comment --- native/Avalonia.Native/src/OSX/automation.mm | 25 +++++++++++++++++++- src/Avalonia.Native/AvnAutomationPeer.cs | 5 ++++ src/Avalonia.Native/avn.idl | 1 + 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm index 84b348e0c2..a6ae754c43 100644 --- a/native/Avalonia.Native/src/OSX/automation.mm +++ b/native/Avalonia.Native/src/OSX/automation.mm @@ -516,8 +516,31 @@ if (_children) [changed addObjectsFromArray:_children]; + /* + For future reference, upon testing with a sample SwiftUI app: + + 1) Containers vanish. VStack/HStack don't appear in the accessibility tree at all, + only real elements (Text, Button, List) do, parented to one root AXHostingView. + + 2) Changes post on the nearest real element, not the container. + Toggling a child two VStack/HStack levels deep fired AXLayoutChanged on the AXHostingView + (the nearest real element), never on the hidden containers. + + 3) Real controls get their own notification. The List (AXOutline) posted AXRowCountChanged on itself. + + Apple never posts a structural notification on a non-accessibility container. + It targets the nearest element that's actually in the tree. + That's exactly what the code below does (templated parent, else walk to the nearest exposed ancestor). + */ + + id target = [AvnAccessibilityElement acquire:_peer->GetTemplatedParent()]; + if (target == nil) + target = self; + while ([target isKindOfClass:[AvnAccessibilityElement class]] && ![(AvnAccessibilityElement*)target isAccessibilityElement]) + target = [(AvnAccessibilityElement*)target accessibilityParent]; + NSAccessibilityPostNotificationWithUserInfo( - self, + target, NSAccessibilityLayoutChangedNotification, @{ NSAccessibilityUIElementsKey: [changed allObjects]}); } diff --git a/src/Avalonia.Native/AvnAutomationPeer.cs b/src/Avalonia.Native/AvnAutomationPeer.cs index dae105c8a4..659ba78200 100644 --- a/src/Avalonia.Native/AvnAutomationPeer.cs +++ b/src/Avalonia.Native/AvnAutomationPeer.cs @@ -6,6 +6,7 @@ using System.Runtime.CompilerServices; using Avalonia.Automation; using Avalonia.Automation.Peers; using Avalonia.Automation.Provider; +using Avalonia.Controls; using Avalonia.Controls.Automation.Peers; using Avalonia.Native.Interop; @@ -56,6 +57,10 @@ namespace Avalonia.Native public AvnLandmarkType LandmarkType => (AvnLandmarkType?)_inner.GetLandmarkType() ?? AvnLandmarkType.LandmarkNone; public int HeadingLevel => _inner.GetHeadingLevel(); public IAvnAutomationPeer? Parent => Wrap(_inner.GetParent()); + public IAvnAutomationPeer? TemplatedParent => + _inner is ControlAutomationPeer { Owner.TemplatedParent: Control templatedParent } ? + Wrap(ControlAutomationPeer.CreatePeerForElement(templatedParent)) + : null; public IAvnAutomationPeer? VisualRoot => Wrap(_inner.GetAutomationRoot()); public AvnLiveSetting LiveSetting => (AvnLiveSetting)_inner.GetLiveSetting(); diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index 3762126989..1b9ee376c6 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -1274,6 +1274,7 @@ interface IAvnAutomationPeer : IUnknown IAvnAutomationPeer* GetLabeledBy(); IAvnString* GetName(); IAvnAutomationPeer* GetParent(); + IAvnAutomationPeer* GetTemplatedParent(); IAvnAutomationPeer* GetVisualRoot(); bool HasKeyboardFocus(); bool IsContentElement();