Skip to content

Commit d3c997d

Browse files
feat: enhance accessibility deferral with frame provider and receiver element
This introduces comprehensive accessibility deferral improvements: - Add ReceiverContainer element to expose deferred accessibility content - Add FrameProvider to centralize accessibility frame handling - Add merge() method to combine accessibility representations - Apply container frames and corner radius to accessibility paths - Refactor Receiver protocol with updateDeferredAccessibility callback - Make CombinableView extensible and add mergeValues support - Update array extensions to package visibility Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 1ab8625 commit d3c997d

4 files changed

Lines changed: 231 additions & 48 deletions

File tree

BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,29 @@ extension AccessibilityComposition {
136136
interactiveChildren = allInteractiveChildren.isEmpty ? nil : allInteractiveChildren
137137
}
138138

139+
// returns a new representation with the combined accessibility, favoring the accessibility of the receiver.
140+
internal func merge(with merge: AccessibilityComposition.CompositeRepresentation?) -> AccessibilityComposition.CompositeRepresentation {
141+
guard let merge else { return self }
142+
var new = AccessibilityComposition.CompositeRepresentation([], invalidator: invalidator)
143+
new.label = [label, merge.label].joinedAccessibilityString()
144+
new.value = [value, merge.value].joinedAccessibilityString()
145+
new.hint = [hint, merge.hint].joinedAccessibilityString()
146+
new.identifier = [identifier, merge.identifier].joinedAccessibilityString()
147+
148+
new.traits = traits.union(merge.traits)
149+
150+
var newActions = actions
151+
newActions.customActions += merge.allActions
152+
new.actions = newActions
153+
154+
new.rotors = rotors + merge.rotors
155+
new.interactiveChildren = interactiveChildren + merge.interactiveChildren
156+
157+
new.activationPoint = activationPoint ?? merge.activationPoint
158+
159+
return new
160+
}
161+
139162
internal func override(with override: AccessibilityComposition.CompositeRepresentation?) -> AccessibilityComposition.CompositeRepresentation {
140163
guard let override else { return self }
141164
var new = AccessibilityComposition.CompositeRepresentation([], invalidator: invalidator)
@@ -315,11 +338,14 @@ extension AccessibilityComposition {
315338

316339
extension AccessibilityComposition {
317340

318-
public final class CombinableView: UIView, AXCustomContentProvider, AccessibilityCombinable {
341+
public class CombinableView: UIView, AXCustomContentProvider, AccessibilityCombinable {
319342

320343
// An accessibility representation with values that should override the combined representation
321344
public var overrideValues: AccessibilityComposition.CompositeRepresentation? = nil
322345

346+
// An accessibility representation with values that should be merged with the combined representation
347+
public var mergeValues: AccessibilityComposition.CompositeRepresentation? = nil
348+
323349
// 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.
324350
public var mergeInteractiveSingleChild: Bool = true
325351

@@ -387,10 +413,12 @@ extension AccessibilityComposition {
387413
root: self,
388414
userInterfaceIdiom: interfaceidiom
389415
)
390-
let combined = combineChildren(filter: customFilter, sorting: sorting)
416+
let accessibility = combineChildren(filter: customFilter, sorting: sorting)
417+
.override(with: overrideValues)
418+
.merge(with: mergeValues)
391419

392420
applyAccessibility(
393-
combined.override(with: overrideValues),
421+
accessibility,
394422
mergeInteractiveSingleChild: mergeInteractiveSingleChild
395423
)
396424

BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift

Lines changed: 194 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,14 @@ public struct AccessibilityDeferral {
1919
var rotorSequencer: AccessibilityComposition.RotorSequencer? { get set }
2020

2121
/// Custom content that may be supplied in addition to the deferred content
22-
var customContent: [Accessibility.CustomContent]? { get set }
22+
var customContent: [BlueprintUI.Accessibility.CustomContent]? { get set }
2323

2424
/// Content from an outside source that will be exposed via AccessibilityCustomContent
2525
var deferredAccessibilityContent: [AccessibilityDeferral.Content]? { get set }
2626

27-
/// Called by the parent container. Default implementation provided.
28-
/// - parameter content: the accessibility content to apply to the receiver.
29-
func applyDeferredAccessibility(
30-
content: [AccessibilityDeferral.Content]?
31-
)
27+
/// Called by the parent container after deferred value update pass completes.
28+
/// - parameter frameProvider: an optional accessibility frame to apply at the receiver's discretion.
29+
func updateDeferredAccessibility(frameProvider: AccessibilityDeferral.FrameProvider?)
3230
}
3331

3432
/// 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.
@@ -44,20 +42,18 @@ public struct AccessibilityDeferral {
4442

4543
public struct Content: Equatable {
4644
public enum Kind: Equatable {
47-
/// Uses accessibility values from the contained element and exposes them as custom via the accessiblity rotor.
45+
/// Uses accessibility values from the contained element and exposes them as custom via the accessibility rotor.
4846
case inherited(Accessibility.CustomContent.Importance = .default)
4947
/// Announces an error message with high importance using accessibility values from the contained element.
5048
case error
51-
/// Exposes the custom content provided.
52-
case custom(Accessibility.CustomContent)
5349
}
5450

5551
public var kind: Kind
5652

5753
/// Used to identify a specific `Source` element to inherit accessibility from.
5854
public var sourceIdentifier: AnyHashable
5955

60-
/// : A stable identifier used to identify a given update pass through he view hierarchy. Content with matching updateIdentifiers should be combined.
56+
/// A stable identifier used to identify a given update pass through he view hierarchy. Content with matching updateIdentifiers should be combined.
6157
internal var updateIdentifier: UUID?
6258
internal var inheritedAccessibility: AccessibilityComposition.CompositeRepresentation?
6359

@@ -77,8 +73,59 @@ public struct AccessibilityDeferral {
7773
content?.value = value
7874
content?.label = LocalizedStrings.Accessibility.errorTitle
7975
return content?.axCustomContent
80-
case .custom(let customContent):
81-
return customContent.axCustomContent
76+
}
77+
}
78+
}
79+
}
80+
81+
extension AccessibilityDeferral {
82+
83+
// Prefer accessibilityPath API to simplify overrides and provide a common codepath.
84+
public struct FrameProvider {
85+
public static let accessibilityCornerRadius = 8.0 // Matches Voiceover's CGRect API
86+
87+
fileprivate static let accessibilityPathInset = -2.0
88+
89+
private let provider: () -> UIBezierPath
90+
91+
private init(_ provider: @escaping () -> UIBezierPath) {
92+
self.provider = provider
93+
}
94+
95+
public func callAsFunction() -> UIBezierPath {
96+
provider()
97+
}
98+
99+
/// Creates a container frame from a CGRect with rounded corners
100+
/// - Parameters:
101+
/// - rect: The frame in global coordinate space
102+
/// - cornerRadius: The radius for rounded corners
103+
public static func frame(_ rect: CGRect, cornerRadius: CGFloat = accessibilityCornerRadius) -> Self {
104+
.init {
105+
UIBezierPath(roundedRect: rect, cornerRadius: max(0, cornerRadius + accessibilityPathInset))
106+
}
107+
}
108+
109+
/// Creates a container frame from a UIView
110+
/// - Parameters:
111+
/// - view: The view providing the frame geometry
112+
public static func view(_ view: UIView) -> Self {
113+
.init { [weak view] in
114+
guard let view else { return UIBezierPath() }
115+
116+
// Prefer the path if it's already set.
117+
guard view.accessibilityPath == nil else { return view.accessibilityPath! }
118+
119+
let bounds = view.bounds
120+
let outsetFrame = bounds.insetBy(dx: accessibilityPathInset * 2, dy: accessibilityPathInset * 2)
121+
let convertedFrame = UIAccessibility.convertToScreenCoordinates(outsetFrame, in: view)
122+
123+
// Apply corner radius from layer if present, otherwise use default text field radius
124+
let cornerRadius = view.layer.cornerRadius > 0 ? view.layer.cornerRadius : accessibilityCornerRadius
125+
return UIBezierPath(
126+
roundedRect: convertedFrame,
127+
cornerRadius: max(0, cornerRadius + accessibilityPathInset)
128+
)
82129
}
83130
}
84131
}
@@ -98,6 +145,12 @@ extension Element {
98145
public func deferredAccessibilitySource(identifier: AnyHashable) -> AccessibilityDeferral.SourceContainer {
99146
AccessibilityDeferral.SourceContainer(wrapping: { self }, identifier: identifier)
100147
}
148+
149+
/// Creates a `ReceiverContainer` element to expose the deferred accessibility.
150+
/// TODO Consider using the identifiers?
151+
public func deferredAccessibilityReceiver(identifiers: [AnyHashable]) -> AccessibilityDeferral.ReceiverContainer {
152+
AccessibilityDeferral.ReceiverContainer(wrapping: { self })
153+
}
101154
}
102155

103156
extension AccessibilityDeferral {
@@ -130,7 +183,6 @@ extension AccessibilityDeferral {
130183

131184
private final class DeferralContainerView: UIView {
132185

133-
var useContainerFrame: Bool = true
134186
var contents: [Content]? {
135187
didSet {
136188
if oldValue != contents {
@@ -177,9 +229,7 @@ extension AccessibilityDeferral {
177229

178230
guard receivers.count <= 1 else {
179231
// We cannot reasonably determine which receiver to apply the content to.
180-
receivers.forEach { $0.applyDeferredAccessibility(
181-
content: nil
182-
) }
232+
receivers.forEach { $0.apply(content: nil, frameProvider: nil) }
183233
return
184234
}
185235

@@ -199,14 +249,113 @@ extension AccessibilityDeferral {
199249
}
200250

201251
// Apply content to receiver.
202-
receiver.applyDeferredAccessibility(
203-
content: deferredContent
204-
)
252+
receiver.apply(content: deferredContent, frameProvider: .view(self))
253+
205254
}
206255
}
207256
}
208257
}
209258

259+
extension AccessibilityDeferral {
260+
261+
public struct ReceiverContainer: Element {
262+
public var wrappedElement: Element
263+
264+
init(wrapping: @escaping () -> Element) {
265+
wrappedElement = wrapping()
266+
}
267+
268+
public var content: ElementContent {
269+
ElementContent(measuring: wrappedElement)
270+
}
271+
272+
public func backingViewDescription(with context: BlueprintUI.ViewDescriptionContext) -> BlueprintUI.ViewDescription? {
273+
ReceiverContainerView.describe { config in
274+
config.apply { view in
275+
view.isAccessibilityElement = true
276+
view.needsAccessibilityUpdate = true
277+
view.layoutDirection = context.environment.layoutDirection
278+
view.element = wrappedElement
279+
}
280+
}
281+
}
282+
283+
private final class ReceiverContainerView: AccessibilityComposition.CombinableView, AccessibilityDeferral.Receiver {
284+
var element: Element? {
285+
didSet {
286+
blueprintView.element = element
287+
blueprintView.setNeedsLayout()
288+
}
289+
}
290+
291+
private var blueprintView = BlueprintView()
292+
293+
override init(frame: CGRect) {
294+
super.init(frame: frame)
295+
isAccessibilityElement = true
296+
mergeInteractiveSingleChild = false
297+
298+
blueprintView.backgroundColor = .clear
299+
addSubview(blueprintView)
300+
}
301+
302+
@MainActor required init?(coder: NSCoder) {
303+
fatalError("init(coder:) has not been implemented")
304+
}
305+
306+
override func layoutSubviews() {
307+
super.layoutSubviews()
308+
blueprintView.frame = bounds
309+
needsAccessibilityUpdate = true
310+
}
311+
312+
// MARK: - Accessibility Deferral and Custom Content
313+
internal var frameProvider: FrameProvider?
314+
315+
var customContent: [Accessibility.CustomContent]?
316+
317+
var deferredAccessibilityContent: [AccessibilityDeferral.Content]?
318+
319+
public override var accessibilityCustomRotors: [UIAccessibilityCustomRotor]? {
320+
get { super.accessibilityCustomRotors + rotorSequencer?.rotors }
321+
set { super.accessibilityCustomRotors = newValue }
322+
}
323+
324+
public override var accessibilityPath: UIBezierPath? {
325+
get { frameProvider?() ?? UIBezierPath(rect: super.accessibilityFrame) }
326+
set { fatalError("Not settable, please use frameProvider instead.") }
327+
}
328+
329+
public override var accessibilityCustomContent: [AXCustomContent]! {
330+
get {
331+
let existing = super.accessibilityCustomContent
332+
let applied = customContent?.map { AXCustomContent($0) }
333+
return (existing + applied)?.removingDuplicates ?? []
334+
}
335+
set { super.accessibilityCustomContent = newValue }
336+
}
337+
338+
public func updateDeferredAccessibility(frameProvider: FrameProvider?) {
339+
needsAccessibilityUpdate = true
340+
341+
self.frameProvider = frameProvider
342+
343+
if var deferred = deferredAccessibilityContent?.compactMap({ $0.inheritedAccessibility }),
344+
let first = deferred.first
345+
{
346+
347+
mergeValues = deferred.dropFirst()
348+
.reduce(into: first) { result, value in
349+
result.merge(with: value)
350+
}
351+
}
352+
needsAccessibilityUpdate = true
353+
}
354+
}
355+
}
356+
}
357+
358+
210359

211360
extension AccessibilityDeferral {
212361

@@ -261,10 +410,6 @@ extension AccessibilityDeferral {
261410
addSubview(blueprintView)
262411
}
263412

264-
override func addSubview(_ view: UIView) {
265-
super.addSubview(view)
266-
}
267-
268413
required init?(coder: NSCoder) {
269414
fatalError("init(coder:) has not been implemented")
270415
}
@@ -325,11 +470,15 @@ extension AccessibilityComposition.CompositeRepresentation {
325470
}
326471
}
327472

328-
/// Default Implementation
329473
extension AccessibilityDeferral.Receiver {
330474

331-
public func applyDeferredAccessibility(
332-
content: [AccessibilityDeferral.Content]?
475+
// Default implementation ignores frame
476+
public func updateDeferredAccessibility(frameProvider: AccessibilityDeferral.FrameProvider?) {}
477+
478+
479+
internal func apply(
480+
content: [AccessibilityDeferral.Content]?,
481+
frameProvider: AccessibilityDeferral.FrameProvider?
333482
) {
334483
guard let content, !content.isEmpty else { replaceContent([]); return }
335484
guard let updateID = content.first?.updateIdentifier, content.allSatisfy({ $0.updateIdentifier == updateID }) else {
@@ -342,30 +491,32 @@ extension AccessibilityDeferral.Receiver {
342491
} else {
343492
replaceContent(content)
344493
}
494+
updateDeferredAccessibility(frameProvider: frameProvider)
495+
}
345496

346-
func replaceContent(_ content: [AccessibilityDeferral.Content]?) {
347-
deferredAccessibilityContent = content
497+
internal func replaceContent(_ content: [AccessibilityDeferral.Content]?) {
498+
deferredAccessibilityContent = content
348499

349-
accessibilityCustomActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }.removingDuplicateActions()
500+
accessibilityCustomActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }.removingDuplicateActions()
350501

351-
if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty {
352-
rotorSequencer = .init(rotors: rotors)
353-
} else {
354-
rotorSequencer = nil
355-
}
502+
if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty {
503+
rotorSequencer = .init(rotors: rotors)
504+
} else {
505+
rotorSequencer = nil
356506
}
507+
}
357508

358-
func mergeContent(_ content: [AccessibilityDeferral.Content]?) {
359-
deferredAccessibilityContent = (deferredAccessibilityContent + content)?.removingDuplicates
509+
internal func mergeContent(_ content: [AccessibilityDeferral.Content]?) {
510+
deferredAccessibilityContent = (deferredAccessibilityContent + content)?.removingDuplicates
360511

361-
let contentActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }
362-
accessibilityCustomActions = (accessibilityCustomActions + contentActions)?.removingDuplicateActions()
512+
let contentActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }
513+
accessibilityCustomActions = (accessibilityCustomActions + contentActions)?.removingDuplicateActions()
363514

364-
if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty {
365-
let mergedRotors = (rotorSequencer?.rotors ?? []) + rotors
366-
rotorSequencer = .init(rotors: mergedRotors)
367-
accessibilityCustomRotors = rotorSequencer?.rotors
368-
}
515+
if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty {
516+
let mergedRotors = (rotorSequencer?.rotors ?? []) + rotors
517+
rotorSequencer = .init(rotors: mergedRotors)
518+
accessibilityCustomRotors = rotorSequencer?.rotors
369519
}
370520
}
521+
371522
}

0 commit comments

Comments
 (0)