Skip to content

Commit 8d42987

Browse files
authored
Record focus in route entry to move a11y focus to the last focused item (#135771)
issue: #97747 engine pr:flutter/engine#47114
1 parent 4727627 commit 8d42987

3 files changed

Lines changed: 141 additions & 0 deletions

File tree

packages/flutter/lib/src/services/binding.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
4343
_initKeyboard();
4444
initLicenses();
4545
SystemChannels.system.setMessageHandler((dynamic message) => handleSystemMessage(message as Object));
46+
SystemChannels.accessibility.setMessageHandler((dynamic message) => _handleAccessibilityMessage(message as Object));
4647
SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage);
4748
SystemChannels.platform.setMethodCallHandler(_handlePlatformMessage);
4849
TextInput.ensureInitialized();
@@ -353,6 +354,21 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
353354
return false;
354355
}
355356

357+
358+
/// Listenable that notifies when the accessibility focus on the system have changed.
359+
final ValueNotifier<int?> accessibilityFocus = ValueNotifier<int?>(null);
360+
361+
Future<void> _handleAccessibilityMessage(Object accessibilityMessage) async {
362+
final Map<String, dynamic> message =
363+
(accessibilityMessage as Map<Object?, Object?>).cast<String, dynamic>();
364+
final String type = message['type'] as String;
365+
switch (type) {
366+
case 'didGainFocus':
367+
accessibilityFocus.value = message['nodeId'] as int;
368+
}
369+
return;
370+
}
371+
356372
Future<dynamic> _handlePlatformMessage(MethodCall methodCall) async {
357373
final String method = methodCall.method;
358374
assert(method == 'SystemChrome.systemUIChange' || method == 'System.requestAppExit');

packages/flutter/lib/src/widgets/navigator.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import 'restoration_properties.dart';
2727
import 'routes.dart';
2828
import 'ticker_provider.dart';
2929

30+
// Duration for delay before refocusing in android so that the focus won't be interrupted.
31+
const Duration _kAndroidRefocusingDelayDuration = Duration(milliseconds: 300);
32+
3033
// Examples can assume:
3134
// typedef MyAppHome = Placeholder;
3235
// typedef MyHomePage = Placeholder;
@@ -372,6 +375,8 @@ abstract class Route<T> {
372375
Future<T?> get popped => _popCompleter.future;
373376
final Completer<T?> _popCompleter = Completer<T?>();
374377

378+
final Completer<T?> _disposeCompleter = Completer<T?>();
379+
375380
/// A request was made to pop this route. If the route can handle it
376381
/// internally (e.g. because it has its own stack of internal state) then
377382
/// return false, otherwise return true (by returning the value of calling
@@ -511,6 +516,7 @@ abstract class Route<T> {
511516
void dispose() {
512517
_navigator = null;
513518
_restorationScopeId.dispose();
519+
_disposeCompleter.complete();
514520
if (kFlutterMemoryAllocationsEnabled) {
515521
MemoryAllocations.instance.dispatchObjectDisposed(object: this);
516522
}
@@ -2940,6 +2946,7 @@ class _RouteEntry extends RouteTransitionRecord {
29402946
Route<dynamic>? lastAnnouncedPreviousRoute = notAnnounced; // last argument to Route.didChangePrevious
29412947
WeakReference<Route<dynamic>> lastAnnouncedPoppedNextRoute = WeakReference<Route<dynamic>>(notAnnounced); // last argument to Route.didPopNext
29422948
Route<dynamic>? lastAnnouncedNextRoute = notAnnounced; // last argument to Route.didChangeNext
2949+
int? lastFocusNode; // The last focused semantic node for the route entry.
29432950

29442951
/// Restoration ID to be used for the encapsulating route when restoration is
29452952
/// enabled for it or null if restoration cannot be enabled for it.
@@ -3028,6 +3035,24 @@ class _RouteEntry extends RouteTransitionRecord {
30283035
void handleDidPopNext(Route<dynamic> poppedRoute) {
30293036
route.didPopNext(poppedRoute);
30303037
lastAnnouncedPoppedNextRoute = WeakReference<Route<dynamic>>(poppedRoute);
3038+
if (lastFocusNode != null) {
3039+
// Move focus back to the last focused node.
3040+
poppedRoute._disposeCompleter.future.then((dynamic result) async {
3041+
switch (defaultTargetPlatform) {
3042+
case TargetPlatform.android:
3043+
// In the Android platform, we have to wait for the system refocus to complete before
3044+
// sending the refocus message. Otherwise, the refocus message will be ignored.
3045+
// TODO(hangyujin): update this logic if Android provide a better way to do so.
3046+
final int? reFocusNode = lastFocusNode;
3047+
await Future<void>.delayed(_kAndroidRefocusingDelayDuration);
3048+
SystemChannels.accessibility.send(const FocusSemanticEvent().toMap(nodeId: reFocusNode));
3049+
case TargetPlatform.iOS:
3050+
SystemChannels.accessibility.send(const FocusSemanticEvent().toMap(nodeId: lastFocusNode));
3051+
case _:
3052+
break ;
3053+
}
3054+
});
3055+
}
30313056
}
30323057

30333058
/// Process the to-be-popped route.
@@ -3576,9 +3601,16 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res
35763601
SystemNavigator.selectSingleEntryHistory();
35773602
}
35783603

3604+
ServicesBinding.instance.accessibilityFocus.addListener(_recordLastFocus);
35793605
_history.addListener(_handleHistoryChanged);
35803606
}
35813607

3608+
// Record the last focused node in route entry.
3609+
void _recordLastFocus(){
3610+
final _RouteEntry? entry = _history.where(_RouteEntry.isPresentPredicate).lastOrNull;
3611+
entry?.lastFocusNode = ServicesBinding.instance.accessibilityFocus.value;
3612+
}
3613+
35823614
// Use [_nextPagelessRestorationScopeId] to get the next id.
35833615
final RestorableNum<int> _rawNextPagelessRestorationScopeId = RestorableNum<int>(0);
35843616

@@ -3871,6 +3903,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res
38713903
_rawNextPagelessRestorationScopeId.dispose();
38723904
_serializableHistory.dispose();
38733905
userGestureInProgressNotifier.dispose();
3906+
ServicesBinding.instance.accessibilityFocus.removeListener(_recordLastFocus);
38743907
_history.removeListener(_handleHistoryChanged);
38753908
_history.dispose();
38763909
super.dispose();

packages/flutter/test/widgets/navigator_test.dart

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4287,6 +4287,98 @@ void main() {
42874287
expect(policy, isA<ReadingOrderTraversalPolicy>());
42884288
});
42894289

4290+
testWidgetsWithLeakTracking(
4291+
'Send semantic event to move a11y focus to the last focused item when pop next page',
4292+
(WidgetTester tester) async {
4293+
dynamic semanticEvent;
4294+
tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>(
4295+
SystemChannels.accessibility, (dynamic message) async {
4296+
semanticEvent = message;
4297+
});
4298+
final Key openSheetKey = UniqueKey();
4299+
await tester.pumpWidget(
4300+
MaterialApp(
4301+
theme: ThemeData(primarySwatch: Colors.blue),
4302+
initialRoute: '/',
4303+
routes: <String, WidgetBuilder>{
4304+
'/': (BuildContext context) => _LinksPage(
4305+
title: 'Home page',
4306+
buttons: <Widget>[
4307+
TextButton(
4308+
onPressed: () {
4309+
Navigator.of(context).pushNamed('/one');
4310+
},
4311+
child: const Text('Go to one'),
4312+
),
4313+
],
4314+
),
4315+
'/one': (BuildContext context) => Scaffold(
4316+
body: Column(
4317+
children: <Widget>[
4318+
const ListTile(title: Text('Title 1')),
4319+
const ListTile(title: Text('Title 2')),
4320+
const ListTile(title: Text('Title 3')),
4321+
ElevatedButton(
4322+
key: openSheetKey,
4323+
onPressed: () {
4324+
showModalBottomSheet<void>(
4325+
context: context,
4326+
builder: (BuildContext context) {
4327+
return Center(
4328+
child: ElevatedButton(
4329+
onPressed: () => Navigator.pop(context),
4330+
child: const Text('Close Sheet'),
4331+
),
4332+
);
4333+
},
4334+
);
4335+
},
4336+
child: const Text('Open Sheet'),
4337+
)
4338+
],
4339+
),
4340+
),
4341+
},
4342+
),
4343+
);
4344+
4345+
expect(find.text('Home page'), findsOneWidget);
4346+
4347+
await tester.tap(find.text('Go to one'));
4348+
await tester.pumpAndSettle();
4349+
4350+
// The focused node before opening the sheet.
4351+
final ByteData? fakeMessage =
4352+
SystemChannels.accessibility.codec.encodeMessage(<String, dynamic>{
4353+
'type': 'didGainFocus',
4354+
'nodeId': 5,
4355+
});
4356+
tester.binding.defaultBinaryMessenger.handlePlatformMessage(
4357+
SystemChannels.accessibility.name,
4358+
fakeMessage,
4359+
(ByteData? data) {},
4360+
);
4361+
await tester.pumpAndSettle();
4362+
4363+
await tester.tap(find.text('Open Sheet'));
4364+
await tester.pumpAndSettle();
4365+
expect(find.text('Close Sheet'), findsOneWidget);
4366+
await tester.tap(find.text('Close Sheet'));
4367+
await tester.pumpAndSettle(const Duration(milliseconds: 500));
4368+
4369+
// The focused node before opening the sheet regains the focus;
4370+
expect(semanticEvent, <String, dynamic>{
4371+
'type': 'focus',
4372+
'nodeId': 5,
4373+
'data': <String, dynamic>{},
4374+
});
4375+
4376+
tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>(SystemChannels.accessibility, null);
4377+
},
4378+
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS}),
4379+
skip: isBrowser, // [intended] only non-web supports move a11y focus back to last item.
4380+
);
4381+
42904382
group('RouteSettings.toString', () {
42914383
test('when name is not null, should have double quote', () {
42924384
expect(const RouteSettings(name: '/home').toString(), 'RouteSettings("/home", null)');

0 commit comments

Comments
 (0)