diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift index c83053dc5..80326b88b 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 the 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..3a00ff169 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -19,16 +19,14 @@ public struct AccessibilityDeferral { var rotorSequencer: AccessibilityComposition.RotorSequencer? { get set } /// Custom content that may be supplied in addition to the deferred content - var customContent: [Accessibility.CustomContent]? { get set } + var customContent: [BlueprintUI.Accessibility.CustomContent]? { get set } /// 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 completes. + /// - parameter frameProvider: an optional accessibility frame to apply at the receiver's discretion. + func updateDeferredAccessibility(frameProvider: AccessibilityDeferral.FrameProvider?) } /// 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,59 @@ public struct AccessibilityDeferral { content?.value = value content?.label = LocalizedStrings.Accessibility.errorTitle return content?.axCustomContent - case .custom(let customContent): - return customContent.axCustomContent + } + } + } +} + +extension AccessibilityDeferral { + + // Prefer accessibilityPath API to simplify overrides and provide a common codepath. + public struct FrameProvider { + public static let accessibilityCornerRadius = 8.0 // Matches Voiceover's CGRect API + + fileprivate static let accessibilityPathInset = -2.0 + + private let provider: () -> UIBezierPath + + private init(_ provider: @escaping () -> UIBezierPath) { + self.provider = provider + } + + public func callAsFunction() -> UIBezierPath { + provider() + } + + /// Creates a container frame from a CGRect with rounded corners + /// - Parameters: + /// - rect: The frame in global coordinate space + /// - cornerRadius: The radius for rounded corners + public static func frame(_ rect: CGRect, cornerRadius: CGFloat = accessibilityCornerRadius) -> Self { + .init { + UIBezierPath(roundedRect: rect, cornerRadius: max(0, cornerRadius + accessibilityPathInset)) + } + } + + /// Creates a container frame from a UIView + /// - Parameters: + /// - view: The view providing the frame geometry + public static func view(_ view: UIView) -> Self { + .init { [weak view] in + guard let view else { return UIBezierPath() } + + // Prefer the path if it's already set. + guard view.accessibilityPath == nil else { return view.accessibilityPath! } + + let bounds = view.bounds + let outsetFrame = bounds.insetBy(dx: accessibilityPathInset * 2, dy: accessibilityPathInset * 2) + let convertedFrame = UIAccessibility.convertToScreenCoordinates(outsetFrame, in: view) + + // Apply corner radius from layer if present, otherwise use default text field radius + let cornerRadius = view.layer.cornerRadius > 0 ? view.layer.cornerRadius : accessibilityCornerRadius + return UIBezierPath( + roundedRect: convertedFrame, + cornerRadius: max(0, cornerRadius + accessibilityPathInset) + ) } } } @@ -98,6 +145,11 @@ extension Element { public func deferredAccessibilitySource(identifier: AnyHashable) -> AccessibilityDeferral.SourceContainer { AccessibilityDeferral.SourceContainer(wrapping: { self }, identifier: identifier) } + + /// Creates a `ReceiverContainer` element to expose the deferred accessibility. + public func deferredAccessibilityReceiver(identifiers: [AnyHashable]) -> AccessibilityDeferral.ReceiverContainer { + AccessibilityDeferral.ReceiverContainer(wrapping: { self }) + } } extension AccessibilityDeferral { @@ -130,7 +182,6 @@ extension AccessibilityDeferral { private final class DeferralContainerView: UIView { - var useContainerFrame: Bool = true var contents: [Content]? { didSet { if oldValue != contents { @@ -177,9 +228,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, frameProvider: nil) } return } @@ -199,14 +248,113 @@ extension AccessibilityDeferral { } // Apply content to receiver. - receiver.applyDeferredAccessibility( - content: deferredContent - ) + receiver.apply(content: deferredContent, frameProvider: .view(self)) + } } } } +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 + internal var frameProvider: FrameProvider? + + 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 accessibilityPath: UIBezierPath? { + get { frameProvider?() ?? UIBezierPath(rect: super.accessibilityFrame) } + set { fatalError("Not settable, please use frameProvider instead.") } + } + + 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 updateDeferredAccessibility(frameProvider: FrameProvider?) { + needsAccessibilityUpdate = true + + self.frameProvider = frameProvider + + 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 +409,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 +469,15 @@ extension AccessibilityComposition.CompositeRepresentation { } } -/// Default Implementation extension AccessibilityDeferral.Receiver { - public func applyDeferredAccessibility( - content: [AccessibilityDeferral.Content]? + // Default implementation ignores frame + public func updateDeferredAccessibility(frameProvider: AccessibilityDeferral.FrameProvider?) {} + + + internal func apply( + content: [AccessibilityDeferral.Content]?, + frameProvider: AccessibilityDeferral.FrameProvider? ) { guard let content, !content.isEmpty else { replaceContent([]); return } guard let updateID = content.first?.updateIdentifier, content.allSatisfy({ $0.updateIdentifier == updateID }) else { @@ -342,30 +490,32 @@ extension AccessibilityDeferral.Receiver { } else { replaceContent(content) } + updateDeferredAccessibility(frameProvider: frameProvider) + } - func replaceContent(_ content: [AccessibilityDeferral.Content]?) { - deferredAccessibilityContent = content + 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..823d65edb 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? { + package 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 { + package 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 + } + }