-
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
Draft
RoyalPineapple
wants to merge
2
commits into
main
Choose a base branch
from
alex/labelDeferral
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+230
−48
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
||
|
|
||
| /// 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. | ||
| 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]?) { | ||
|
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 | ||
| } | ||
| } | ||
|
|
||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.