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
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
}
}
}
18 changes: 18 additions & 0 deletions BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
84 changes: 84 additions & 0 deletions BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift
Original file line number Diff line number Diff line change
@@ -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 = {

Check failure on line 18 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 16.2

'action' is inaccessible due to 'internal' protection level

Check failure on line 18 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 17.2

'action' is inaccessible due to 'internal' protection level

Check failure on line 18 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 15.4

'action' is inaccessible due to 'internal' protection level
didInvoke = true
}

trigger.requestFocus()
XCTAssertTrue(didInvoke)
}

func test_action_is_cleared_on_rebind() {
let trigger = AccessibilityFocusTrigger()

var invokeCount = 0
trigger.action = {

Check failure on line 30 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 16.2

'action' is inaccessible due to 'internal' protection level

Check failure on line 30 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 17.2

'action' is inaccessible due to 'internal' protection level

Check failure on line 30 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 15.4

'action' is inaccessible due to 'internal' protection level
invokeCount += 1
}

trigger.requestFocus()
XCTAssertEqual(invokeCount, 1)

// Simulate rebinding (as would happen when a new backing view takes over).
trigger.action = nil

Check failure on line 38 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 16.2

'action' is inaccessible due to 'internal' protection level

Check failure on line 38 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 17.2

'action' is inaccessible due to 'internal' protection level

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)

Check failure on line 70 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 16.2

cannot find type 'AccessibilityFocusableElement' in scope

Check failure on line 70 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 17.2

cannot find type 'AccessibilityFocusableElement' in scope

Check failure on line 70 in BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift

View workflow job for this annotation

GitHub Actions / iOS 15.4

cannot find type 'AccessibilityFocusableElement' in scope
}
}

// MARK: - Helpers

private struct TestElement: Element {
var content: ElementContent {
ElementContent(intrinsicSize: CGSize(width: 100, height: 44))
}

func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? {
nil
}
}
Loading
Loading