From 98638d56b9b89b4dd78845f810026ad2214204de Mon Sep 17 00:00:00 2001 From: takumatt Date: Tue, 31 Mar 2026 11:28:18 +0900 Subject: [PATCH 1/2] Detect activatable accessibility elements --- README.md | 4 +- Sources/Vimulator/AccessibilityScanner.swift | 44 ++++++++++++++++++- .../AccessibilityScannerTests.swift | 19 ++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7de0e36..b6fd213 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Holding a scroll key scrolls continuously at 600 pt/sec. ```swift dependencies: [ - .package(url: "https://github.com/takumatt/vimulator", from: "0.1.0") + .package(url: "https://github.com/takumatt/vimulator", from: "0.3.0") ] ``` @@ -140,7 +140,7 @@ Vimulator.shared.appearAnimation = .fade(duration: 0.3) ## How it works - **Keyboard monitoring** — swizzles `UIApplication.sendEvent` to intercept hardware key events globally -- **Hint mode** — walks the UIAccessibility tree to collect interactive elements, renders a transparent `UIWindow` overlay with hint labels +- **Hint mode** — walks the UIAccessibility tree to collect interactive elements and elements with custom accessibility activation, then renders a transparent `UIWindow` overlay with hint labels - **Activation** — calls `accessibilityActivate()` on the target element; falls back to `becomeFirstResponder()` for text inputs and `UIControl.sendActions` for controls - **Scrolling** — uses `CADisplayLink` to move `contentOffset` at a fixed velocity per frame, with `adjustedContentInset`-aware clamping diff --git a/Sources/Vimulator/AccessibilityScanner.swift b/Sources/Vimulator/AccessibilityScanner.swift index 906f88d..52a7ed0 100644 --- a/Sources/Vimulator/AccessibilityScanner.swift +++ b/Sources/Vimulator/AccessibilityScanner.swift @@ -1,4 +1,5 @@ #if targetEnvironment(simulator) +import ObjectiveC.runtime import UIKit enum AccessibilityScanner { @@ -40,7 +41,48 @@ enum AccessibilityScanner { private static func isInteractive(_ element: NSObject) -> Bool { if let control = element as? UIControl { return control.isEnabled } - return !element.accessibilityTraits.intersection(interactiveTraits).isEmpty + if !element.accessibilityTraits.intersection(interactiveTraits).isEmpty { + return true + } + return hasCustomAccessibilityActivation(element) + } + + private static func hasCustomAccessibilityActivation(_ element: NSObject) -> Bool { + let selector = NSSelectorFromString("accessibilityActivate") + let baselineClass: AnyClass = baselineClass(for: element) + + var currentClass: AnyClass? = object_getClass(element) + while let current = currentClass, current != baselineClass { + guard + let method = class_getInstanceMethod(current, selector), + let superclass = class_getSuperclass(current), + let superMethod = class_getInstanceMethod(superclass, selector) + else { + currentClass = class_getSuperclass(current) + continue + } + + if method_getImplementation(method) != method_getImplementation(superMethod) { + return true + } + + currentClass = superclass + } + + return false + } + + private static func baselineClass(for element: NSObject) -> AnyClass { + if element is UIView { + return UIView.self + } + + if let accessibilityElementClass = NSClassFromString("UIAccessibilityElement"), + element.isKind(of: accessibilityElementClass) { + return accessibilityElementClass + } + + return NSObject.self } } #endif diff --git a/Tests/VimulatorTests/AccessibilityScannerTests.swift b/Tests/VimulatorTests/AccessibilityScannerTests.swift index 486763f..c861ceb 100644 --- a/Tests/VimulatorTests/AccessibilityScannerTests.swift +++ b/Tests/VimulatorTests/AccessibilityScannerTests.swift @@ -47,6 +47,19 @@ final class AccessibilityScannerTests: XCTestCase { XCTAssertEqual(results.count, 1) } + func testFindsElementsWithCustomAccessibilityActivation() { + let root = UIView(frame: CGRect(x: 0, y: 0, width: 375, height: 812)) + let activatable = ActivatableLabel(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + activatable.isAccessibilityElement = true + activatable.accessibilityTraits = .staticText + root.addSubview(activatable) + + let results = AccessibilityScanner.scan(in: root) + + XCTAssertEqual(results.count, 1) + XCTAssertTrue(results.first === activatable) + } + // MARK: - Performance func testPerformanceShallowHierarchy() { @@ -101,4 +114,10 @@ final class AccessibilityScannerTests: XCTestCase { } } } + +private final class ActivatableLabel: UIView { + override func accessibilityActivate() -> Bool { + true + } +} #endif From 8932372065df1bc0c34cc62700bd2caa0851370d Mon Sep 17 00:00:00 2001 From: takumatt Date: Tue, 31 Mar 2026 11:42:56 +0900 Subject: [PATCH 2/2] Use accessibility interaction for hint targets --- README.md | 2 +- Sources/Vimulator/AccessibilityScanner.swift | 42 +++---------------- .../AccessibilityScannerTests.swift | 23 ++++++---- 3 files changed, 22 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index b6fd213..0b5b5da 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ Vimulator.shared.appearAnimation = .fade(duration: 0.3) ## How it works - **Keyboard monitoring** — swizzles `UIApplication.sendEvent` to intercept hardware key events globally -- **Hint mode** — walks the UIAccessibility tree to collect interactive elements and elements with custom accessibility activation, then renders a transparent `UIWindow` overlay with hint labels +- **Hint mode** — walks the UIAccessibility tree to collect interactive elements and other named elements that opt into accessibility user interaction, then renders a transparent `UIWindow` overlay with hint labels - **Activation** — calls `accessibilityActivate()` on the target element; falls back to `becomeFirstResponder()` for text inputs and `UIControl.sendActions` for controls - **Scrolling** — uses `CADisplayLink` to move `contentOffset` at a fixed velocity per frame, with `adjustedContentInset`-aware clamping diff --git a/Sources/Vimulator/AccessibilityScanner.swift b/Sources/Vimulator/AccessibilityScanner.swift index 52a7ed0..5933215 100644 --- a/Sources/Vimulator/AccessibilityScanner.swift +++ b/Sources/Vimulator/AccessibilityScanner.swift @@ -1,5 +1,4 @@ #if targetEnvironment(simulator) -import ObjectiveC.runtime import UIKit enum AccessibilityScanner { @@ -44,45 +43,14 @@ enum AccessibilityScanner { if !element.accessibilityTraits.intersection(interactiveTraits).isEmpty { return true } - return hasCustomAccessibilityActivation(element) + return element.accessibilityRespondsToUserInteraction && hasAccessibleName(element) } - private static func hasCustomAccessibilityActivation(_ element: NSObject) -> Bool { - let selector = NSSelectorFromString("accessibilityActivate") - let baselineClass: AnyClass = baselineClass(for: element) - - var currentClass: AnyClass? = object_getClass(element) - while let current = currentClass, current != baselineClass { - guard - let method = class_getInstanceMethod(current, selector), - let superclass = class_getSuperclass(current), - let superMethod = class_getInstanceMethod(superclass, selector) - else { - currentClass = class_getSuperclass(current) - continue - } - - if method_getImplementation(method) != method_getImplementation(superMethod) { - return true - } - - currentClass = superclass + private static func hasAccessibleName(_ element: NSObject) -> Bool { + guard let label = element.accessibilityLabel else { + return false } - - return false - } - - private static func baselineClass(for element: NSObject) -> AnyClass { - if element is UIView { - return UIView.self - } - - if let accessibilityElementClass = NSClassFromString("UIAccessibilityElement"), - element.isKind(of: accessibilityElementClass) { - return accessibilityElementClass - } - - return NSObject.self + return !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } #endif diff --git a/Tests/VimulatorTests/AccessibilityScannerTests.swift b/Tests/VimulatorTests/AccessibilityScannerTests.swift index c861ceb..523e68a 100644 --- a/Tests/VimulatorTests/AccessibilityScannerTests.swift +++ b/Tests/VimulatorTests/AccessibilityScannerTests.swift @@ -49,9 +49,11 @@ final class AccessibilityScannerTests: XCTestCase { func testFindsElementsWithCustomAccessibilityActivation() { let root = UIView(frame: CGRect(x: 0, y: 0, width: 375, height: 812)) - let activatable = ActivatableLabel(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + let activatable = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) activatable.isAccessibilityElement = true activatable.accessibilityTraits = .staticText + activatable.accessibilityLabel = "Search" + activatable.accessibilityRespondsToUserInteraction = true root.addSubview(activatable) let results = AccessibilityScanner.scan(in: root) @@ -60,6 +62,19 @@ final class AccessibilityScannerTests: XCTestCase { XCTAssertTrue(results.first === activatable) } + func testIgnoresCustomAccessibilityActivationWithoutLabel() { + let root = UIView(frame: CGRect(x: 0, y: 0, width: 375, height: 812)) + let activatable = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + activatable.isAccessibilityElement = true + activatable.accessibilityTraits = .staticText + activatable.accessibilityRespondsToUserInteraction = true + root.addSubview(activatable) + + let results = AccessibilityScanner.scan(in: root) + + XCTAssertTrue(results.isEmpty) + } + // MARK: - Performance func testPerformanceShallowHierarchy() { @@ -114,10 +129,4 @@ final class AccessibilityScannerTests: XCTestCase { } } } - -private final class ActivatableLabel: UIView { - override func accessibilityActivate() -> Bool { - true - } -} #endif