Unverified Commit 8d429877 authored by hangyu's avatar hangyu Committed by GitHub

Record focus in route entry to move a11y focus to the last focused item (#135771)

issue: #97747 
engine pr:https://github.com/flutter/engine/pull/47114
parent 4727627b
......@@ -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();
......@@ -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');
......
......@@ -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;
......@@ -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
......@@ -511,6 +516,7 @@ abstract class Route<T> {
void dispose() {
_navigator = null;
_restorationScopeId.dispose();
_disposeCompleter.complete();
if (kFlutterMemoryAllocationsEnabled) {
MemoryAllocations.instance.dispatchObjectDisposed(object: this);
}
......@@ -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.
......@@ -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.
......@@ -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);
......@@ -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();
......
......@@ -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)');
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment