Skip to content

Commit

Permalink
Record focus in route entry to move a11y focus to the last focused it…
Browse files Browse the repository at this point in the history
…em (#135771)

issue: #97747 
engine pr:flutter/engine#47114
  • Loading branch information
hannah-hyj authored Nov 27, 2023
1 parent 4727627 commit 8d42987
Show file tree
Hide file tree
Showing 3 changed files with 141 additions and 0 deletions.
16 changes: 16 additions & 0 deletions packages/flutter/lib/src/services/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
_initKeyboard();
initLicenses();
SystemChannels.system.setMessageHandler((dynamic message) => handleSystemMessage(message as Object));
SystemChannels.accessibility.setMessageHandler((dynamic message) => _handleAccessibilityMessage(message as Object));
SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage);
SystemChannels.platform.setMethodCallHandler(_handlePlatformMessage);
TextInput.ensureInitialized();
Expand Down Expand Up @@ -353,6 +354,21 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
return false;
}


/// Listenable that notifies when the accessibility focus on the system have changed.
final ValueNotifier<int?> accessibilityFocus = ValueNotifier<int?>(null);

Future<void> _handleAccessibilityMessage(Object accessibilityMessage) async {
final Map<String, dynamic> message =
(accessibilityMessage as Map<Object?, Object?>).cast<String, dynamic>();
final String type = message['type'] as String;
switch (type) {
case 'didGainFocus':
accessibilityFocus.value = message['nodeId'] as int;
}
return;
}

Future<dynamic> _handlePlatformMessage(MethodCall methodCall) async {
final String method = methodCall.method;
assert(method == 'SystemChrome.systemUIChange' || method == 'System.requestAppExit');
Expand Down
33 changes: 33 additions & 0 deletions packages/flutter/lib/src/widgets/navigator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import 'restoration_properties.dart';
import 'routes.dart';
import 'ticker_provider.dart';

// Duration for delay before refocusing in android so that the focus won't be interrupted.
const Duration _kAndroidRefocusingDelayDuration = Duration(milliseconds: 300);

// Examples can assume:
// typedef MyAppHome = Placeholder;
// typedef MyHomePage = Placeholder;
Expand Down Expand Up @@ -372,6 +375,8 @@ abstract class Route<T> {
Future<T?> get popped => _popCompleter.future;
final Completer<T?> _popCompleter = Completer<T?>();

final Completer<T?> _disposeCompleter = Completer<T?>();

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

/// Restoration ID to be used for the encapsulating route when restoration is
/// enabled for it or null if restoration cannot be enabled for it.
Expand Down Expand Up @@ -3028,6 +3035,24 @@ class _RouteEntry extends RouteTransitionRecord {
void handleDidPopNext(Route<dynamic> poppedRoute) {
route.didPopNext(poppedRoute);
lastAnnouncedPoppedNextRoute = WeakReference<Route<dynamic>>(poppedRoute);
if (lastFocusNode != null) {
// Move focus back to the last focused node.
poppedRoute._disposeCompleter.future.then((dynamic result) async {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
// In the Android platform, we have to wait for the system refocus to complete before
// sending the refocus message. Otherwise, the refocus message will be ignored.
// TODO(hangyujin): update this logic if Android provide a better way to do so.
final int? reFocusNode = lastFocusNode;
await Future<void>.delayed(_kAndroidRefocusingDelayDuration);
SystemChannels.accessibility.send(const FocusSemanticEvent().toMap(nodeId: reFocusNode));
case TargetPlatform.iOS:
SystemChannels.accessibility.send(const FocusSemanticEvent().toMap(nodeId: lastFocusNode));
case _:
break ;
}
});
}
}

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

ServicesBinding.instance.accessibilityFocus.addListener(_recordLastFocus);
_history.addListener(_handleHistoryChanged);
}

// Record the last focused node in route entry.
void _recordLastFocus(){
final _RouteEntry? entry = _history.where(_RouteEntry.isPresentPredicate).lastOrNull;
entry?.lastFocusNode = ServicesBinding.instance.accessibilityFocus.value;
}

// Use [_nextPagelessRestorationScopeId] to get the next id.
final RestorableNum<int> _rawNextPagelessRestorationScopeId = RestorableNum<int>(0);

Expand Down Expand Up @@ -3871,6 +3903,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res
_rawNextPagelessRestorationScopeId.dispose();
_serializableHistory.dispose();
userGestureInProgressNotifier.dispose();
ServicesBinding.instance.accessibilityFocus.removeListener(_recordLastFocus);
_history.removeListener(_handleHistoryChanged);
_history.dispose();
super.dispose();
Expand Down
92 changes: 92 additions & 0 deletions packages/flutter/test/widgets/navigator_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4287,6 +4287,98 @@ void main() {
expect(policy, isA<ReadingOrderTraversalPolicy>());
});

testWidgetsWithLeakTracking(
'Send semantic event to move a11y focus to the last focused item when pop next page',
(WidgetTester tester) async {
dynamic semanticEvent;
tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>(
SystemChannels.accessibility, (dynamic message) async {
semanticEvent = message;
});
final Key openSheetKey = UniqueKey();
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(primarySwatch: Colors.blue),
initialRoute: '/',
routes: <String, WidgetBuilder>{
'/': (BuildContext context) => _LinksPage(
title: 'Home page',
buttons: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pushNamed('/one');
},
child: const Text('Go to one'),
),
],
),
'/one': (BuildContext context) => Scaffold(
body: Column(
children: <Widget>[
const ListTile(title: Text('Title 1')),
const ListTile(title: Text('Title 2')),
const ListTile(title: Text('Title 3')),
ElevatedButton(
key: openSheetKey,
onPressed: () {
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
return Center(
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close Sheet'),
),
);
},
);
},
child: const Text('Open Sheet'),
)
],
),
),
},
),
);

expect(find.text('Home page'), findsOneWidget);

await tester.tap(find.text('Go to one'));
await tester.pumpAndSettle();

// The focused node before opening the sheet.
final ByteData? fakeMessage =
SystemChannels.accessibility.codec.encodeMessage(<String, dynamic>{
'type': 'didGainFocus',
'nodeId': 5,
});
tester.binding.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.accessibility.name,
fakeMessage,
(ByteData? data) {},
);
await tester.pumpAndSettle();

await tester.tap(find.text('Open Sheet'));
await tester.pumpAndSettle();
expect(find.text('Close Sheet'), findsOneWidget);
await tester.tap(find.text('Close Sheet'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));

// The focused node before opening the sheet regains the focus;
expect(semanticEvent, <String, dynamic>{
'type': 'focus',
'nodeId': 5,
'data': <String, dynamic>{},
});

tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>(SystemChannels.accessibility, null);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS}),
skip: isBrowser, // [intended] only non-web supports move a11y focus back to last item.
);

group('RouteSettings.toString', () {
test('when name is not null, should have double quote', () {
expect(const RouteSettings(name: '/home').toString(), 'RouteSettings("/home", null)');
Expand Down

0 comments on commit 8d42987

Please sign in to comment.