diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift index b55def67b..6b4e03ddd 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift @@ -136,6 +136,29 @@ 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 += merge.allActions + new.actions = newActions + + new.rotors = rotors + merge.rotors + new.interactiveChildren = interactiveChildren + merge.interactiveChildren + + new.activationPoint = activationPoint ?? merge.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 +338,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 +413,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..883587fd6 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. + /// - 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. @@ -44,12 +42,10 @@ 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 - /// Exposes the custom content provided. - case custom(Accessibility.CustomContent) } public var kind: Kind @@ -57,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? @@ -77,8 +73,6 @@ public struct AccessibilityDeferral { content?.value = value content?.label = LocalizedStrings.Accessibility.errorTitle return content?.axCustomContent - case .custom(let customContent): - return customContent.axCustomContent } } } @@ -98,6 +92,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 { @@ -177,9 +177,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, containerFrame: nil) } return } @@ -199,14 +197,107 @@ extension AccessibilityDeferral { } // Apply content to receiver. - receiver.applyDeferredAccessibility( - content: deferredContent - ) + receiver.apply(content: deferredContent, containerFrame: accessibilityFrame) + } } } } +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 + mergeInteractiveSingleChild = false + + 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 applied = customContent?.map { AXCustomContent($0) } + return (existing + applied)?.removingDuplicates ?? [] + } + set { super.accessibilityCustomContent = newValue } + } + + 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 + { + + mergeValues = deferred.dropFirst() + .reduce(into: first) { result, value in + result.merge(with: value) + } + } + needsAccessibilityUpdate = true + } + } + } +} + + extension AccessibilityDeferral { @@ -261,10 +352,6 @@ extension AccessibilityDeferral { addSubview(blueprintView) } - override func addSubview(_ view: UIView) { - super.addSubview(view) - } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -325,11 +412,18 @@ extension AccessibilityComposition.CompositeRepresentation { } } -/// Default Implementation +extension AccessibilityDeferral.Receiver { + // Default Implementation + public func applyDeferredAccessibility(containerFrame: CGRect?) { + accessibilityFrame = containerFrame ?? UIAccessibility.convertToScreenCoordinates(bounds, in: self) + } +} + extension AccessibilityDeferral.Receiver { - public func applyDeferredAccessibility( - content: [AccessibilityDeferral.Content]? + internal func apply( + 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 { @@ -343,29 +437,32 @@ extension AccessibilityDeferral.Receiver { replaceContent(content) } - func replaceContent(_ content: [AccessibilityDeferral.Content]?) { - deferredAccessibilityContent = content + applyDeferredAccessibility(containerFrame: containerFrame) + } + + internal func replaceContent(_ content: [AccessibilityDeferral.Content]?) { + deferredAccessibilityContent = content - accessibilityCustomActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }.removingDuplicateActions() + 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 - } + 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/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] } } } 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 + } + }