Skip to content
Closed
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,79 @@
import BlueprintUI
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?()
}

}

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
)
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ let package = Package(
.process("Resources"),
],
),
.testTarget(
name: "BlueprintUIAccessibilityCoreTests",
dependencies: ["BlueprintUIAccessibilityCore", "BlueprintUI"],
path: "BlueprintUIAccessibilityCore/Tests"
),
],
swiftLanguageModes: [.v5]
)
145 changes: 145 additions & 0 deletions SampleApp/Sources/AccessibilityFocusTriggerViewController.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading
Loading