Skip to content

Commit 05cdd60

Browse files
feat: accessibility deferral frame provider and receiver element
Introduce 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 - Simplify customContent type to Accessibility.CustomContent - Fix reduce bug in updateDeferredAccessibility where merge results were discarded - Fix corner radius calculation to match Market's UIView+Accessibility helper (cornerRadius - accessibilityPathInset) - Add AccessibilityDeferralTests covering merge chaining, apply/replace flow, and content generation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d3cd819 commit 05cdd60

6 files changed

Lines changed: 422 additions & 47 deletions

File tree

.bk.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
selected_org: runway

BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,28 @@ 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 other: AccessibilityComposition.CompositeRepresentation?) -> AccessibilityComposition.CompositeRepresentation {
141+
guard let other else { return self }
142+
var new = AccessibilityComposition.CompositeRepresentation([], invalidator: invalidator)
143+
new.label = [label, other.label].joinedAccessibilityString()
144+
new.value = [value, other.value].joinedAccessibilityString()
145+
new.hint = [hint, other.hint].joinedAccessibilityString()
146+
new.identifier = [identifier, other.identifier].joinedAccessibilityString()
147+
148+
new.traits = traits.union(other.traits)
149+
150+
new.actions = actions
151+
new.actions.customActions += other.allActions
152+
153+
new.rotors = rotors + other.rotors
154+
new.interactiveChildren = interactiveChildren + other.interactiveChildren
155+
156+
new.activationPoint = activationPoint ?? other.activationPoint
157+
158+
return new
159+
}
160+
139161
internal func override(with override: AccessibilityComposition.CompositeRepresentation?) -> AccessibilityComposition.CompositeRepresentation {
140162
guard let override else { return self }
141163
var new = AccessibilityComposition.CompositeRepresentation([], invalidator: invalidator)
@@ -315,11 +337,14 @@ extension AccessibilityComposition {
315337

316338
extension AccessibilityComposition {
317339

318-
public final class CombinableView: UIView, AXCustomContentProvider, AccessibilityCombinable {
340+
public class CombinableView: UIView, AXCustomContentProvider, AccessibilityCombinable {
319341

320342
// An accessibility representation with values that should override the combined representation
321343
public var overrideValues: AccessibilityComposition.CompositeRepresentation? = nil
322344

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

@@ -387,10 +412,12 @@ extension AccessibilityComposition {
387412
root: self,
388413
userInterfaceIdiom: interfaceidiom
389414
)
390-
let combined = combineChildren(filter: customFilter, sorting: sorting)
415+
let accessibility = combineChildren(filter: customFilter, sorting: sorting)
416+
.override(with: overrideValues)
417+
.merge(with: mergeValues)
391418

392419
applyAccessibility(
393-
combined.override(with: overrideValues),
420+
accessibility,
394421
mergeInteractiveSingleChild: mergeInteractiveSingleChild
395422
)
396423

BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift

Lines changed: 190 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,9 @@ public struct AccessibilityDeferral {
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 the 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,11 @@ 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+
public func deferredAccessibilityReceiver() -> AccessibilityDeferral.ReceiverContainer {
151+
AccessibilityDeferral.ReceiverContainer(wrapping: { self })
152+
}
101153
}
102154

103155
extension AccessibilityDeferral {
@@ -130,7 +182,6 @@ extension AccessibilityDeferral {
130182

131183
private final class DeferralContainerView: UIView {
132184

133-
var useContainerFrame: Bool = true
134185
var contents: [Content]? {
135186
didSet {
136187
if oldValue != contents {
@@ -177,9 +228,7 @@ extension AccessibilityDeferral {
177228

178229
guard receivers.count <= 1 else {
179230
// We cannot reasonably determine which receiver to apply the content to.
180-
receivers.forEach { $0.applyDeferredAccessibility(
181-
content: nil
182-
) }
231+
receivers.forEach { $0.apply(content: nil, frameProvider: nil) }
183232
return
184233
}
185234

@@ -199,14 +248,111 @@ extension AccessibilityDeferral {
199248
}
200249

201250
// Apply content to receiver.
202-
receiver.applyDeferredAccessibility(
203-
content: deferredContent
204-
)
251+
receiver.apply(content: deferredContent, frameProvider: .view(self))
252+
205253
}
206254
}
207255
}
208256
}
209257

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

211357
extension AccessibilityDeferral {
212358

@@ -261,10 +407,6 @@ extension AccessibilityDeferral {
261407
addSubview(blueprintView)
262408
}
263409

264-
override func addSubview(_ view: UIView) {
265-
super.addSubview(view)
266-
}
267-
268410
required init?(coder: NSCoder) {
269411
fatalError("init(coder:) has not been implemented")
270412
}
@@ -325,11 +467,15 @@ extension AccessibilityComposition.CompositeRepresentation {
325467
}
326468
}
327469

328-
/// Default Implementation
329470
extension AccessibilityDeferral.Receiver {
330471

331-
public func applyDeferredAccessibility(
332-
content: [AccessibilityDeferral.Content]?
472+
// Default implementation ignores frame
473+
public func updateDeferredAccessibility(frameProvider: AccessibilityDeferral.FrameProvider?) {}
474+
475+
476+
internal func apply(
477+
content: [AccessibilityDeferral.Content]?,
478+
frameProvider: AccessibilityDeferral.FrameProvider?
333479
) {
334480
guard let content, !content.isEmpty else { replaceContent([]); return }
335481
guard let updateID = content.first?.updateIdentifier, content.allSatisfy({ $0.updateIdentifier == updateID }) else {
@@ -342,30 +488,32 @@ extension AccessibilityDeferral.Receiver {
342488
} else {
343489
replaceContent(content)
344490
}
491+
updateDeferredAccessibility(frameProvider: frameProvider)
492+
}
345493

346-
func replaceContent(_ content: [AccessibilityDeferral.Content]?) {
347-
deferredAccessibilityContent = content
494+
internal func replaceContent(_ content: [AccessibilityDeferral.Content]?) {
495+
deferredAccessibilityContent = content
348496

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

351-
if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty {
352-
rotorSequencer = .init(rotors: rotors)
353-
} else {
354-
rotorSequencer = nil
355-
}
499+
if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty {
500+
rotorSequencer = .init(rotors: rotors)
501+
} else {
502+
rotorSequencer = nil
356503
}
504+
}
357505

358-
func mergeContent(_ content: [AccessibilityDeferral.Content]?) {
359-
deferredAccessibilityContent = (deferredAccessibilityContent + content)?.removingDuplicates
506+
internal func mergeContent(_ content: [AccessibilityDeferral.Content]?) {
507+
deferredAccessibilityContent = (deferredAccessibilityContent + content)?.removingDuplicates
360508

361-
let contentActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }
362-
accessibilityCustomActions = (accessibilityCustomActions + contentActions)?.removingDuplicateActions()
509+
let contentActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }
510+
accessibilityCustomActions = (accessibilityCustomActions + contentActions)?.removingDuplicateActions()
363511

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-
}
512+
if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty {
513+
let mergedRotors = (rotorSequencer?.rotors ?? []) + rotors
514+
rotorSequencer = .init(rotors: mergedRotors)
515+
accessibilityCustomRotors = rotorSequencer?.rotors
369516
}
370517
}
518+
371519
}

0 commit comments

Comments
 (0)