From 955794c19108ca5e1c6665beb1a756b4cc35b76d Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Wed, 1 Apr 2026 18:54:17 +0200 Subject: [PATCH] Add AccessibilityFocusTrigger for programmatic VoiceOver focus Introduces a late-binding trigger that moves VoiceOver focus to a Blueprint element's backing view. Includes an Element modifier, backing view integration, unit tests, and a sample app demo. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AccessibilityFocusTrigger.swift | 66 ++++++ .../AccessibilityFocusableElement.swift | 55 +++++ .../Element/Element+AccessibilityFocus.swift | 18 ++ .../AccessibilityFocusTriggerTests.swift | 84 ++++++++ ...essibilityFocusTriggerViewController.swift | 191 ++++++++++++++++++ SampleApp/Sources/RootViewController.swift | 3 + 6 files changed, 417 insertions(+) create mode 100644 BlueprintUI/Sources/Accessibility/AccessibilityFocusTrigger.swift create mode 100644 BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift create mode 100644 BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift create mode 100644 BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift create mode 100644 SampleApp/Sources/AccessibilityFocusTriggerViewController.swift diff --git a/BlueprintUI/Sources/Accessibility/AccessibilityFocusTrigger.swift b/BlueprintUI/Sources/Accessibility/AccessibilityFocusTrigger.swift new file mode 100644 index 000000000..9bdbafa7a --- /dev/null +++ b/BlueprintUI/Sources/Accessibility/AccessibilityFocusTrigger.swift @@ -0,0 +1,66 @@ +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?() + } + + /// Posts a VoiceOver announcement without focusing a specific view. + /// No-op if VoiceOver is not running. + /// - Parameter message: The string to announce. + public func announce(_ message: String) { + guard UIAccessibility.isVoiceOverRunning else { return } + UIAccessibility.post(notification: .announcement, argument: message) + } +} diff --git a/BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift b/BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift new file mode 100644 index 000000000..956181bd1 --- /dev/null +++ b/BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift @@ -0,0 +1,55 @@ +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? + + 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/BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift b/BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift new file mode 100644 index 000000000..f54aa8a65 --- /dev/null +++ b/BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift @@ -0,0 +1,18 @@ +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/BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift b/BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift new file mode 100644 index 000000000..82af29600 --- /dev/null +++ b/BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift @@ -0,0 +1,84 @@ +import BlueprintUI +import XCTest + +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/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift new file mode 100644 index 000000000..b22caffb5 --- /dev/null +++ b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift @@ -0,0 +1,191 @@ +import BlueprintUI +import BlueprintUICommonControls +import UIKit + +final class AccessibilityFocusTriggerViewController: UIViewController { + + private let blueprintView = BlueprintView() + + private enum DemoState { + case idle, loading, result + } + + private var errorState: DemoState = .idle { + didSet { update() } + } + + private var transferState: DemoState = .idle { + didSet { update() } + } + + private let errorTrigger = AccessibilityFocusTrigger() + 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: Focus on error + + sectionHeader("Focus on Error Message") + + if errorState == .result { + Label(text: "Invalid amount entered.") { label in + label.font = .systemFont(ofSize: 16, weight: .medium) + label.color = .systemRed + } + .accessibilityFocus(trigger: errorTrigger) + } + + switch errorState { + case .idle: + Button( + onTap: { + self.errorState = .loading + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.errorState = .result + DispatchQueue.main.async { + self.errorTrigger.requestFocus() + } + } + }, + wrapping: buttonLabel("Validate Amount") + ) + case .loading: + buttonLabel("Validating…", color: .systemGray) + .opacity(0.6) + case .result: + Button( + onTap: { + self.errorState = .idle + }, + wrapping: buttonLabel("Reset", color: .systemGray) + ) + } + + 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) + } + + switch transferState { + case .idle: + Button( + onTap: { + self.transferState = .loading + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.transferState = .result + DispatchQueue.main.async { + self.resultTrigger.requestFocus() + } + } + }, + wrapping: buttonLabel("Send Transfer") + ) + case .loading: + buttonLabel("Sending…", color: .systemGray) + .opacity(0.6) + case .result: + Button( + onTap: { + self.transferState = .idle + }, + wrapping: buttonLabel("Reset", color: .systemGray) + ) + } + + separator() + + // 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") + ) + } + .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 / UIScreen.main.scale)) + } +} 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()) }),