From abf3b3f57cc2c68b28b5cfe01b60455b9b898fa8 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Wed, 22 Jul 2026 19:34:11 +0200 Subject: [PATCH] chore: Add an iOS example --- Cargo.lock | 1 + adapters/ios/Cargo.toml | 13 + adapters/ios/README.md | 8 + adapters/ios/examples/apple/.gitignore | 2 + .../examples/apple/build_with_cargo.bash | 35 +- adapters/ios/examples/apple/project.yml | 30 ++ adapters/ios/examples/showcase.rs | 381 ++++++++++++++++++ adapters/winit/examples/apple/project.yml | 14 +- example_common/src/lib.rs | 13 +- 9 files changed, 464 insertions(+), 33 deletions(-) create mode 100644 adapters/ios/examples/apple/.gitignore rename adapters/{winit => ios}/examples/apple/build_with_cargo.bash (60%) create mode 100644 adapters/ios/examples/apple/project.yml create mode 100644 adapters/ios/examples/showcase.rs diff --git a/Cargo.lock b/Cargo.lock index cc978d725..5edff7751 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,7 @@ version = "0.2.1" dependencies = [ "accesskit", "accesskit_consumer", + "example_common", "hashbrown", "objc2 0.5.2", "objc2-foundation 0.2.2", diff --git a/adapters/ios/Cargo.toml b/adapters/ios/Cargo.toml index ae6385c95..9886fadf1 100644 --- a/adapters/ios/Cargo.toml +++ b/adapters/ios/Cargo.toml @@ -8,6 +8,7 @@ categories.workspace = true keywords = ["gui", "ui", "accessibility"] repository.workspace = true readme = "README.md" +exclude = ["examples"] edition.workspace = true rust-version.workspace = true @@ -41,3 +42,15 @@ objc2-ui-kit = { version = "0.2.0", features = [ "UIViewController", "UIWindow", ] } + +[dev-dependencies] +example_common = { path = "../../example_common" } +objc2-foundation = { version = "0.2.0", features = ["NSObjCRuntime", "NSRunLoop"] } +objc2-ui-kit = { version = "0.2.0", features = [ + "UIApplication", + "UIColor", + "UIScene", + "UISceneOptions", + "UISceneSession", + "UIWindowScene", +] } diff --git a/adapters/ios/README.md b/adapters/ios/README.md index 69b34ea96..7d855adc9 100644 --- a/adapters/ios/README.md +++ b/adapters/ios/README.md @@ -2,6 +2,14 @@ This is the iOS adapter for [AccessKit](https://accesskit.dev/). It exposes an AccessKit accessibility tree through the UIKit `UIAccessibility` protocol. +## Example + +The `examples/` directory contains one runnable example: + +- `showcase` — a UIKit application that owns its `UIView` subclass and drives the low-level `Adapter` directly. + +Install [XcodeGen](https://github.com/yonaskolb/XcodeGen) and the iOS Rust targets, then run `xcodegen` from `examples/apple/` to generate the Xcode project. Open it in Xcode and build/run the `Showcase` target on a device or simulator. + ## Acknowledgements This project was funded through the [NGI0 Commons Fund](https://nlnet.nl/commonsfund), a fund established by [NLnet](https://nlnet.nl/). See the [project page](https://nlnet.nl/project/AccessKit-iOS/) for more information. diff --git a/adapters/ios/examples/apple/.gitignore b/adapters/ios/examples/apple/.gitignore new file mode 100644 index 000000000..01c9df8ac --- /dev/null +++ b/adapters/ios/examples/apple/.gitignore @@ -0,0 +1,2 @@ +# Generated by xcodegen +Info.plist diff --git a/adapters/winit/examples/apple/build_with_cargo.bash b/adapters/ios/examples/apple/build_with_cargo.bash similarity index 60% rename from adapters/winit/examples/apple/build_with_cargo.bash rename to adapters/ios/examples/apple/build_with_cargo.bash index 4b1f06f74..e6edb7822 100755 --- a/adapters/winit/examples/apple/build_with_cargo.bash +++ b/adapters/ios/examples/apple/build_with_cargo.bash @@ -4,6 +4,9 @@ set -eux : "${SRCROOT:?}" "${DERIVED_FILE_DIR:?}" "${TARGET_BUILD_DIR:?}" "${EXECUTABLE_PATH:?}" "${ARCHS:?}" "${PLATFORM_NAME:?}" export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH:$HOME/.cargo/bin" +example="$1" +shift + if [[ "$CONFIGURATION" != "Debug" ]]; then CARGO_PROFILE=release cargo_args=(--release) @@ -16,16 +19,8 @@ fi export CARGO_TARGET_DIR="$DERIVED_FILE_DIR/cargo" case "$PLATFORM_NAME" in - iphoneos) CARGO_OS=ios; BUILD_KIND=device ;; - iphonesimulator) CARGO_OS=ios; BUILD_KIND=simulator ;; - macosx) - if [[ "${IS_MACCATALYST:-NO}" != "YES" ]]; then - echo "non-Catalyst macOS builds are not supported" >&2 - exit 1 - fi - CARGO_OS=ios - BUILD_KIND=catalyst - ;; + iphoneos) BUILD_KIND=device ;; + iphonesimulator) BUILD_KIND=simulator ;; *) echo "unsupported platform: $PLATFORM_NAME" >&2 exit 1 @@ -51,30 +46,24 @@ for arch in $ARCHS; do echo "x86_64 is not valid for device builds" >&2 exit 1 fi - CARGO_TARGET="${RUST_ARCH}-apple-${CARGO_OS}" + CARGO_TARGET="${RUST_ARCH}-apple-ios" ;; simulator) + # Rust names the x86_64 simulator target without a -sim suffix. if [[ "$RUST_ARCH" = "x86_64" ]]; then - # Rust names the x86_64 simulator target without a -sim suffix, - # but clang still needs the -simulator triple. - CARGO_TARGET="${RUST_ARCH}-apple-${CARGO_OS}" - export "CFLAGS_${RUST_ARCH}_apple_${CARGO_OS}=-target ${RUST_ARCH}-apple-${CARGO_OS}-simulator" + CARGO_TARGET="${RUST_ARCH}-apple-ios" else - CARGO_TARGET="${RUST_ARCH}-apple-${CARGO_OS}-sim" + CARGO_TARGET="${RUST_ARCH}-apple-ios-sim" fi ;; - catalyst) - CARGO_TARGET="${RUST_ARCH}-apple-ios-macabi" - ;; esac cargo build ${cargo_args[@]+"${cargo_args[@]}"} \ --target "$CARGO_TARGET" \ - --example "$1" \ - --no-default-features --features rwh_06 + --example "$example" \ + "$@" - executables+=("$DERIVED_FILE_DIR/cargo/$CARGO_TARGET/$CARGO_PROFILE/examples/$1") + executables+=("$DERIVED_FILE_DIR/cargo/$CARGO_TARGET/$CARGO_PROFILE/examples/$example") done lipo -create -output "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" "${executables[@]}" - diff --git a/adapters/ios/examples/apple/project.yml b/adapters/ios/examples/apple/project.yml new file mode 100644 index 000000000..610189e18 --- /dev/null +++ b/adapters/ios/examples/apple/project.yml @@ -0,0 +1,30 @@ +name: AccessKit iOS Examples +options: + bundleIdPrefix: dev.accesskit +settings: + ENABLE_USER_SCRIPT_SANDBOXING: NO +targets: + Showcase: + type: application + platform: iOS + deploymentTarget: "15.0" + info: + path: Info.plist + properties: + CFBundleDisplayName: AccessKit Example application + UILaunchScreen: + ImageRespectSafeAreaInsets: false + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: false + UISceneConfigurations: + UIWindowSceneSessionRoleApplication: + - UISceneConfigurationName: Default Configuration + UISceneDelegateClassName: AccessKitExampleSceneDelegate + settings: + PRODUCT_BUNDLE_IDENTIFIER: dev.accesskit.showcase + sources: [] + postCompileScripts: + - script: | + ./build_with_cargo.bash showcase + outputFiles: + - $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH) diff --git a/adapters/ios/examples/showcase.rs b/adapters/ios/examples/showcase.rs new file mode 100644 index 000000000..ab1a2d13e --- /dev/null +++ b/adapters/ios/examples/showcase.rs @@ -0,0 +1,381 @@ +use accesskit::{ + ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, Size, TreeUpdate, Vec2, +}; +use accesskit_ios::Adapter; +use example_common::UiState; +use objc2::{ + ClassType, DeclaredClass, declare_class, msg_send, msg_send_id, + mutability::MainThreadOnly, + rc::{Allocated, Retained, WeakId}, + runtime::{AnyObject, Bool, NSObjectProtocol, Sel}, + sel, +}; +use objc2_foundation::{ + CGPoint, CGRect, MainThreadMarker, NSArray, NSCopying, NSDictionary, NSObject, + NSRunLoopCommonModes, NSString, +}; +use objc2_ui_kit::{ + UIApplication, UIApplicationDelegate, UIApplicationLaunchOptionsKey, UIApplicationMain, + UIColor, UIResponder, UIScene, UISceneConnectionOptions, UISceneDelegate, UISceneSession, + UIView, UIViewAutoresizing, UIViewController, UIWindow, UIWindowScene, UIWindowSceneDelegate, +}; +use std::{ + cell::RefCell, + ffi::{CString, c_char, c_int, c_void}, + ptr::{NonNull, null_mut}, +}; + +// The handlers only hold weak references to the view, which owns the adapter, +// which owns the handlers. +struct ShowcaseActivationHandler { + view: WeakId, +} + +impl ActivationHandler for ShowcaseActivationHandler { + fn request_initial_tree(&mut self) -> Option { + let view = self.view.load()?; + view.refresh_viewport(); + Some(view.ivars().ui.borrow_mut().build_tree_update()) + } +} + +struct ShowcaseActionHandler { + view: WeakId, +} + +impl ActionHandler for ShowcaseActionHandler { + fn do_action(&mut self, request: ActionRequest) { + let Some(view) = self.view.load() else { + return; + }; + // The adapter is handling a UIKit call, so the tree can't be updated + // until the run loop comes back around. + let mut pending_actions = view.ivars().pending_actions.borrow_mut(); + let schedule = pending_actions.is_empty(); + pending_actions.push(request); + drop(pending_actions); + if schedule { + view.perform_after_delay(sel!(processPendingActions), 0.0); + } + } +} + +struct ShowcaseDeactivationHandler { + view: WeakId, +} + +impl DeactivationHandler for ShowcaseDeactivationHandler { + fn deactivate_accessibility(&mut self) { + if let Some(view) = self.view.load() { + view.ivars().ui.borrow_mut().deactivated(); + } + } +} + +struct ContentViewIvars { + // Created once the view it points to is initialized. + adapter: RefCell>, + ui: RefCell, + pending_actions: RefCell>, +} + +declare_class!( + struct ContentView; + + unsafe impl ClassType for ContentView { + type Super = UIView; + type Mutability = MainThreadOnly; + const NAME: &'static str = "AccessKitExampleContentView"; + } + + impl DeclaredClass for ContentView { + type Ivars = ContentViewIvars; + } + + unsafe impl ContentView { + #[method(isAccessibilityElement)] + fn is_accessibility_element(&self) -> Bool { + match &*self.ivars().adapter.borrow() { + Some(adapter) => Bool::new(adapter.is_accessibility_element()), + None => Bool::NO, + } + } + + #[method(accessibilityElements)] + fn accessibility_elements(&self) -> *mut NSArray { + match &*self.ivars().adapter.borrow() { + Some(adapter) => adapter.accessibility_elements(), + None => Retained::autorelease_return(NSArray::new()), + } + } + + #[method(accessibilityHitTest:)] + fn accessibility_hit_test(&self, point: CGPoint) -> *mut NSObject { + match &*self.ivars().adapter.borrow() { + Some(adapter) => adapter.hit_test(point), + None => null_mut(), + } + } + + #[method(didMoveToWindow)] + fn did_move_to_window(&self) { + unsafe { + let _: () = msg_send![super(self), didMoveToWindow]; + } + if self.window().is_none() { + return; + } + if self.refresh_viewport() { + self.update(); + } + let events = self.ivars().adapter.borrow().as_ref().and_then(Adapter::view_did_appear); + if let Some(events) = events { + events.raise(); + } + } + + #[method(layoutSubviews)] + fn layout_subviews(&self) { + unsafe { + let _: () = msg_send![super(self), layoutSubviews]; + } + if self.refresh_viewport() { + self.update(); + } + } + + #[method(processPendingActions)] + fn process_pending_actions(&self) { + let requests = self.ivars().pending_actions.take(); + { + let mut ui = self.ivars().ui.borrow_mut(); + for request in &requests { + ui.do_action(request); + } + } + self.after_input(); + } + + #[method(processDueMessages)] + fn process_due_messages(&self) { + let processed = self.ivars().ui.borrow_mut().process_due_messages(); + if processed { + self.update(); + } + self.schedule_next_message(); + } + } +); + +impl ContentView { + fn new(mtm: MainThreadMarker, frame: CGRect) -> Retained { + let this = mtm.alloc::().set_ivars(ContentViewIvars { + adapter: RefCell::new(None), + ui: RefCell::new(UiState::new()), + pending_actions: RefCell::new(Vec::new()), + }); + let this: Retained = unsafe { msg_send_id![super(this), initWithFrame: frame] }; + + unsafe { + this.setBackgroundColor(Some(&UIColor::darkGrayColor())); + this.setAutoresizingMask( + UIViewAutoresizing::FlexibleWidth | UIViewAutoresizing::FlexibleHeight, + ); + } + + let weak = WeakId::from_retained(&this); + // SAFETY: `this` is a valid, retained `UIView`, and it outlives the + // adapter, which is owned by the view itself. + let adapter = unsafe { + Adapter::new( + Retained::as_ptr(&this) as *mut c_void, + ShowcaseActivationHandler { view: weak.clone() }, + ShowcaseActionHandler { view: weak.clone() }, + ShowcaseDeactivationHandler { view: weak }, + ) + }; + *this.ivars().adapter.borrow_mut() = Some(adapter); + + this + } + + /// Returns whether the viewport changed. + fn refresh_viewport(&self) -> bool { + let bounds = self.bounds(); + let insets = self.safeAreaInsets(); + let scale_factor = self.contentScaleFactor(); + self.ivars().ui.borrow_mut().set_viewport( + scale_factor, + Vec2::new(insets.left * scale_factor, insets.top * scale_factor), + Size::new( + (bounds.size.width - insets.left - insets.right).max(0.0), + (bounds.size.height - insets.top - insets.bottom).max(0.0), + ), + ) + } + + fn update(&self) { + // Raising the events may call back into the view, which borrows the + // adapter. + let events = { + let adapter = self.ivars().adapter.borrow(); + match &*adapter { + Some(adapter) => { + adapter.update_if_active(|| self.ivars().ui.borrow_mut().build_tree_update()) + } + None => None, + } + }; + if let Some(events) = events { + events.raise(); + } + } + + fn after_input(&self) { + self.update(); + self.schedule_next_message(); + } + + fn schedule_next_message(&self) { + unsafe { + let _: () = msg_send![ + NSObject::class(), + cancelPreviousPerformRequestsWithTarget: self, + selector: sel!(processDueMessages), + object: null_mut::(), + ]; + } + let Some(delay) = self.ivars().ui.borrow().time_until_next_message() else { + return; + }; + self.perform_after_delay(sel!(processDueMessages), delay.as_secs_f64()); + } + + fn perform_after_delay(&self, selector: Sel, delay: f64) { + let modes = NSArray::from_vec(vec![unsafe { NSRunLoopCommonModes }.copy()]); + unsafe { + let _: () = msg_send![ + self, + performSelector: selector, + withObject: null_mut::(), + afterDelay: delay, + inModes: &*modes, + ]; + } + } +} + +declare_class!( + struct AppDelegate; + + unsafe impl ClassType for AppDelegate { + type Super = UIResponder; + type Mutability = MainThreadOnly; + const NAME: &'static str = "AccessKitExampleAppDelegate"; + } + + impl DeclaredClass for AppDelegate { + type Ivars = (); + } + + unsafe impl NSObjectProtocol for AppDelegate {} + + // The scene delegate, which the Info.plist names, sets up the window. + unsafe impl UIApplicationDelegate for AppDelegate { + #[method(application:didFinishLaunchingWithOptions:)] + fn did_finish_launching( + &self, + _application: &UIApplication, + _launch_options: Option<&NSDictionary>, + ) -> bool { + true + } + } +); + +struct SceneDelegateIvars { + window: RefCell>>, +} + +declare_class!( + struct SceneDelegate; + + unsafe impl ClassType for SceneDelegate { + type Super = UIResponder; + type Mutability = MainThreadOnly; + const NAME: &'static str = "AccessKitExampleSceneDelegate"; + } + + impl DeclaredClass for SceneDelegate { + type Ivars = SceneDelegateIvars; + } + + unsafe impl SceneDelegate { + // UIKit creates the scene delegate. + #[method_id(init)] + fn init(this: Allocated) -> Option> { + let this = this.set_ivars(SceneDelegateIvars { + window: RefCell::new(None), + }); + unsafe { msg_send_id![super(this), init] } + } + } + + unsafe impl NSObjectProtocol for SceneDelegate {} + + unsafe impl UISceneDelegate for SceneDelegate { + #[method(scene:willConnectToSession:options:)] + fn scene_will_connect( + &self, + scene: &UIScene, + _session: &UISceneSession, + _connection_options: &UISceneConnectionOptions, + ) { + let mtm = MainThreadMarker::from(self); + if !scene.isKindOfClass(UIWindowScene::class()) { + return; + } + // SAFETY: We just checked that the scene is a `UIWindowScene`. + let window_scene = + unsafe { &*(scene as *const UIScene as *const UIWindowScene) }; + + let window = unsafe { UIWindow::initWithWindowScene(mtm.alloc(), window_scene) }; + let view_controller = unsafe { UIViewController::init(mtm.alloc()) }; + window.setRootViewController(Some(&view_controller)); + window.makeKeyAndVisible(); + + let root_view = view_controller.view().expect("view controller has no view"); + let content_view = ContentView::new(mtm, root_view.bounds()); + unsafe { root_view.addSubview(&content_view) }; + + *self.ivars().window.borrow_mut() = Some(window); + } + } + + unsafe impl UIWindowSceneDelegate for SceneDelegate {} +); + +fn main() { + // UIKit looks the delegate classes up by name, so they must be registered + // first. + let delegate_class_name = NSString::from_str(AppDelegate::class().name()); + let _ = SceneDelegate::class(); + + let args = std::env::args() + .map(|arg| CString::new(arg).unwrap()) + .collect::>(); + let mut argv = args + .iter() + .map(|arg| arg.as_ptr() as *mut c_char) + .collect::>(); + argv.push(null_mut()); + + unsafe { + UIApplicationMain( + args.len() as c_int, + NonNull::new(argv.as_mut_ptr()).unwrap(), + None, + Some(&delegate_class_name), + ); + } +} diff --git a/adapters/winit/examples/apple/project.yml b/adapters/winit/examples/apple/project.yml index 3bd1175f5..9a6c7ee3c 100644 --- a/adapters/winit/examples/apple/project.yml +++ b/adapters/winit/examples/apple/project.yml @@ -7,30 +7,32 @@ targets: Simple: type: application platform: iOS - deploymentTarget: "18.0" + deploymentTarget: "15.0" info: path: Info.plist properties: UILaunchScreen: - - ImageRespectSafeAreaInsets: false + ImageRespectSafeAreaInsets: false sources: [] postCompileScripts: - script: | - ./build_with_cargo.bash simple + ../../../ios/examples/apple/build_with_cargo.bash simple \ + --no-default-features --features rwh_06 outputFiles: - $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH) MixedHandlers: type: application platform: iOS - deploymentTarget: "18.0" + deploymentTarget: "15.0" info: path: Info.plist properties: UILaunchScreen: - - ImageRespectSafeAreaInsets: false + ImageRespectSafeAreaInsets: false sources: [] postCompileScripts: - script: | - ./build_with_cargo.bash mixed_handlers + ../../../ios/examples/apple/build_with_cargo.bash mixed_handlers \ + --no-default-features --features rwh_06 outputFiles: - $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH) diff --git a/example_common/src/lib.rs b/example_common/src/lib.rs index 6c3a02928..cc3b293b7 100644 --- a/example_common/src/lib.rs +++ b/example_common/src/lib.rs @@ -174,13 +174,18 @@ impl UiState { } /// Sets the area of the window the user interface occupies: its logical - /// size, and its offset in physical pixels. - pub fn set_viewport(&mut self, scale_factor: f64, safe_area_inset: Vec2, size: Size) { - self.dispatch(Message::ViewportChanged(Viewport { + /// size, and its offset in physical pixels. Returns whether it changed. + pub fn set_viewport(&mut self, scale_factor: f64, safe_area_inset: Vec2, size: Size) -> bool { + let viewport = Viewport { scale_factor, safe_area_inset, size, - })); + }; + if self.model.viewport == Some(viewport) { + return false; + } + self.dispatch(Message::ViewportChanged(viewport)); + true } /// Builds an update for the AccessKit adapter: the whole tree when the