Unverified Commit 4218c0bc authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Implement focus traversal for desktop platforms, shoehorn edition. (#30040)

Implements focus traversal for desktop platforms, including re-implementing the existing focus manager and focus tree.

This implements a Focus widget that can be put into a widget tree to allow input focus to be given to a particular part of a widget tree.

It incorporates with the existing FocusScope and FocusNode infrastructure, and has minimal breakage to the API, although FocusScope.reparentIfNeeded is removed, replaced by a call to FocusAttachment.reparent(), so this is a breaking change:

FocusScopeNodes must now be attached to the focus tree using FocusScopeNode.attach, which takes a context and an optional onKey callback, and returns a FocusAttachment that should be kept by the widget that hosts the FocusScopeNode. This is necessary because of the need to make sure that the focus tree reflects the widget hierarchy.

Callers that used to call FocusScope(context).reparentIfNeeded in their build method will call reparent  on a FocusAttachment instead, which they will obtain by calling FocusScopeNode.attach in their initState method. Widgets that own FocusNodes will need to call dispose on the focus node in their dispose method.

Addresses #11344, #1608, #13264, and #1678
Fixes #30084
Fixes #26704
parent 37bc48f2
......@@ -62,7 +62,7 @@ class _HardwareKeyDemoState extends State<RawKeyboardDemo> {
if (!_focusNode.hasFocus) {
return GestureDetector(
onTap: () {
FocusScope.of(context).requestFocus(_focusNode);
_focusNode.requestFocus();
},
child: Text('Tap to focus', style: textTheme.display1),
);
......
......@@ -17,7 +17,7 @@ class MyApp extends StatelessWidget {
return MaterialApp(
title: _title,
home: Scaffold(
appBar: AppBar(title: Text(_title)),
appBar: AppBar(title: const Text(_title)),
body: MyStatefulWidget(),
),
);
......
......@@ -17,7 +17,7 @@ class MyApp extends StatelessWidget {
return MaterialApp(
title: _title,
home: Scaffold(
appBar: AppBar(title: Text(_title)),
appBar: AppBar(title: const Text(_title)),
body: MyStatelessWidget(),
),
);
......
......@@ -90,7 +90,7 @@ import 'package:flutter/foundation.dart';
/// onTap: () {
/// FocusScope.of(context).requestFocus(_focusNode);
/// },
/// child: Text('Tap to focus'),
/// child: const Text('Tap to focus'),
/// );
/// }
/// return Text(_message ?? 'Press a key');
......
......@@ -304,7 +304,7 @@ class _TabSwitchingViewState extends State<_TabSwitchingView> {
tabs = List<Widget>(widget.tabNumber);
tabFocusNodes = List<FocusScopeNode>.generate(
widget.tabNumber,
(int index) => FocusScopeNode(),
(int index) => FocusScopeNode(debugLabel: 'Tab Focus Scope $index'),
);
}
......@@ -327,7 +327,7 @@ class _TabSwitchingViewState extends State<_TabSwitchingView> {
@override
void dispose() {
for (FocusScopeNode focusScopeNode in tabFocusNodes) {
focusScopeNode.detach();
focusScopeNode.dispose();
}
super.dispose();
}
......
......@@ -191,7 +191,7 @@ abstract class SearchDelegate<T> {
///
/// * [showSuggestions] to show the search suggestions again.
void showResults(BuildContext context) {
_focusNode.unfocus();
_focusNode?.unfocus();
_currentBody = _SearchBody.results;
}
......@@ -208,7 +208,8 @@ abstract class SearchDelegate<T> {
///
/// * [showResults] to show the search results.
void showSuggestions(BuildContext context) {
FocusScope.of(context).requestFocus(_focusNode);
assert(_focusNode != null, '_focusNode must be set by route before showSuggestions is called.');
_focusNode.requestFocus();
_currentBody = _SearchBody.suggestions;
}
......@@ -218,7 +219,7 @@ abstract class SearchDelegate<T> {
/// to [showSearch] that launched the search initially.
void close(BuildContext context, T result) {
_currentBody = null;
_focusNode.unfocus();
_focusNode?.unfocus();
Navigator.of(context)
..popUntil((Route<dynamic> route) => route == _route)
..pop(result);
......@@ -232,7 +233,9 @@ abstract class SearchDelegate<T> {
/// page.
Animation<double> get transitionAnimation => _proxyAnimation;
final FocusNode _focusNode = FocusNode();
// The focus node to use for manipulating focus on the search page. This is
// managed, owned, and set by the _SearchPageRoute using this delegate.
FocusNode _focusNode;
final TextEditingController _queryTextController = TextEditingController();
......@@ -246,7 +249,6 @@ abstract class SearchDelegate<T> {
}
_SearchPageRoute<T> _route;
}
/// Describes the body that is currently shown under the [AppBar] in the
......@@ -346,13 +348,18 @@ class _SearchPage<T> extends StatefulWidget {
}
class _SearchPageState<T> extends State<_SearchPage<T>> {
// This node is owned, but not hosted by, the search page. Hosting is done by
// the text field.
FocusNode focusNode = FocusNode();
@override
void initState() {
super.initState();
queryTextController.addListener(_onQueryChanged);
widget.animation.addStatusListener(_onAnimationStatusChanged);
widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
widget.delegate._focusNode.addListener(_onFocusChanged);
focusNode.addListener(_onFocusChanged);
widget.delegate._focusNode = focusNode;
}
@override
......@@ -361,7 +368,8 @@ class _SearchPageState<T> extends State<_SearchPage<T>> {
queryTextController.removeListener(_onQueryChanged);
widget.animation.removeStatusListener(_onAnimationStatusChanged);
widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);
widget.delegate._focusNode.removeListener(_onFocusChanged);
widget.delegate._focusNode = null;
focusNode.dispose();
}
void _onAnimationStatusChanged(AnimationStatus status) {
......@@ -370,12 +378,12 @@ class _SearchPageState<T> extends State<_SearchPage<T>> {
}
widget.animation.removeStatusListener(_onAnimationStatusChanged);
if (widget.delegate._currentBody == _SearchBody.suggestions) {
FocusScope.of(context).requestFocus(widget.delegate._focusNode);
focusNode.requestFocus();
}
}
void _onFocusChanged() {
if (widget.delegate._focusNode.hasFocus && widget.delegate._currentBody != _SearchBody.suggestions) {
if (focusNode.hasFocus && widget.delegate._currentBody != _SearchBody.suggestions) {
widget.delegate.showSuggestions(context);
}
}
......@@ -436,7 +444,7 @@ class _SearchPageState<T> extends State<_SearchPage<T>> {
leading: widget.delegate.buildLeading(context),
title: TextField(
controller: queryTextController,
focusNode: widget.delegate._focusNode,
focusNode: focusNode,
style: theme.textTheme.title,
textInputAction: TextInputAction.search,
onSubmitted: (String _) {
......
......@@ -1455,7 +1455,8 @@ class RenderEditable extends RenderBox {
}
TextSelection _selectWordAtOffset(TextPosition position) {
assert(_textLayoutLastWidth == constraints.maxWidth);
assert(_textLayoutLastWidth == constraints.maxWidth,
'Last width ($_textLayoutLastWidth) not the same as max width constraint (${constraints.maxWidth}).');
final TextRange word = _textPainter.getWordBoundary(position);
// When long-pressing past the end of the text, we want a collapsed cursor.
if (position.offset >= word.end)
......@@ -1527,7 +1528,8 @@ class RenderEditable extends RenderBox {
}
void _paintCaret(Canvas canvas, Offset effectiveOffset, TextPosition textPosition) {
assert(_textLayoutLastWidth == constraints.maxWidth);
assert(_textLayoutLastWidth == constraints.maxWidth,
'Last width ($_textLayoutLastWidth) not the same as max width constraint (${constraints.maxWidth}).');
// If the floating cursor is enabled, the text cursor's color is [backgroundCursorColor] while
// the floating cursor's color is _cursorColor;
......@@ -1593,7 +1595,8 @@ class RenderEditable extends RenderBox {
}
void _paintFloatingCaret(Canvas canvas, Offset effectiveOffset) {
assert(_textLayoutLastWidth == constraints.maxWidth);
assert(_textLayoutLastWidth == constraints.maxWidth,
'Last width ($_textLayoutLastWidth) not the same as max width constraint (${constraints.maxWidth}).');
assert(_floatingCursorOn);
// We always want the floating cursor to render at full opacity.
......@@ -1680,7 +1683,8 @@ class RenderEditable extends RenderBox {
}
void _paintSelection(Canvas canvas, Offset effectiveOffset) {
assert(_textLayoutLastWidth == constraints.maxWidth);
assert(_textLayoutLastWidth == constraints.maxWidth,
'Last width ($_textLayoutLastWidth) not the same as max width constraint (${constraints.maxWidth}).');
assert(_selectionRects != null);
final Paint paint = Paint()..color = _selectionColor;
for (ui.TextBox box in _selectionRects)
......@@ -1688,7 +1692,8 @@ class RenderEditable extends RenderBox {
}
void _paintContents(PaintingContext context, Offset offset) {
assert(_textLayoutLastWidth == constraints.maxWidth);
assert(_textLayoutLastWidth == constraints.maxWidth,
'Last width ($_textLayoutLastWidth) not the same as max width constraint (${constraints.maxWidth}).');
final Offset effectiveOffset = offset + _paintOffset;
bool showSelection = false;
......
......@@ -90,7 +90,7 @@ import 'package:flutter/foundation.dart';
/// onTap: () {
/// FocusScope.of(context).requestFocus(_focusNode);
/// },
/// child: Text('Tap to focus'),
/// child: const Text('Tap to focus'),
/// );
/// }
/// return Text(_message ?? 'Press a key');
......
......@@ -787,6 +787,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final LayerLink _layerLink = LayerLink();
bool _didAutoFocus = false;
FocusAttachment _focusAttachment;
// This value is an eyeball estimation of the time it takes for the iOS cursor
// to ease in and out.
......@@ -809,6 +810,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
void initState() {
super.initState();
widget.controller.addListener(_didChangeTextEditingValue);
_focusAttachment = widget.focusNode.attach(context);
widget.focusNode.addListener(_handleFocusChanged);
_scrollController.addListener(() { _selectionOverlay?.updateForScroll(); });
_cursorBlinkOpacityController = AnimationController(vsync: this, duration: _fadeDuration);
......@@ -836,6 +838,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_handleFocusChanged);
_focusAttachment?.detach();
_focusAttachment = widget.focusNode.attach(context);
widget.focusNode.addListener(_handleFocusChanged);
updateKeepAlive();
}
......@@ -852,6 +856,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
assert(_cursorTimer == null);
_selectionOverlay?.dispose();
_selectionOverlay = null;
_focusAttachment.detach();
widget.focusNode.removeListener(_handleFocusChanged);
super.dispose();
}
......@@ -1091,10 +1096,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (_hasFocus) {
_openInputConnection();
} else {
final List<FocusScopeNode> ancestorScopes = FocusScope.ancestorsOf(context);
for (int i = ancestorScopes.length - 1; i >= 1; i -= 1)
ancestorScopes[i].setFirstFocus(ancestorScopes[i - 1]);
FocusScope.of(context).requestFocus(widget.focusNode);
widget.focusNode.requestFocus();
}
}
......@@ -1400,7 +1402,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
FocusScope.of(context).reparentIfNeeded(widget.focusNode);
_focusAttachment.reparent();
super.build(context); // See AutomaticKeepAliveClientMixin.
final TextSelectionControls controls = widget.selectionControls;
......
......@@ -2124,7 +2124,7 @@ class BuildOwner {
/// the [FocusScopeNode] for a given [BuildContext].
///
/// See [FocusManager] for more details.
final FocusManager focusManager = FocusManager();
FocusManager focusManager = FocusManager();
/// Adds an element to the dirty elements list so that it will be rebuilt
/// when [WidgetsBinding.drawFrame] calls [buildScope].
......
......@@ -1468,7 +1468,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
final Set<Route<dynamic>> _poppedRoutes = <Route<dynamic>>{};
/// The [FocusScopeNode] for the [FocusScope] that encloses the routes.
final FocusScopeNode focusScopeNode = FocusScopeNode();
final FocusScopeNode focusScopeNode = FocusScopeNode(debugLabel: 'Navigator Scope');
final List<OverlayEntry> _initialOverlayEntries = <OverlayEntry>[];
......@@ -1556,7 +1556,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
route.dispose();
_poppedRoutes.clear();
_history.clear();
focusScopeNode.detach();
focusScopeNode.dispose();
super.dispose();
assert(() { _debugLocked = false; return true; }());
}
......
......@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'basic.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart';
export 'package:flutter/services.dart' show RawKeyEvent;
......@@ -112,5 +113,5 @@ class _RawKeyboardListenerState extends State<RawKeyboardListener> {
}
@override
Widget build(BuildContext context) => widget.child;
Widget build(BuildContext context) => Focus(focusNode: widget.focusNode, child: widget.child);
}
......@@ -583,6 +583,9 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
// This is the combination of the two animations for the route.
Listenable _listenable;
/// The node this scope will use for its root [FocusScope] widget.
final FocusScopeNode focusScopeNode = FocusScopeNode(debugLabel: '$_ModalScopeState Focus Scope');
@override
void initState() {
super.initState();
......@@ -592,12 +595,14 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
if (widget.route.secondaryAnimation != null)
animations.add(widget.route.secondaryAnimation);
_listenable = Listenable.merge(animations);
widget.route._grabFocusIfNeeded(focusScopeNode);
}
@override
void didUpdateWidget(_ModalScope<T> oldWidget) {
super.didUpdateWidget(oldWidget);
assert(widget.route == oldWidget.route);
widget.route._grabFocusIfNeeded(focusScopeNode);
}
@override
......@@ -612,6 +617,12 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
});
}
@override
void dispose() {
super.dispose();
focusScopeNode.dispose();
}
// This should be called to wrap any changes to route.isCurrent, route.canPop,
// and route.offstage.
void _routeSetState(VoidCallback fn) {
......@@ -629,7 +640,7 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
child: PageStorage(
bucket: widget.route._storageBucket, // immutable
child: FocusScope(
node: widget.route.focusScopeNode, // immutable
node: focusScopeNode, // immutable
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _listenable, // immutable
......@@ -887,9 +898,6 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
return child;
}
/// The node this route will use for its root [FocusScope] widget.
final FocusScopeNode focusScopeNode = FocusScopeNode();
@override
void install(OverlayEntry insertionPoint) {
super.install(insertionPoint);
......@@ -897,16 +905,18 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
_secondaryAnimationProxy = ProxyAnimation(super.secondaryAnimation);
}
@override
TickerFuture didPush() {
navigator.focusScopeNode.setFirstFocus(focusScopeNode);
return super.didPush();
bool _wantsFocus = false;
void _grabFocusIfNeeded(FocusScopeNode node) {
if (_wantsFocus) {
_wantsFocus = false;
navigator.focusScopeNode.setFirstFocus(node);
}
}
@override
void dispose() {
focusScopeNode.detach();
super.dispose();
TickerFuture didPush() {
_wantsFocus = true;
return super.didPush();
}
// The API for subclasses to override - used by this class
......
......@@ -106,7 +106,10 @@ void main() {
testWidgets('Last tab gets focus', (WidgetTester tester) async {
// 2 nodes for 2 tabs
final List<FocusNode> focusNodes = <FocusNode>[FocusNode(), FocusNode()];
final List<FocusNode> focusNodes = <FocusNode>[
FocusNode(debugLabel: 'Node 1'),
FocusNode(debugLabel: 'Node 2'),
];
await tester.pumpWidget(
CupertinoApp(
......@@ -139,7 +142,10 @@ void main() {
testWidgets('Do not affect focus order in the route', (WidgetTester tester) async {
final List<FocusNode> focusNodes = <FocusNode>[
FocusNode(), FocusNode(), FocusNode(), FocusNode(),
FocusNode(debugLabel: 'Node 1'),
FocusNode(debugLabel: 'Node 2'),
FocusNode(debugLabel: 'Node 3'),
FocusNode(debugLabel: 'Node 4'),
];
await tester.pumpWidget(
......
......@@ -9,11 +9,14 @@ void main() {
testWidgets('Dialog interaction', (WidgetTester tester) async {
expect(tester.testTextInput.isVisible, isFalse);
final FocusNode focusNode = FocusNode(debugLabel: 'Editable Text Node');
await tester.pumpWidget(
const MaterialApp(
MaterialApp(
home: Material(
child: Center(
child: TextField(
focusNode: focusNode,
autofocus: true,
),
),
......@@ -130,7 +133,7 @@ void main() {
await tester.pumpWidget(Container());
expect(tester.testTextInput.isVisible, isFalse);
}, skip: true); // https://github.com/flutter/flutter/issues/29384.
});
testWidgets('Focus triggers keep-alive', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
......
......@@ -2754,29 +2754,31 @@ void main() {
controller = TextEditingController();
});
MaterialApp setupWidget() {
Future<void> setupWidget(WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
controller = TextEditingController();
return MaterialApp(
home: Material(
child: RawKeyboardListener(
focusNode: focusNode,
onKey: null,
child: TextField(
controller: controller,
maxLines: 3,
strutStyle: StrutStyle.disabled,
await tester.pumpWidget(
MaterialApp(
home: Material(
child: RawKeyboardListener(
focusNode: focusNode,
onKey: null,
child: TextField(
controller: controller,
maxLines: 3,
strutStyle: StrutStyle.disabled,
),
),
) ,
),
),
);
focusNode.requestFocus();
await tester.pump();
}
testWidgets('Shift test 1', (WidgetTester tester) async {
await tester.pumpWidget(setupWidget());
await setupWidget(tester);
const String testValue = 'a big house';
await tester.enterText(find.byType(TextField), testValue);
......@@ -2789,7 +2791,7 @@ void main() {
});
testWidgets('Control Shift test', (WidgetTester tester) async {
await tester.pumpWidget(setupWidget());
await setupWidget(tester);
const String testValue = 'their big house';
await tester.enterText(find.byType(TextField), testValue);
......@@ -2805,7 +2807,7 @@ void main() {
});
testWidgets('Down and up test', (WidgetTester tester) async {
await tester.pumpWidget(setupWidget());
await setupWidget(tester);
const String testValue = 'a big house';
await tester.enterText(find.byType(TextField), testValue);
......@@ -2827,7 +2829,7 @@ void main() {
});
testWidgets('Down and up test 2', (WidgetTester tester) async {
await tester.pumpWidget(setupWidget());
await setupWidget(tester);
const String testValue = 'a big house\njumped over a mouse\nOne more line yay'; // 11 \n 19
await tester.enterText(find.byType(TextField), testValue);
......@@ -2914,6 +2916,8 @@ void main() {
),
),
);
focusNode.requestFocus();
await tester.pump();
const String testValue = 'a big house\njumped over a mouse'; // 11 \n 19
await tester.enterText(find.byType(TextField), testValue);
......@@ -2984,6 +2988,8 @@ void main() {
),
),
);
focusNode.requestFocus();
await tester.pump();
const String testValue = 'a big house\njumped over a mouse'; // 11 \n 19
await tester.enterText(find.byType(TextField), testValue);
......@@ -3093,6 +3099,8 @@ void main() {
),
),
);
focusNode.requestFocus();
await tester.pump();
const String testValue = 'a big house\njumped over a mouse'; // 11 \n 19
await tester.enterText(find.byType(TextField), testValue);
......
......@@ -16,8 +16,8 @@ import 'editable_text_utils.dart';
import 'semantics_tester.dart';
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
final FocusScopeNode focusScopeNode = FocusScopeNode();
final FocusNode focusNode = FocusNode(debugLabel: 'EditableText Node');
final FocusScopeNode focusScopeNode = FocusScopeNode(debugLabel: 'EditableText Scope Node');
const TextStyle textStyle = TextStyle();
const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
......@@ -975,6 +975,9 @@ void main() {
),
));
focusNode.requestFocus();
await tester.pump();
expect(
semantics,
includesNodeWith(
......@@ -1532,6 +1535,8 @@ void main() {
),
);
focusNode.requestFocus();
// Now change it to make it obscure text.
await tester.pumpWidget(MaterialApp(
home: EditableText(
......@@ -1906,7 +1911,7 @@ void main() {
);
final GlobalKey<EditableTextState> editableTextKey =
GlobalKey<EditableTextState>();
final FocusNode focusNode = FocusNode();
final FocusNode focusNode = FocusNode(debugLabel: 'Test Focus Node');
await tester.pumpWidget(MaterialApp( // So we can show overlays.
home: EditableText(
......
This diff is collapsed.
......@@ -12,7 +12,7 @@ void sendFakeKeyEvent(Map<String, dynamic> data) {
BinaryMessages.handlePlatformMessage(
SystemChannels.keyEvent.name,
SystemChannels.keyEvent.codec.encodeMessage(data),
(ByteData data) { },
(ByteData data) {},
);
}
......@@ -29,13 +29,15 @@ void main() {
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(RawKeyboardListener(
focusNode: focusNode,
onKey: events.add,
child: Container(),
));
await tester.pumpWidget(
RawKeyboardListener(
focusNode: focusNode,
onKey: events.add,
child: Container(),
),
);
tester.binding.focusManager.rootScope.requestFocus(focusNode);
focusNode.requestFocus();
await tester.idle();
sendFakeKeyEvent(<String, dynamic>{
......@@ -65,13 +67,15 @@ void main() {
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(RawKeyboardListener(
focusNode: focusNode,
onKey: events.add,
child: Container(),
));
await tester.pumpWidget(
RawKeyboardListener(
focusNode: focusNode,
onKey: events.add,
child: Container(),
),
);
tester.binding.focusManager.rootScope.requestFocus(focusNode);
focusNode.requestFocus();
await tester.idle();
sendFakeKeyEvent(<String, dynamic>{
......
......@@ -692,6 +692,7 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
FlutterError.onError = _oldExceptionHandler;
_pendingExceptionDetails = null;
_parentZone = null;
buildOwner.focusManager = FocusManager();
}
}
......
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