From 8a2a0574699ba0f911af4033af3dd14784b66d22 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Tue, 16 Dec 2025 17:47:10 +0100 Subject: [PATCH 1/6] adjusting visibilitty of array extensions --- .../Sources/Extensions/Array+Extensions.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/Extensions/Array+Extensions.swift b/BlueprintUIAccessibilityCore/Sources/Extensions/Array+Extensions.swift index 20228b0a5..6cb7511e4 100644 --- a/BlueprintUIAccessibilityCore/Sources/Extensions/Array+Extensions.swift +++ b/BlueprintUIAccessibilityCore/Sources/Extensions/Array+Extensions.swift @@ -3,7 +3,7 @@ import Foundation extension [String?] { /// Joins non-empty optional strings into a single string formatted for use in accessibility contexts. - internal func joinedAccessibilityString() -> String? { + public func joinedAccessibilityString() -> String? { let joined = compactMap { $0 } .filter { !$0.isEmpty } .joined(separator: ", ") @@ -14,7 +14,7 @@ extension [String?] { extension Array where Element: Equatable { /// Returns an array where only the first instance of any duplicated element is included. - internal var removingDuplicates: Self { + public var removingDuplicates: Self { reduce([]) { $0.contains($1) ? $0 : $0 + [$1] } } } From 8f82857bcfa636ccbdf171e60b31248cf0f01299 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Tue, 16 Dec 2025 17:47:26 +0100 Subject: [PATCH 2/6] creatting accessibility deferral reciever container --- .../Sources/AccessibilityComposition.swift | 36 +++++- .../Sources/AccessibilityDeferral.swift | 114 +++++++++++++++++- 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift index b55def67b..3d141322e 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift @@ -136,6 +136,31 @@ extension AccessibilityComposition { interactiveChildren = allInteractiveChildren.isEmpty ? nil : allInteractiveChildren } + // returns a new representation with the combined accessibility, favoring the accessibility of the receiver. + internal func merge(with merge: AccessibilityComposition.CompositeRepresentation?) -> AccessibilityComposition.CompositeRepresentation { + guard let merge else { return self } + var new = AccessibilityComposition.CompositeRepresentation([], invalidator: invalidator) + new.label = [label, merge.label].joinedAccessibilityString() + new.value = [value, merge.value].joinedAccessibilityString() + new.hint = [hint, merge.hint].joinedAccessibilityString() + new.identifier = [identifier, merge.identifier].joinedAccessibilityString() + + new.traits = traits.union(merge.traits) + + var newActions = actions + newActions.customActions = actions.customActions + merge.allActions + new.actions = newActions + + + new.rotors = rotors + merge.rotors + new.interactiveChildren = interactiveChildren + merge.interactiveChildren + + // TODO: ADD FRAME SUPPORT? lets not change the activation point as we didn't change the activation item +// new.activationPoint = merge.activationPoint ?? activationPoint + + return new + } + internal func override(with override: AccessibilityComposition.CompositeRepresentation?) -> AccessibilityComposition.CompositeRepresentation { guard let override else { return self } var new = AccessibilityComposition.CompositeRepresentation([], invalidator: invalidator) @@ -315,11 +340,14 @@ extension AccessibilityComposition { extension AccessibilityComposition { - public final class CombinableView: UIView, AXCustomContentProvider, AccessibilityCombinable { + public class CombinableView: UIView, AXCustomContentProvider, AccessibilityCombinable { // An accessibility representation with values that should override the combined representation public var overrideValues: AccessibilityComposition.CompositeRepresentation? = nil + // An accessibility representation with values that should be merged with combined representation + public var mergeValues: AccessibilityComposition.CompositeRepresentation? = nil + // If enabled, a combined view with only a single interactive child element will include the child in the accessibility representation rather than as a custom action. E.G. a button and label become a single button element. public var mergeInteractiveSingleChild: Bool = true @@ -387,10 +415,12 @@ extension AccessibilityComposition { root: self, userInterfaceIdiom: interfaceidiom ) - let combined = combineChildren(filter: customFilter, sorting: sorting) + let accessibility = combineChildren(filter: customFilter, sorting: sorting) + .override(with: overrideValues) + .merge(with: mergeValues) applyAccessibility( - combined.override(with: overrideValues), + accessibility, mergeInteractiveSingleChild: mergeInteractiveSingleChild ) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index c99aeff52..831702867 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -44,7 +44,7 @@ public struct AccessibilityDeferral { public struct Content: Equatable { public enum Kind: Equatable { - /// Uses accessibility values from the contained element and exposes them as custom via the accessiblity rotor. + /// Uses accessibility values from the contained element and exposes them as custom via the accessibility rotor. case inherited(Accessibility.CustomContent.Importance = .default) /// Announces an error message with high importance using accessibility values from the contained element. case error @@ -98,6 +98,12 @@ extension Element { public func deferredAccessibilitySource(identifier: AnyHashable) -> AccessibilityDeferral.SourceContainer { AccessibilityDeferral.SourceContainer(wrapping: { self }, identifier: identifier) } + + /// Creates a `ReceiverContainer` element to expose the deferred accessibility. + /// TODO Consider using the identifiers? + public func deferredAccessibilityReceiver(identifiers: [AnyHashable]) -> AccessibilityDeferral.ReceiverContainer { + AccessibilityDeferral.ReceiverContainer(wrapping: { self }) + } } extension AccessibilityDeferral { @@ -202,11 +208,107 @@ extension AccessibilityDeferral { receiver.applyDeferredAccessibility( content: deferredContent ) + } } } } +extension AccessibilityDeferral { + + public struct ReceiverContainer: Element { + public var wrappedElement: Element + + init(wrapping: @escaping () -> Element) { + wrappedElement = wrapping() + } + + public var content: ElementContent { + ElementContent(measuring: wrappedElement) + } + + public func backingViewDescription(with context: BlueprintUI.ViewDescriptionContext) -> BlueprintUI.ViewDescription? { + ReceiverContainerView.describe { config in + config.apply { view in + view.isAccessibilityElement = true + view.needsAccessibilityUpdate = true + view.layoutDirection = context.environment.layoutDirection + view.element = wrappedElement + } + } + } + + private final class ReceiverContainerView: AccessibilityComposition.CombinableView, AccessibilityDeferral.Receiver { + var element: Element? { + didSet { + blueprintView.element = element + blueprintView.setNeedsLayout() + } + } + + private var blueprintView = BlueprintView() + + override init(frame: CGRect) { + super.init(frame: frame) + isAccessibilityElement = true + + blueprintView.backgroundColor = .clear + addSubview(blueprintView) + } + + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + blueprintView.frame = bounds + needsAccessibilityUpdate = true + } + + // MARK: - Accessibility Deferral and Custom Content + + var customContent: [Accessibility.CustomContent]? + + var deferredAccessibilityContent: [AccessibilityDeferral.Content]? + + public override var accessibilityCustomRotors: [UIAccessibilityCustomRotor]? { + get { super.accessibilityCustomRotors + rotorSequencer?.rotors } + set { super.accessibilityCustomRotors = newValue } + } + + public override var accessibilityCustomContent: [AXCustomContent]! { + get { + let existing = super.accessibilityCustomContent + let deferred = deferredAccessibilityContent?.compactMap { $0.customContent } + let applied = customContent?.map { AXCustomContent($0) } + return (existing + applied + deferred)?.removingDuplicates ?? [] + } + set { super.accessibilityCustomContent = newValue } + } + + public func applyDeferredAccessibility( + content: [AccessibilityDeferral.Content]? + ) { + _applyDeferredAccessibility(content: content) + + if let deferred = deferredAccessibilityContent?.compactMap({ $0.inheritedAccessibility }) { + + let merged: AccessibilityComposition.CompositeRepresentation = deferred + .reduce(into: AccessibilityComposition.CompositeRepresentation([]) { [weak self] in + self?.needsAccessibilityUpdate = true + }) { result, value in + value.merge(with: result) + } + mergeValues = merged + + } + } + } + } +} + + extension AccessibilityDeferral { @@ -261,10 +363,6 @@ extension AccessibilityDeferral { addSubview(blueprintView) } - override func addSubview(_ view: UIView) { - super.addSubview(view) - } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -330,6 +428,12 @@ extension AccessibilityDeferral.Receiver { public func applyDeferredAccessibility( content: [AccessibilityDeferral.Content]? + ) { + _applyDeferredAccessibility(content: content) + } + + internal func _applyDeferredAccessibility( + content: [AccessibilityDeferral.Content]? ) { guard let content, !content.isEmpty else { replaceContent([]); return } guard let updateID = content.first?.updateIdentifier, content.allSatisfy({ $0.updateIdentifier == updateID }) else { From 025d5b27b28341d4b8aebce2d69093be1c1454db Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Tue, 16 Dec 2025 19:28:31 +0100 Subject: [PATCH 3/6] refactoring deferreal application --- .../Sources/AccessibilityComposition.swift | 3 +- .../Sources/AccessibilityDeferral.swift | 88 ++++++++----------- .../Extensions/Optional+Extensions.swift | 4 + 3 files changed, 44 insertions(+), 51 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift index 3d141322e..3d2e78580 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift @@ -148,10 +148,9 @@ extension AccessibilityComposition { new.traits = traits.union(merge.traits) var newActions = actions - newActions.customActions = actions.customActions + merge.allActions + newActions.customActions += merge.allActions new.actions = newActions - new.rotors = rotors + merge.rotors new.interactiveChildren = interactiveChildren + merge.interactiveChildren diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index 831702867..8121a6a94 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -24,11 +24,9 @@ public struct AccessibilityDeferral { /// Content from an outside source that will be exposed via AccessibilityCustomContent var deferredAccessibilityContent: [AccessibilityDeferral.Content]? { get set } - /// Called by the parent container. Default implementation provided. - /// - parameter content: the accessibility content to apply to the receiver. - func applyDeferredAccessibility( - content: [AccessibilityDeferral.Content]? - ) + /// Called by the parent container after deferred value update pass. + func applyDeferredAccessibility() + } /// An accessibility container wrapping an element that natively provides the deferred accessibility content. This element's accessibility is conditionally exposed based on the presence of a receiver. @@ -183,9 +181,7 @@ extension AccessibilityDeferral { guard receivers.count <= 1 else { // We cannot reasonably determine which receiver to apply the content to. - receivers.forEach { $0.applyDeferredAccessibility( - content: nil - ) } + receivers.forEach { $0.apply(content: nil) } return } @@ -205,9 +201,7 @@ extension AccessibilityDeferral { } // Apply content to receiver. - receiver.applyDeferredAccessibility( - content: deferredContent - ) + receiver.apply(content: deferredContent) } } @@ -251,6 +245,7 @@ extension AccessibilityDeferral { override init(frame: CGRect) { super.init(frame: frame) isAccessibilityElement = true + mergeInteractiveSingleChild = false blueprintView.backgroundColor = .clear addSubview(blueprintView) @@ -280,29 +275,24 @@ extension AccessibilityDeferral { public override var accessibilityCustomContent: [AXCustomContent]! { get { let existing = super.accessibilityCustomContent - let deferred = deferredAccessibilityContent?.compactMap { $0.customContent } let applied = customContent?.map { AXCustomContent($0) } - return (existing + applied + deferred)?.removingDuplicates ?? [] + return (existing + applied)?.removingDuplicates ?? [] } set { super.accessibilityCustomContent = newValue } } - public func applyDeferredAccessibility( - content: [AccessibilityDeferral.Content]? - ) { - _applyDeferredAccessibility(content: content) - - if let deferred = deferredAccessibilityContent?.compactMap({ $0.inheritedAccessibility }) { + public func applyDeferredAccessibility() { + needsAccessibilityUpdate = true + if var deferred = deferredAccessibilityContent?.compactMap({ $0.inheritedAccessibility }), + let first = deferred.first + { - let merged: AccessibilityComposition.CompositeRepresentation = deferred - .reduce(into: AccessibilityComposition.CompositeRepresentation([]) { [weak self] in - self?.needsAccessibilityUpdate = true - }) { result, value in - value.merge(with: result) + mergeValues = deferred.dropFirst() + .reduce(into: first) { result, value in + result.merge(with: value) } - mergeValues = merged - } + needsAccessibilityUpdate = true } } } @@ -423,16 +413,13 @@ extension AccessibilityComposition.CompositeRepresentation { } } -/// Default Implementation extension AccessibilityDeferral.Receiver { - public func applyDeferredAccessibility( - content: [AccessibilityDeferral.Content]? - ) { - _applyDeferredAccessibility(content: content) + public func applyDeferredAccessibility() { + // No Op as default, available for override } - internal func _applyDeferredAccessibility( + internal func apply( content: [AccessibilityDeferral.Content]? ) { guard let content, !content.isEmpty else { replaceContent([]); return } @@ -447,29 +434,32 @@ extension AccessibilityDeferral.Receiver { replaceContent(content) } - func replaceContent(_ content: [AccessibilityDeferral.Content]?) { - deferredAccessibilityContent = content + applyDeferredAccessibility() + } - accessibilityCustomActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }.removingDuplicateActions() + internal func replaceContent(_ content: [AccessibilityDeferral.Content]?) { + deferredAccessibilityContent = content - if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty { - rotorSequencer = .init(rotors: rotors) - } else { - rotorSequencer = nil - } + accessibilityCustomActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }.removingDuplicateActions() + + if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty { + rotorSequencer = .init(rotors: rotors) + } else { + rotorSequencer = nil } + } - func mergeContent(_ content: [AccessibilityDeferral.Content]?) { - deferredAccessibilityContent = (deferredAccessibilityContent + content)?.removingDuplicates + internal func mergeContent(_ content: [AccessibilityDeferral.Content]?) { + deferredAccessibilityContent = (deferredAccessibilityContent + content)?.removingDuplicates - let contentActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 } - accessibilityCustomActions = (accessibilityCustomActions + contentActions)?.removingDuplicateActions() + let contentActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 } + accessibilityCustomActions = (accessibilityCustomActions + contentActions)?.removingDuplicateActions() - if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty { - let mergedRotors = (rotorSequencer?.rotors ?? []) + rotors - rotorSequencer = .init(rotors: mergedRotors) - accessibilityCustomRotors = rotorSequencer?.rotors - } + if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty { + let mergedRotors = (rotorSequencer?.rotors ?? []) + rotors + rotorSequencer = .init(rotors: mergedRotors) + accessibilityCustomRotors = rotorSequencer?.rotors } } + } diff --git a/BlueprintUIAccessibilityCore/Sources/Extensions/Optional+Extensions.swift b/BlueprintUIAccessibilityCore/Sources/Extensions/Optional+Extensions.swift index c015479a1..edf45915c 100644 --- a/BlueprintUIAccessibilityCore/Sources/Extensions/Optional+Extensions.swift +++ b/BlueprintUIAccessibilityCore/Sources/Extensions/Optional+Extensions.swift @@ -7,4 +7,8 @@ extension Optional where Wrapped: RangeReplaceableCollection { return val.isEmpty ? nil : val } + static func += (lhs: inout Wrapped?, rhs: Wrapped?) { + lhs = lhs + rhs + } + } From b0a6e00c4f3d54a0391ebcfc4fcaca60593c0393 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Tue, 16 Dec 2025 20:11:18 +0100 Subject: [PATCH 4/6] removing unused enum case --- .../Sources/AccessibilityDeferral.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index 8121a6a94..ed0d48f0f 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -46,8 +46,6 @@ public struct AccessibilityDeferral { case inherited(Accessibility.CustomContent.Importance = .default) /// Announces an error message with high importance using accessibility values from the contained element. case error - /// Exposes the custom content provided. - case custom(Accessibility.CustomContent) } public var kind: Kind @@ -75,8 +73,6 @@ public struct AccessibilityDeferral { content?.value = value content?.label = LocalizedStrings.Accessibility.errorTitle return content?.axCustomContent - case .custom(let customContent): - return customContent.axCustomContent } } } From 791d33c8cd7d29e0ee17b9e290404d2a8f5df9f0 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Tue, 16 Dec 2025 20:25:51 +0100 Subject: [PATCH 5/6] now propogating the accessibility frame of the container --- .../Sources/AccessibilityDeferral.swift | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index ed0d48f0f..883587fd6 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -25,8 +25,8 @@ public struct AccessibilityDeferral { var deferredAccessibilityContent: [AccessibilityDeferral.Content]? { get set } /// Called by the parent container after deferred value update pass. - func applyDeferredAccessibility() - + /// - parameter containerFrame: an optional accessibility frame to apply to the receiver. + func applyDeferredAccessibility(containerFrame: CGRect?) } /// An accessibility container wrapping an element that natively provides the deferred accessibility content. This element's accessibility is conditionally exposed based on the presence of a receiver. @@ -53,7 +53,7 @@ public struct AccessibilityDeferral { /// Used to identify a specific `Source` element to inherit accessibility from. public var sourceIdentifier: AnyHashable - /// : A stable identifier used to identify a given update pass through he view hierarchy. Content with matching updateIdentifiers should be combined. + /// :A stable identifier used to identify a given update pass through he view hierarchy. Content with matching updateIdentifiers should be combined. internal var updateIdentifier: UUID? internal var inheritedAccessibility: AccessibilityComposition.CompositeRepresentation? @@ -177,7 +177,7 @@ extension AccessibilityDeferral { guard receivers.count <= 1 else { // We cannot reasonably determine which receiver to apply the content to. - receivers.forEach { $0.apply(content: nil) } + receivers.forEach { $0.apply(content: nil, containerFrame: nil) } return } @@ -197,7 +197,7 @@ extension AccessibilityDeferral { } // Apply content to receiver. - receiver.apply(content: deferredContent) + receiver.apply(content: deferredContent, containerFrame: accessibilityFrame) } } @@ -277,8 +277,11 @@ extension AccessibilityDeferral { set { super.accessibilityCustomContent = newValue } } - public func applyDeferredAccessibility() { + public func applyDeferredAccessibility(containerFrame: CGRect?) { needsAccessibilityUpdate = true + + accessibilityFrame = containerFrame ?? UIAccessibility.convertToScreenCoordinates(bounds, in: self) + if var deferred = deferredAccessibilityContent?.compactMap({ $0.inheritedAccessibility }), let first = deferred.first { @@ -410,13 +413,17 @@ extension AccessibilityComposition.CompositeRepresentation { } extension AccessibilityDeferral.Receiver { - - public func applyDeferredAccessibility() { - // No Op as default, available for override + // Default Implementation + public func applyDeferredAccessibility(containerFrame: CGRect?) { + accessibilityFrame = containerFrame ?? UIAccessibility.convertToScreenCoordinates(bounds, in: self) } +} + +extension AccessibilityDeferral.Receiver { internal func apply( - content: [AccessibilityDeferral.Content]? + content: [AccessibilityDeferral.Content]?, + containerFrame: CGRect? = nil ) { guard let content, !content.isEmpty else { replaceContent([]); return } guard let updateID = content.first?.updateIdentifier, content.allSatisfy({ $0.updateIdentifier == updateID }) else { @@ -430,7 +437,7 @@ extension AccessibilityDeferral.Receiver { replaceContent(content) } - applyDeferredAccessibility() + applyDeferredAccessibility(containerFrame: containerFrame) } internal func replaceContent(_ content: [AccessibilityDeferral.Content]?) { From a30947fdfb48e10e44fac13b35c616de0c35c7ae Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Wed, 7 Jan 2026 14:23:35 +0100 Subject: [PATCH 6/6] merging activatoin point --- .../Sources/AccessibilityComposition.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift index 3d2e78580..6b4e03ddd 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift @@ -154,8 +154,7 @@ extension AccessibilityComposition { new.rotors = rotors + merge.rotors new.interactiveChildren = interactiveChildren + merge.interactiveChildren - // TODO: ADD FRAME SUPPORT? lets not change the activation point as we didn't change the activation item -// new.activationPoint = merge.activationPoint ?? activationPoint + new.activationPoint = activationPoint ?? merge.activationPoint return new }