flutter / flutter/flutter

FKA Tab navigation does not dispatch `onDidGainAccessibilityFocus` / `SemanticsAction.didGainAccessibilityFocus` to the Dart layer

Open
#183,415 2 comments 1 reaction 0 assignees View on GitHub
a: accessibility has reproducible steps P2 platform-ios team-ios triaged-ios
Dominant language
Dart
Stars
179k
Forks
31.1k
PR merge metrics
PR metrics pending

Description

### Steps to reproduce

1. Run any Flutter app on a physical iPhone (iOS 15+) with an external keyboard connected.
2. Enable **Settings → Accessibility → Full Keyboard Access**.
3. Wrap a widget with `Semantics.onDidGainAccessibilityFocus`:

```dart
Semantics(
onDidGainAccessibilityFocus: () {
debugPrint('Focus gained!');
},
child: ElevatedButton(
onPressed: () {},
child: const Text('Press me'),
),
)
```

4. Tab through the UI using the physical keyboard.
5. Press Space on the focused widget to activate it.
6. Repeat steps 4–5 with VoiceOver enabled instead of FKA.

### Expected results

`onDidGainAccessibilityFocus` should fire each time Tab moves the FKA focus ring to a new widget, matching the existing VoiceOver behavior where it fires on every cursor movement.

### Actual results

- **FKA (Tab):** `onDidGainAccessibilityFocus` is **never called**. The focus ring moves visually and Space correctly triggers `onPressed`, but no Dart-side focus notification is ever dispatched.
- **FKA (Space):** `onPressed` fires, but `onDidGainAccessibilityFocus` still does not.
- **VoiceOver:** `onDidGainAccessibilityFocus` fires correctly on every swipe. ✅

This makes it impossible to react to FKA focus changes in Dart — including calling `Scrollable.ensureVisible`, updating focus indicators, or any other focus-driven logic — for all FKA users on iOS 15+.

### Code sample

Code sample

```dart
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: ListView(
children: List.generate(20, (i) =>
Semantics(
onDidGainAccessibilityFocus: () {
// Never called when tabbing with FKA.
// Called correctly when using VoiceOver.
debugPrint('Focus gained on item $i');
},
child: Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton(
onPressed: () {},
child: Text('Item $i'),
),
),
),
),
),
),
);
}
}
```

### Screenshots or Video

### Logs

Root cause + proposed fix

**Root cause:** VoiceOver and FKA use two entirely different iOS APIs. VoiceOver triggers `accessibilityElementDidBecomeFocused` (UIAccessibility), which the Flutter accessibility bridge already intercepts and forwards to Dart via `DispatchSemanticsAction`:
```objc
// EXISTING — works today (accessibility_bridge.mm)
- (void)accessibilityElementDidBecomeFocused {
[self bridge]->DispatchSemanticsAction(
self.uid, kFlutterSemanticsActionDidGainAccessibilityFocus
);
}
```

FKA uses the separate `UIFocusEngine` system. PR flutter/engine#55964 correctly exposed semantic nodes as `UIFocusItem`s, but `didUpdateFocusInContext:withAnimationCoordinator:` never calls `DispatchSemanticsAction`. The focus change is tracked internally in the engine (for auto-scroll in flutter/engine#56606) but never reaches Dart.

**Proposed fix** — mirror the VoiceOver path inside `FlutterFocusableSemanticObject`:
```objc
- (void)didUpdateFocusInContext:(UIFocusUpdateContext*)context
withAnimationCoordinator:(UIFocusAnimationCoordinator*)coordinator {
// ... existing auto-scroll logic ...

if (context.nextFocusedItem == self) {
[self bridge]->DispatchSemanticsAction(
self.uid, kFlutterSemanticsActionDidGainAccessibilityFocus
);
}
if (context.previouslyFocusedItem == self) {
[self bridge]->DispatchSemanticsAction(
self.uid, kFlutterSemanticsActionDidLoseAccessibilityFocus
);
}
}
```

**Implementation caveats:**
- ⚠️ **Frame-timing** — dispatching a `SemanticsAction` mid-frame can cause an illegal `setState`. The web engine hit this exact issue in #162472 and required a frame-delay guard. The same protection should apply here.
- ⚠️ **Deduplication** — VoiceOver and FKA can be simultaneously active. A guard is needed to avoid firing `didGainAccessibilityFocus` twice for the same focus movement.

**Related:** flutter/engine#55964, flutter/engine#56606, #76497, #166683, #162472

### Flutter Doctor output

Doctor output

```console
[✓] Flutter (Channel stable, 3.41.4, on macOS 26.3.1 25D2128 darwin-arm64, locale en-UY) [264ms]
• Flutter version 3.41.4 on channel stable at /Users/cgronrroz/dev/flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision ff37bef603 (6 days ago), 2026-03-03 16:03:22 -0800
• Engine revision e4b8dca3f1
• Dart version 3.11.1
• DevTools version 2.54.1
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration

[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [1,098ms]
• Android SDK at /Users/cgronrroz/Library/Android/sdk
• Emulator version 36.4.9.0 (build_id 14788078) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14018985-b1038.68)
• All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 26.3) [718ms]
• Xcode at /Applications/Xcode.app/Contents/Developer
• Build 17C529
• CocoaPods version 1.16.2

[✓] Chrome - develop for the web [6ms]
• Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.