diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift new file mode 100644 index 000000000..ddaa11f1b --- /dev/null +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift @@ -0,0 +1,59 @@ +import UIKit + +/// A trigger that moves VoiceOver focus to a backing view. +/// +/// Like `FocusTrigger`, this uses late-binding: create the trigger +/// before any view exists, bind it to a backing view during layout, +/// and invoke it later to move VoiceOver focus. +/// +/// ## Example +/// +/// let errorFocusTrigger = AccessibilityFocusTrigger() +/// +/// // In the element tree: +/// Label(text: "Invalid amount") +/// .accessibilityFocus(trigger: errorFocusTrigger) +/// +/// // After validation fails: +/// errorFocusTrigger.requestFocus() +/// +public final class AccessibilityFocusTrigger { + + /// The type of accessibility notification to post when requesting focus. + public enum Notification { + /// Use for focus changes within the current screen. + case layoutChanged + /// Use for major screen transitions. + case screenChanged + + var uiAccessibilityNotification: UIAccessibility.Notification { + switch self { + case .layoutChanged: + return .layoutChanged + case .screenChanged: + return .screenChanged + } + } + } + + /// The notification type to post when focus is requested. + public let notification: Notification + + /// Creates a new trigger, not yet bound to any view. + /// - Parameter notification: The type of accessibility notification to post. Defaults to `.layoutChanged`. + public init(notification: Notification = .layoutChanged) { + self.notification = notification + } + + /// Bound by the backing view during apply(). + /// The closure posts `UIAccessibility.post(notification:argument:)` + /// targeting the bound view. + var action: (() -> Void)? + + /// Moves VoiceOver focus to the bound backing view. + /// No-op if VoiceOver is not running or trigger is unbound. + public func requestFocus() { + action?() + } + +} diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusableElement.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusableElement.swift new file mode 100644 index 000000000..7ab8556a3 --- /dev/null +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusableElement.swift @@ -0,0 +1,60 @@ +import BlueprintUI +import UIKit + +/// A wrapping element that binds an `AccessibilityFocusTrigger` to a backing view, +/// enabling VoiceOver focus to be programmatically moved to the wrapped element. +struct AccessibilityFocusableElement: Element { + + var wrapped: Element + var trigger: AccessibilityFocusTrigger + + // MARK: Element + + var content: ElementContent { + ElementContent(child: wrapped) + } + + func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { + BackingView.describe { config in + config.apply { view in + view.apply(trigger: self.trigger) + } + } + } +} + +// MARK: - Backing View + +extension AccessibilityFocusableElement { + + private final class BackingView: UIView { + + private var currentTrigger: AccessibilityFocusTrigger? + + deinit { + currentTrigger?.action = nil + } + + func apply(trigger: AccessibilityFocusTrigger) { + // Tear down old trigger binding. + currentTrigger?.action = nil + + currentTrigger = trigger + + // Bind the new trigger to this view. + let notification = trigger.notification + trigger.action = { [weak self] in + guard let self, UIAccessibility.isVoiceOverRunning else { return } + UIAccessibility.post( + notification: notification.uiAccessibilityNotification, + argument: self + ) + } + } + + override var isAccessibilityElement: Bool { + get { false } + set {} + } + } +} diff --git a/BlueprintUIAccessibilityCore/Sources/Element+AccessibilityFocus.swift b/BlueprintUIAccessibilityCore/Sources/Element+AccessibilityFocus.swift new file mode 100644 index 000000000..c1c2bbd64 --- /dev/null +++ b/BlueprintUIAccessibilityCore/Sources/Element+AccessibilityFocus.swift @@ -0,0 +1,20 @@ +import BlueprintUI + +extension Element { + + /// Binds an `AccessibilityFocusTrigger` to this element. + /// + /// When `trigger.requestFocus()` is called, VoiceOver focus + /// moves to this element's backing view. + /// + /// - Parameter trigger: A trigger that can later be used to move VoiceOver focus to this element. + /// - Returns: A wrapping element that provides a backing view for VoiceOver focus. + public func accessibilityFocus( + trigger: AccessibilityFocusTrigger + ) -> Element { + AccessibilityFocusableElement( + wrapped: self, + trigger: trigger + ) + } +} diff --git a/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift b/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift new file mode 100644 index 000000000..d1c925849 --- /dev/null +++ b/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift @@ -0,0 +1,85 @@ +import BlueprintUI +import XCTest +@testable import BlueprintUIAccessibilityCore + +final class AccessibilityFocusTriggerTests: XCTestCase { + + // MARK: - Trigger + + func test_unbound_requestFocus_is_noop() { + let trigger = AccessibilityFocusTrigger() + // Should not crash or have any effect. + trigger.requestFocus() + } + + func test_bound_requestFocus_invokes_action() { + let trigger = AccessibilityFocusTrigger() + + var didInvoke = false + trigger.action = { + didInvoke = true + } + + trigger.requestFocus() + XCTAssertTrue(didInvoke) + } + + func test_action_is_cleared_on_rebind() { + let trigger = AccessibilityFocusTrigger() + + var invokeCount = 0 + trigger.action = { + invokeCount += 1 + } + + trigger.requestFocus() + XCTAssertEqual(invokeCount, 1) + + // Simulate rebinding (as would happen when a new backing view takes over). + trigger.action = nil + + trigger.requestFocus() + XCTAssertEqual(invokeCount, 1, "Action should not fire after being cleared") + } + + func test_default_notification_is_layoutChanged() { + let trigger = AccessibilityFocusTrigger() + switch trigger.notification { + case .layoutChanged: + break // Expected + case .screenChanged: + XCTFail("Expected default notification to be .layoutChanged") + } + } + + func test_screenChanged_notification() { + let trigger = AccessibilityFocusTrigger(notification: .screenChanged) + switch trigger.notification { + case .screenChanged: + break // Expected + case .layoutChanged: + XCTFail("Expected notification to be .screenChanged") + } + } + + // MARK: - Element modifier + + func test_accessibilityFocus_modifier_wraps_element() { + let trigger = AccessibilityFocusTrigger() + let base = TestElement() + let wrapped = base.accessibilityFocus(trigger: trigger) + XCTAssertTrue(wrapped is AccessibilityFocusableElement) + } +} + +// MARK: - Helpers + +private struct TestElement: Element { + var content: ElementContent { + ElementContent(intrinsicSize: CGSize(width: 100, height: 44)) + } + + func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { + nil + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index bace273e7..2d5eee72e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `AccessibilityFocusTrigger` and `Element.accessibilityFocus(trigger:)` for programmatic VoiceOver focus management. + ### Removed ### Changed diff --git a/Package.swift b/Package.swift index b122d6e25..3a54000db 100644 --- a/Package.swift +++ b/Package.swift @@ -69,6 +69,11 @@ let package = Package( .process("Resources"), ], ), + .testTarget( + name: "BlueprintUIAccessibilityCoreTests", + dependencies: ["BlueprintUIAccessibilityCore", "BlueprintUI"], + path: "BlueprintUIAccessibilityCore/Tests" + ), ], swiftLanguageModes: [.v5] ) diff --git a/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift new file mode 100644 index 000000000..ee43753d3 --- /dev/null +++ b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift @@ -0,0 +1,145 @@ +import BlueprintUI +import BlueprintUIAccessibilityCore +import BlueprintUICommonControls +import UIKit + +final class AccessibilityFocusTriggerViewController: UIViewController { + + private let blueprintView = BlueprintView() + + private enum DemoState { + case idle, loading, result + } + + private var transferState: DemoState = .idle { + didSet { update() } + } + + private let resultTrigger = AccessibilityFocusTrigger() + private let layoutChangeTrigger = AccessibilityFocusTrigger(notification: .layoutChanged) + private let screenChangeTrigger = AccessibilityFocusTrigger(notification: .screenChanged) + + override func loadView() { + view = blueprintView + view.backgroundColor = .systemBackground + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "AccessibilityFocusTrigger" + update() + } + + private func update() { + blueprintView.element = element + } + + var element: Element { + Column(alignment: .fill, minimumSpacing: 24) { + + // MARK: - Section: Layout changed + + sectionHeader("Layout Changed (.layoutChanged)") + + Label(text: "Focuses this label with .layoutChanged") { label in + label.font = .systemFont(ofSize: 16) + label.color = .secondaryLabel + } + .accessibilityFocus(trigger: layoutChangeTrigger) + + Button( + onTap: { + self.layoutChangeTrigger.requestFocus() + }, + wrapping: buttonLabel("Trigger Layout Changed") + ) + + separator() + + // MARK: - Section: Screen changed + + sectionHeader("Screen Changed (.screenChanged)") + + Label(text: "Focuses this label with .screenChanged") { label in + label.font = .systemFont(ofSize: 16) + label.color = .secondaryLabel + } + .accessibilityFocus(trigger: screenChangeTrigger) + + Button( + onTap: { + self.screenChangeTrigger.requestFocus() + }, + wrapping: buttonLabel("Trigger Screen Changed") + ) + + separator() + + // MARK: - Section: Focus after async operation + + sectionHeader("Focus After Async Operation") + + if transferState == .result { + Label(text: "Transfer complete! $42.00 sent.") { label in + label.font = .systemFont(ofSize: 16, weight: .medium) + label.color = .systemGreen + } + .accessibilityFocus(trigger: resultTrigger) + } + + Button( + isEnabled: transferState != .loading, + onTap: { + switch self.transferState { + case .idle: + self.transferState = .loading + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.transferState = .result + DispatchQueue.main.async { + self.resultTrigger.requestFocus() + } + } + case .result: + self.transferState = .idle + case .loading: + break + } + }, + wrapping: buttonLabel( + transferState == .idle ? "Send Transfer" : transferState == .loading ? "Sending…" : "Reset", + color: transferState == .loading ? .systemGray : transferState == .result ? .systemGray : .systemBlue + ) + ) + } + .inset(uniform: 20) + .scrollable(.fittingHeight) { scrollView in + scrollView.alwaysBounceVertical = true + } + } + + // MARK: - Helpers + + private func sectionHeader(_ text: String) -> Element { + Label(text: text) { label in + label.font = .systemFont(ofSize: 20, weight: .bold) + label.color = .label + } + } + + private func buttonLabel(_ text: String, color: UIColor = .systemBlue) -> Element { + Label(text: text) { label in + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.color = color + } + .inset(by: UIEdgeInsets(top: 12, left: 20, bottom: 12, right: 20)) + .box( + background: color.withAlphaComponent(0.12), + corners: .rounded(radius: 10) + ) + } + + private func separator() -> Element { + Box(backgroundColor: .separator) + .constrainedTo(height: .absolute(1.0 / UITraitCollection.current.displayScale)) + } +} diff --git a/SampleApp/Sources/RootViewController.swift b/SampleApp/Sources/RootViewController.swift index 47057290e..3873d8427 100644 --- a/SampleApp/Sources/RootViewController.swift +++ b/SampleApp/Sources/RootViewController.swift @@ -12,6 +12,9 @@ final class RootViewController: UIViewController { DemoItem(title: "Accessibility", onTap: { [weak self] in self?.push(AccessibilityViewController()) }), + DemoItem(title: "Accessibility Focus Trigger", onTap: { [weak self] in + self?.push(AccessibilityFocusTriggerViewController()) + }), DemoItem(title: "Text Links", onTap: { [weak self] in self?.push(TextLinkViewController()) }), diff --git a/SampleApp/Tuist/ProjectDescriptionHelpers/Project+Blueprint.swift b/SampleApp/Tuist/ProjectDescriptionHelpers/Project+Blueprint.swift index 789111f73..d8c668936 100644 --- a/SampleApp/Tuist/ProjectDescriptionHelpers/Project+Blueprint.swift +++ b/SampleApp/Tuist/ProjectDescriptionHelpers/Project+Blueprint.swift @@ -8,6 +8,7 @@ public let blueprintDeploymentTargets: DeploymentTargets = .iOS("15.0") public let blueprintDependencies: [TargetDependency] = [ .external(name: "BlueprintUI"), .external(name: "BlueprintUICommonControls"), + .external(name: "BlueprintUIAccessibilityCore"), ] extension String {