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 { ...@@ -43,6 +43,7 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
_initKeyboard(); _initKeyboard();
initLicenses(); initLicenses();
SystemChannels.system.setMessageHandler((dynamic message) => handleSystemMessage(message as Object)); SystemChannels.system.setMessageHandler((dynamic message) => handleSystemMessage(message as Object));
SystemChannels.accessibility.setMessageHandler((dynamic message) => _handleAccessibilityMessage(message as Object));
SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage); SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage);
SystemChannels.platform.setMethodCallHandler(_handlePlatformMessage); SystemChannels.platform.setMethodCallHandler(_handlePlatformMessage);
TextInput.ensureInitialized(); TextInput.ensureInitialized();
...@@ -353,6 +354,21 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { ...@@ -353,6 +354,21 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
return false; 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 { Future<dynamic> _handlePlatformMessage(MethodCall methodCall) async {
final String method = methodCall.method; final String method = methodCall.method;
assert(method == 'SystemChrome.systemUIChange' || method == 'System.requestAppExit'); assert(method == 'SystemChrome.systemUIChange' || method == 'System.requestAppExit');
......
...@@ -27,6 +27,9 @@ import 'restoration_properties.dart'; ...@@ -27,6 +27,9 @@ import 'restoration_properties.dart';
import 'routes.dart'; import 'routes.dart';
import 'ticker_provider.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: // Examples can assume:
// typedef MyAppHome = Placeholder; // typedef MyAppHome = Placeholder;
// typedef MyHomePage = Placeholder; // typedef MyHomePage = Placeholder;
...@@ -372,6 +375,8 @@ abstract class Route<T> { ...@@ -372,6 +375,8 @@ abstract class Route<T> {
Future<T?> get popped => _popCompleter.future; Future<T?> get popped => _popCompleter.future;
final Completer<T?> _popCompleter = Completer<T?>(); 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 /// 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 /// internally (e.g. because it has its own stack of internal state) then
/// return false, otherwise return true (by returning the value of calling /// return false, otherwise return true (by returning the value of calling
...@@ -511,6 +516,7 @@ abstract class Route<T> { ...@@ -511,6 +516,7 @@ abstract class Route<T> {
void dispose() { void dispose() {
_navigator = null; _navigator = null;
_restorationScopeId.dispose(); _restorationScopeId.dispose();
_disposeCompleter.complete();
if (kFlutterMemoryAllocationsEnabled) { if (kFlutterMemoryAllocationsEnabled) {
MemoryAllocations.instance.dispatchObjectDisposed(object: this); MemoryAllocations.instance.dispatchObjectDisposed(object: this);
} }
...@@ -2940,6 +2946,7 @@ class _RouteEntry extends RouteTransitionRecord { ...@@ -2940,6 +2946,7 @@ class _RouteEntry extends RouteTransitionRecord {
Route<dynamic>? lastAnnouncedPreviousRoute = notAnnounced; // last argument to Route.didChangePrevious Route<dynamic>? lastAnnouncedPreviousRoute = notAnnounced; // last argument to Route.didChangePrevious
WeakReference<Route<dynamic>> lastAnnouncedPoppedNextRoute = WeakReference<Route<dynamic>>(notAnnounced); // last argument to Route.didPopNext WeakReference<Route<dynamic>> lastAnnouncedPoppedNextRoute = WeakReference<Route<dynamic>>(notAnnounced); // last argument to Route.didPopNext
Route<dynamic>? lastAnnouncedNextRoute = notAnnounced; // last argument to Route.didChangeNext 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 /// Restoration ID to be used for the encapsulating route when restoration is
/// enabled for it or null if restoration cannot be enabled for it. /// enabled for it or null if restoration cannot be enabled for it.
...@@ -3028,6 +3035,24 @@ class _RouteEntry extends RouteTransitionRecord { ...@@ -3028,6 +3035,24 @@ class _RouteEntry extends RouteTransitionRecord {
void handleDidPopNext(Route<dynamic> poppedRoute) { void handleDidPopNext(Route<dynamic> poppedRoute) {
route.didPopNext(poppedRoute); route.didPopNext(poppedRoute);
lastAnnouncedPoppedNextRoute = WeakReference<Route<dynamic>>(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. /// Process the to-be-popped route.
...@@ -3576,9 +3601,16 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res ...@@ -3576,9 +3601,16 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res
SystemNavigator.selectSingleEntryHistory(); SystemNavigator.selectSingleEntryHistory();
} }
ServicesBinding.instance.accessibilityFocus.addListener(_recordLastFocus);
_history.addListener(_handleHistoryChanged); _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. // Use [_nextPagelessRestorationScopeId] to get the next id.
final RestorableNum<int> _rawNextPagelessRestorationScopeId = RestorableNum<int>(0); final RestorableNum<int> _rawNextPagelessRestorationScopeId = RestorableNum<int>(0);
...@@ -3871,6 +3903,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res ...@@ -3871,6 +3903,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin, Res
_rawNextPagelessRestorationScopeId.dispose(); _rawNextPagelessRestorationScopeId.dispose();
_serializableHistory.dispose(); _serializableHistory.dispose();
userGestureInProgressNotifier.dispose(); userGestureInProgressNotifier.dispose();
ServicesBinding.instance.accessibilityFocus.removeListener(_recordLastFocus);
_history.removeListener(_handleHistoryChanged); _history.removeListener(_handleHistoryChanged);
_history.dispose(); _history.dispose();
super.dispose(); super.dispose();
......
...@@ -4287,6 +4287,98 @@ void main() { ...@@ -4287,6 +4287,98 @@ void main() {
expect(policy, isA<ReadingOrderTraversalPolicy>()); 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', () { group('RouteSettings.toString', () {
test('when name is not null, should have double quote', () { test('when name is not null, should have double quote', () {
expect(const RouteSettings(name: '/home').toString(), 'RouteSettings("/home", null)'); 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