Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
)

Expand Down
236 changes: 193 additions & 43 deletions BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.
Expand All @@ -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?

Expand All @@ -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)
)
}
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -130,7 +182,6 @@ extension AccessibilityDeferral {

private final class DeferralContainerView: UIView {

var useContainerFrame: Bool = true
var contents: [Content]? {
didSet {
if oldValue != contents {
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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]?) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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
}
}

}
Loading
Loading