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,30 @@ 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

// TODO: ADD FRAME SUPPORT? lets not change the activation point as we didn't change the activation item
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • remove todo, its done

// new.activationPoint = merge.activationPoint ?? 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 +339,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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "merged with the combined..."

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 +414,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)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we consider overwriting if both exist?


applyAccessibility(
combined.override(with: overrideValues),
accessibility,
mergeInteractiveSingleChild: mergeInteractiveSingleChild
)

Expand Down
179 changes: 138 additions & 41 deletions BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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,6 @@ public struct AccessibilityDeferral {
content?.value = value
content?.label = LocalizedStrings.Accessibility.errorTitle
return content?.axCustomContent
case .custom(let customContent):
return customContent.axCustomContent
}
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

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

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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: ", ")
Expand All @@ -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] }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ extension Optional where Wrapped: RangeReplaceableCollection {
return val.isEmpty ? nil : val
}

static func += (lhs: inout Wrapped?, rhs: Wrapped?) {
lhs = lhs + rhs
}

}
Loading