Unverified Commit 7775c237 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

[Re-Land] Implement focus traversal for desktop platforms. (#31614)

This re-lands the Focus changes in #30040. Correctness changes in routes.dart, and removes the automatic requesting of focus on reparent when there is no current focus, which caused undesirable selections.

Addresses #11344, #1608, #13264, and #1678
Fixes #30084
Fixes #26704
parent fdae7bb8
......@@ -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 _) {
......
......@@ -1465,7 +1465,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)
......@@ -1537,7 +1538,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;
......@@ -1603,7 +1605,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.
......@@ -1690,7 +1693,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)
......@@ -1698,7 +1702,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;
......
......@@ -2117,7 +2117,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,18 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
if (widget.route.secondaryAnimation != null)
animations.add(widget.route.secondaryAnimation);
_listenable = Listenable.merge(animations);
if (widget.route.isCurrent) {
widget.route.navigator.focusScopeNode.setFirstFocus(focusScopeNode);
}
}
@override
void didUpdateWidget(_ModalScope<T> oldWidget) {
super.didUpdateWidget(oldWidget);
assert(widget.route == oldWidget.route);
if (widget.route.isCurrent) {
widget.route.navigator.focusScopeNode.setFirstFocus(focusScopeNode);
}
}
@override
......@@ -612,6 +621,12 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
});
}
@override
void dispose() {
focusScopeNode.dispose();
super.dispose();
}
// This should be called to wrap any changes to route.isCurrent, route.canPop,
// and route.offstage.
void _routeSetState(VoidCallback fn) {
......@@ -629,7 +644,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 +902,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);
......@@ -899,14 +911,10 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
@override
TickerFuture didPush() {
navigator.focusScopeNode.setFirstFocus(focusScopeNode);
return super.didPush();
if (_scopeKey.currentState != null) {
navigator.focusScopeNode.setFirstFocus(_scopeKey.currentState.focusScopeNode);
}
@override
void dispose() {
focusScopeNode.detach();
super.dispose();
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,12 +2754,12 @@ void main() {
controller = TextEditingController();
});
MaterialApp setupWidget() {
Future<void> setupWidget(WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
controller = TextEditingController();
return MaterialApp(
await tester.pumpWidget(
MaterialApp(
home: Material(
child: RawKeyboardListener(
focusNode: focusNode,
......@@ -2769,14 +2769,15 @@ void main() {
maxLines: 3,
strutStyle: StrutStyle.disabled,
),
) ,
),
),
),
);
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 +2790,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 +2806,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 +2828,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 +2915,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 +2987,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 +3098,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);
......
......@@ -17,8 +17,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);
......@@ -955,6 +955,9 @@ void main() {
),
));
focusNode.requestFocus();
await tester.pump();
expect(
semantics,
includesNodeWith(
......@@ -1512,6 +1515,8 @@ void main() {
),
);
focusNode.requestFocus();
// Now change it to make it obscure text.
await tester.pumpWidget(MaterialApp(
home: EditableText(
......@@ -1884,7 +1889,7 @@ void main() {
composing: TextRange(start: 5, end: 14),
),
);
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(
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(
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