-
Notifications
You must be signed in to change notification settings - Fork 48
feat(ios) Accessibility Deferral Element and additions #598
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 } | ||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure we need this
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I think kill it |
||||||
|
|
||||||
| /// 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,20 +42,18 @@ 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 | ||||||
|
|
||||||
| /// 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. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| 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)) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can this really be negative? |
||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// 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 }) | ||||||
| } | ||||||
|
Comment on lines
+148
to
+152
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we update this function to use or remove the |
||||||
| } | ||||||
|
|
||||||
| 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) | ||||||
| } | ||||||
|
Comment on lines
+346
to
+349
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will the result of |
||||||
| } | ||||||
| needsAccessibilityUpdate = true | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are two calls to |
||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
|
|
||||||
| 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]?) { | ||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. moving this internal as it doesn't need to be the responsibility of the consumer and this removes some foot guns |
||||||
| 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 | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| } | ||||||
Uh oh!
There was an error while loading. Please reload this page.