From 89aaaa9c32ad3ee9824362b727c928f611eff4e8 Mon Sep 17 00:00:00 2001 From: Adam Barth Date: Fri, 31 Mar 2017 13:10:37 -0700 Subject: [PATCH] Improve focus management (#9074) We now have an explicit focus tree that we manage. Instead of using GlobalKeys to manage focus, we use FocusNode and FocusScopeNode objects. The FocusNode is Listenable and notifies when its focus state changes. Focus notifications trigger by tree mutations are now delayed by one frame, which is necessary to handle certain complex tree mutations. In the common case of focus changes being triggered by user input, the focus notificiation still arives in the same frame. --- dev/manual_tests/raw_keyboard.dart | 97 ++-- packages/flutter/lib/foundation.dart | 1 + .../foundation/tree_diagnostics_mixin.dart | 38 ++ packages/flutter/lib/src/material/drawer.dart | 16 +- packages/flutter/lib/src/material/input.dart | 115 ++--- packages/flutter/lib/src/rendering/layer.dart | 32 +- packages/flutter/lib/src/widgets/binding.dart | 9 + .../lib/src/widgets/editable_text.dart | 101 +++-- packages/flutter/lib/src/widgets/focus.dart | 386 ---------------- .../lib/src/widgets/focus_manager.dart | 422 ++++++++++++++++++ .../flutter/lib/src/widgets/focus_scope.dart | 114 +++++ .../flutter/lib/src/widgets/navigator.dart | 23 +- .../src/widgets/raw_keyboard_listener.dart | 25 +- packages/flutter/lib/src/widgets/routes.dart | 32 +- packages/flutter/lib/widgets.dart | 3 +- .../tree_diagnostics_mixin_test.dart | 58 +++ packages/flutter/test/material/app_test.dart | 6 +- .../flutter/test/material/will_pop_test.dart | 2 +- packages/flutter/test/widgets/focus_test.dart | 172 ++++--- packages/flutter/test/widgets/form_test.dart | 26 +- packages/flutter/test/widgets/input_test.dart | 29 +- .../flutter/test/widgets/navigator_test.dart | 12 - .../widgets/raw_keyboard_listener_test.dart | 21 +- 23 files changed, 1009 insertions(+), 731 deletions(-) create mode 100644 packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart delete mode 100644 packages/flutter/lib/src/widgets/focus.dart create mode 100644 packages/flutter/lib/src/widgets/focus_manager.dart create mode 100644 packages/flutter/lib/src/widgets/focus_scope.dart create mode 100644 packages/flutter/test/foundation/tree_diagnostics_mixin_test.dart diff --git a/dev/manual_tests/raw_keyboard.dart b/dev/manual_tests/raw_keyboard.dart index be532c1f1b1..392c856bed3 100644 --- a/dev/manual_tests/raw_keyboard.dart +++ b/dev/manual_tests/raw_keyboard.dart @@ -5,34 +5,37 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -GlobalKey _key = new GlobalKey(); - void main() { runApp(new MaterialApp( - title: "Hardware Key Demo", + title: 'Hardware Key Demo', home: new Scaffold( appBar: new AppBar( - title: new Text("Hardware Key Demo") + title: new Text('Hardware Key Demo'), ), body: new Center( - child: new RawKeyboardDemo( - key: _key - ) - ) - ) + child: new RawKeyboardDemo(), + ), + ), )); } class RawKeyboardDemo extends StatefulWidget { - RawKeyboardDemo({ GlobalKey key }) : super(key: key); + RawKeyboardDemo({ Key key }) : super(key: key); @override _HardwareKeyDemoState createState() => new _HardwareKeyDemoState(); } class _HardwareKeyDemoState extends State { + final FocusNode _focusNode = new FocusNode(); RawKeyEvent _event; + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + void _handleKeyEvent(RawKeyEvent event) { setState(() { _event = event; @@ -42,44 +45,46 @@ class _HardwareKeyDemoState extends State { @override Widget build(BuildContext context) { final TextTheme textTheme = Theme.of(context).textTheme; - final bool focused = Focus.at(context); - Widget child; - if (!focused) { - child = new GestureDetector( - onTap: () { - Focus.moveTo(config.key); - }, - child: new Text('Tap to focus', style: textTheme.display1), - ); - } else if (_event == null) { - child = new Text('Press a key', style: textTheme.display1); - } else { - int codePoint; - int keyCode; - int hidUsage; - final RawKeyEventData data = _event.data; - if (data is RawKeyEventDataAndroid) { - codePoint = data.codePoint; - keyCode = data.keyCode; - } else if (data is RawKeyEventDataFuchsia) { - codePoint = data.codePoint; - hidUsage = data.hidUsage; - } - child = new Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - new Text('${_event.runtimeType}', style: textTheme.body2), - new Text('codePoint: $codePoint', style: textTheme.display4), - new Text('keyCode: $keyCode', style: textTheme.display4), - new Text('hidUsage: $hidUsage', style: textTheme.display4), - ], - ); - } return new RawKeyboardListener( - focused: focused, + focusNode: _focusNode, onKey: _handleKeyEvent, - child: child, + child: new AnimatedBuilder( + animation: _focusNode, + builder: (BuildContext context, Widget child) { + if (!_focusNode.hasFocus) { + return new GestureDetector( + onTap: () { + FocusScope.of(context).requestFocus(_focusNode); + }, + child: new Text('Tap to focus', style: textTheme.display1), + ); + } + + if (_event == null) + return new Text('Press a key', style: textTheme.display1); + + int codePoint; + int keyCode; + int hidUsage; + final RawKeyEventData data = _event.data; + if (data is RawKeyEventDataAndroid) { + codePoint = data.codePoint; + keyCode = data.keyCode; + } else if (data is RawKeyEventDataFuchsia) { + codePoint = data.codePoint; + hidUsage = data.hidUsage; + } + return new Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + new Text('${_event.runtimeType}', style: textTheme.body2), + new Text('codePoint: $codePoint', style: textTheme.display4), + new Text('keyCode: $keyCode', style: textTheme.display4), + new Text('hidUsage: $hidUsage', style: textTheme.display4), + ], + ); + }, + ), ); } } - diff --git a/packages/flutter/lib/foundation.dart b/packages/flutter/lib/foundation.dart index 6bd52346bb2..e21c0ae3196 100644 --- a/packages/flutter/lib/foundation.dart +++ b/packages/flutter/lib/foundation.dart @@ -26,3 +26,4 @@ export 'src/foundation/platform.dart'; export 'src/foundation/print.dart'; export 'src/foundation/serialization.dart'; export 'src/foundation/synchronous_future.dart'; +export 'src/foundation/tree_diagnostics_mixin.dart'; diff --git a/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart b/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart new file mode 100644 index 00000000000..d1054277c3c --- /dev/null +++ b/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart @@ -0,0 +1,38 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +/// A mixin that helps dump string representations of trees. +abstract class TreeDiagnosticsMixin { + @override + String toString() => '$runtimeType#$hashCode'; + + /// Returns a string representation of this node and its descendants. + String toStringDeep([String prefixLineOne = '', String prefixOtherLines = '']) { + String result = '$prefixLineOne$this\n'; + final String childrenDescription = debugDescribeChildren(prefixOtherLines); + final String descriptionPrefix = childrenDescription != '' ? '$prefixOtherLines \u2502 ' : '$prefixOtherLines '; + final List description = []; + debugFillDescription(description); + result += description.map((String description) => '$descriptionPrefix$description\n').join(); + if (childrenDescription == '') { + final String prefix = prefixOtherLines.trimRight(); + if (prefix != '') + result += '$prefix\n'; + } else { + result += childrenDescription; + } + return result; + } + + /// Add additional information to the given description for use by [toStringDeep]. + @protected + @mustCallSuper + void debugFillDescription(List description) { } + + /// Returns a description of this node's children for use by [toStringDeep]. + @protected + String debugDescribeChildren(String prefix) => ''; +} diff --git a/packages/flutter/lib/src/material/drawer.dart b/packages/flutter/lib/src/material/drawer.dart index da6e79d59ca..d112938a0d8 100644 --- a/packages/flutter/lib/src/material/drawer.dart +++ b/packages/flutter/lib/src/material/drawer.dart @@ -158,8 +158,7 @@ class DrawerControllerState extends State with SingleTickerPro } LocalHistoryEntry _historyEntry; - // TODO(abarth): This should be a GlobalValueKey when those exist. - GlobalKey get _drawerKey => new GlobalObjectKey(config.key); + final FocusScopeNode _focusScopeNode = new FocusScopeNode(); void _ensureHistoryEntry() { if (_historyEntry == null) { @@ -167,7 +166,7 @@ class DrawerControllerState extends State with SingleTickerPro if (route != null) { _historyEntry = new LocalHistoryEntry(onRemove: _handleHistoryEntryRemoved); route.addLocalHistoryEntry(_historyEntry); - Focus.moveScopeTo(_drawerKey, context: context); + FocusScope.of(context).setFirstFocus(_focusScopeNode); } } } @@ -210,10 +209,12 @@ class DrawerControllerState extends State with SingleTickerPro } } + final GlobalKey _drawerKey = new GlobalKey(); + double get _width { - final RenderBox drawerBox = _drawerKey.currentContext?.findRenderObject(); - if (drawerBox != null) - return drawerBox.size.width; + final RenderBox box = _drawerKey.currentContext?.findRenderObject(); + if (box != null) + return box.size.width; return _kWidth; // drawer not being shown currently } @@ -286,8 +287,9 @@ class DrawerControllerState extends State with SingleTickerPro alignment: FractionalOffset.centerRight, widthFactor: _controller.value, child: new RepaintBoundary( - child: new Focus( + child: new FocusScope( key: _drawerKey, + node: _focusScopeNode, child: config.child ), ), diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart index 1244449982d..63ff387d79f 100644 --- a/packages/flutter/lib/src/material/input.dart +++ b/packages/flutter/lib/src/material/input.dart @@ -43,7 +43,7 @@ const Curve _kTransitionCurve = Curves.fastOutSlowIn; class InputField extends StatefulWidget { InputField({ Key key, - this.focusKey, + this.focusNode, this.value, this.keyboardType: TextInputType.text, this.hintText, @@ -56,7 +56,10 @@ class InputField extends StatefulWidget { this.onSubmitted, }) : super(key: key); - final GlobalKey focusKey; + /// Controls whether this widget has keyboard focus. + /// + /// If null, this widget will create its own [FocusNode]. + final FocusNode focusNode; /// The current state of text of the input field. This includes the selected /// text, if any, among other things. @@ -109,9 +112,15 @@ class InputField extends StatefulWidget { class _InputFieldState extends State { final GlobalKey _editableTextKey = new GlobalKey(); - final GlobalKey _focusKey = new GlobalKey(debugLabel: "_InputFieldState _focusKey"); - GlobalKey get focusKey => config.focusKey ?? (config.key is GlobalKey ? config.key : _focusKey); + FocusNode _focusNode; + FocusNode get _effectiveFocusNode => config.focusNode ?? (_focusNode ??= new FocusNode()); + + @override + void dispose() { + _focusNode?.dispose(); + super.dispose(); + } void requestKeyboard() { _editableTextKey.currentState?.requestKeyboard(); @@ -126,31 +135,22 @@ class _InputFieldState extends State { final List stackChildren = [ new GestureDetector( - key: focusKey == _focusKey ? _focusKey : null, behavior: HitTestBehavior.opaque, onTap: requestKeyboard, - // Since the focusKey may have been created here, defer building the - // EditableText until the focusKey's context has been set. This is - // necessary because the EditableText will check the focus, like - // Focus.at(focusContext), when it builds. - child: new Builder( - builder: (BuildContext context) { - return new EditableText( - key: _editableTextKey, - value: value, - focusKey: focusKey, - style: textStyle, - obscureText: config.obscureText, - maxLines: config.maxLines, - autofocus: config.autofocus, - cursorColor: themeData.textSelectionColor, - selectionColor: themeData.textSelectionColor, - selectionControls: materialTextSelectionControls, - keyboardType: config.keyboardType, - onChanged: config.onChanged, - onSubmitted: config.onSubmitted, - ); - } + child: new EditableText( + key: _editableTextKey, + value: value, + focusNode: _effectiveFocusNode, + style: textStyle, + obscureText: config.obscureText, + maxLines: config.maxLines, + autofocus: config.autofocus, + cursorColor: themeData.textSelectionColor, + selectionColor: themeData.textSelectionColor, + selectionControls: materialTextSelectionControls, + keyboardType: config.keyboardType, + onChanged: config.onChanged, + onSubmitted: config.onSubmitted, ), ), ]; @@ -432,6 +432,7 @@ class Input extends StatefulWidget { Input({ Key key, this.value, + this.focusNode, this.keyboardType: TextInputType.text, this.icon, this.labelText, @@ -451,6 +452,11 @@ class Input extends StatefulWidget { /// text, if any, among other things. final InputValue value; + /// Controls whether this widget has keyboard focus. + /// + /// If null, this widget will create its own [FocusNode]. + final FocusNode focusNode; + /// The type of keyboard to use for editing the text. final TextInputType keyboardType; @@ -522,26 +528,30 @@ class Input extends StatefulWidget { class _InputState extends State { final GlobalKey<_InputFieldState> _inputFieldKey = new GlobalKey<_InputFieldState>(); - final GlobalKey _focusKey = new GlobalKey(); - GlobalKey get focusKey => config.key is GlobalKey ? config.key : _focusKey; + FocusNode _focusNode; + FocusNode get _effectiveFocusNode => config.focusNode ?? (_focusNode ??= new FocusNode()); + + @override + void dispose() { + _focusNode?.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { + final bool isEmpty = (config.value ?? InputValue.empty).text.isEmpty; + final FocusNode focusNode = _effectiveFocusNode; + return new GestureDetector( - key: focusKey == _focusKey ? _focusKey : null, onTap: () { _inputFieldKey.currentState?.requestKeyboard(); }, - // Since the focusKey may have been created here, defer building the - // InputContainer until the focusKey's context has been set. This is - // necessary because we're passing the value of Focus.at() along. - child: new Builder( - builder: (BuildContext context) { - final bool focused = Focus.at(focusKey.currentContext, autofocus: config.autofocus); - final bool isEmpty = (config.value ?? InputValue.empty).text.isEmpty; + child: new AnimatedBuilder( + animation: focusNode, + builder: (BuildContext context, Widget child) { return new InputContainer( - focused: focused, + focused: focusNode.hasFocus, isEmpty: isEmpty, icon: config.icon, labelText: config.labelText, @@ -550,20 +560,21 @@ class _InputState extends State { style: config.style, isDense: config.isDense, showDivider: config.showDivider, - child: new InputField( - key: _inputFieldKey, - focusKey: focusKey, - value: config.value, - style: config.style, - obscureText: config.obscureText, - maxLines: config.maxLines, - autofocus: config.autofocus, - keyboardType: config.keyboardType, - onChanged: config.onChanged, - onSubmitted: config.onSubmitted, - ), + child: child, ); }, + child: new InputField( + key: _inputFieldKey, + focusNode: focusNode, + value: config.value, + style: config.style, + obscureText: config.obscureText, + maxLines: config.maxLines, + autofocus: config.autofocus, + keyboardType: config.keyboardType, + onChanged: config.onChanged, + onSubmitted: config.onSubmitted, + ), ), ); } @@ -643,7 +654,7 @@ class _InputState extends State { class TextField extends FormField { TextField({ Key key, - GlobalKey focusKey, + FocusNode focusNode, TextInputType keyboardType: TextInputType.text, Icon icon, String labelText, @@ -664,7 +675,7 @@ class TextField extends FormField { validator: validator, builder: (FormFieldState field) { return new Input( - key: focusKey, + focusNode: focusNode, keyboardType: keyboardType, icon: icon, labelText: labelText, diff --git a/packages/flutter/lib/src/rendering/layer.dart b/packages/flutter/lib/src/rendering/layer.dart index 66a79af4bc9..53b5ac48316 100644 --- a/packages/flutter/lib/src/rendering/layer.dart +++ b/packages/flutter/lib/src/rendering/layer.dart @@ -16,7 +16,7 @@ import 'debug.dart'; /// During painting, the render tree generates a tree of composited layers that /// are uploaded into the engine and displayed by the compositor. This class is /// the base class for all composited layers. -abstract class Layer { +abstract class Layer extends Object with TreeDiagnosticsMixin { /// This layer's parent in the layer tree ContainerLayer get parent => _parent; ContainerLayer _parent; @@ -70,44 +70,18 @@ abstract class Layer { /// origin of the builder's coordinate system. void addToScene(ui.SceneBuilder builder, Offset layerOffset); - @override - String toString() => '$runtimeType'; - /// The object responsible for creating this layer. /// /// Defaults to the value of [RenderObject.debugCreator] for the render object /// that created this layer. Used in debug messages. dynamic debugCreator; - /// Returns a string representation of this layer and its descendants. - String toStringDeep([String prefixLineOne = '', String prefixOtherLines = '']) { - String result = '$prefixLineOne$this\n'; - final String childrenDescription = debugDescribeChildren(prefixOtherLines); - final String descriptionPrefix = childrenDescription != '' ? '$prefixOtherLines \u2502 ' : '$prefixOtherLines '; - final List description = []; - debugFillDescription(description); - result += description.map((String description) => "$descriptionPrefix$description\n").join(); - if (childrenDescription == '') { - final String prefix = prefixOtherLines.trimRight(); - if (prefix != '') - result += '$prefix\n'; - } else { - result += childrenDescription; - } - return result; - } - - /// Add additional information to the given description for use by [toStringDeep]. - @protected - @mustCallSuper + @override void debugFillDescription(List description) { + super.debugFillDescription(description); if (debugCreator != null) description.add('creator: $debugCreator'); } - - /// Returns a description of this layer's children for use by [toStringDeep]. - @protected - String debugDescribeChildren(String prefix) => ''; } /// A composited layer containing a [Picture] diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index 0782390e572..ae9a4deb166 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -14,6 +14,7 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'app.dart'; +import 'focus_manager.dart'; import 'framework.dart'; export 'dart:ui' show AppLifecycleState, Locale; @@ -119,6 +120,14 @@ abstract class WidgetsBinding extends BindingBase implements GestureBinding, Ren BuildOwner get buildOwner => _buildOwner; final BuildOwner _buildOwner = new BuildOwner(); + /// The object in charge of the focus tree. + /// + /// Rarely used directly. Instead, consider using [FocusScope.of] to obtain + /// the [FocusScopeNode] for a given [BuildContext]. + /// + /// See [FocusManager] for more details. + final FocusManager focusManager = new FocusManager(); + final List _observers = []; /// Registers the given object as a binding observer. Binding diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 40e4540d8ce..2320c8d9476 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -9,7 +9,8 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'basic.dart'; -import 'focus.dart'; +import 'focus_manager.dart'; +import 'focus_scope.dart'; import 'framework.dart'; import 'media_query.dart'; import 'scroll_controller.dart'; @@ -125,11 +126,12 @@ class InputValue { class EditableText extends StatefulWidget { /// Creates a basic text input control. /// - /// The [value] argument must not be null. + /// The [value], [focusNode], [style], and [cursorColor] arguments must not + /// be null. EditableText({ Key key, @required this.value, - @required this.focusKey, + @required this.focusNode, this.obscureText: false, @required this.style, @required this.cursorColor, @@ -143,7 +145,7 @@ class EditableText extends StatefulWidget { this.onSubmitted, }) : super(key: key) { assert(value != null); - assert(focusKey != null); + assert(focusNode != null); assert(obscureText != null); assert(style != null); assert(cursorColor != null); @@ -154,8 +156,8 @@ class EditableText extends StatefulWidget { /// The string being displayed in this widget. final InputValue value; - /// Key of the enclosing widget that holds the focus. - final GlobalKey focusKey; + /// Controls whether this widget has keyboard focus. + final FocusNode focusNode; /// Whether to hide the text being edited (e.g., for passwords). /// @@ -217,11 +219,23 @@ class EditableTextState extends State implements TextInputClient { TextSelectionOverlay _selectionOverlay; final ScrollController _scrollController = new ScrollController(); + bool _didAutoFocus = false; @override void initState() { super.initState(); _currentValue = config.value; + config.focusNode.addListener(_handleFocusChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_didAutoFocus && config.autofocus) { + _didRequestKeyboard = true; + FocusScope.of(context).autofocus(config.focusNode); + _didAutoFocus = true; + } } @override @@ -231,6 +245,10 @@ class EditableTextState extends State implements TextInputClient { if (_isAttachedToKeyboard) _textInputConnection.setEditingState(_getTextEditingValueFromInputValue(_currentValue)); } + if (config.focusNode != oldConfig.focusNode) { + oldConfig.focusNode.removeListener(_handleFocusChanged); + config.focusNode.addListener(_handleFocusChanged); + } } bool get _isAttachedToKeyboard => _textInputConnection != null && _textInputConnection.attached; @@ -250,12 +268,10 @@ class EditableTextState extends State implements TextInputClient { return scrollOffset; } - // True if the focus was explicitly requested last frame. This ensures we - // don't show the keyboard when focus defaults back to the EditableText. - bool _requestingFocus = false; + bool _didRequestKeyboard = false; void _attachOrDetachKeyboard(bool focused) { - if (focused && !_isAttachedToKeyboard && (_requestingFocus || config.autofocus)) { + if (focused && !_isAttachedToKeyboard && _didRequestKeyboard) { _textInputConnection = TextInput.attach(this, new TextInputConfiguration(inputType: config.keyboardType)) ..setEditingState(_getTextEditingValueFromInputValue(_currentValue)) ..show(); @@ -266,7 +282,7 @@ class EditableTextState extends State implements TextInputClient { } _clearComposing(); } - _requestingFocus = false; + _didRequestKeyboard = false; } void _clearComposing() { @@ -286,10 +302,11 @@ class EditableTextState extends State implements TextInputClient { if (_isAttachedToKeyboard) { _textInputConnection.show(); } else { - Focus.moveTo(config.focusKey); - setState(() { - _requestingFocus = true; - }); + _didRequestKeyboard = true; + if (config.focusNode.hasFocus) + _attachOrDetachKeyboard(true); + else + FocusScope.of(context).requestFocus(config.focusNode); } } @@ -307,7 +324,7 @@ class EditableTextState extends State implements TextInputClient { @override void performAction(TextInputAction action) { _clearComposing(); - Focus.clear(context); + config.focusNode.unfocus(); if (config.onSubmitted != null) config.onSubmitted(_currentValue); } @@ -369,30 +386,8 @@ class EditableTextState extends State implements TextInputClient { _cursorTimer = new Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick); } - @override - void dispose() { - if (_isAttachedToKeyboard) { - _textInputConnection.close(); - _textInputConnection = null; - } - assert(!_isAttachedToKeyboard); - if (_cursorTimer != null) - _stopCursorTimer(); - assert(_cursorTimer == null); - _selectionOverlay?.dispose(); - _selectionOverlay = null; - super.dispose(); - } - - void _stopCursorTimer() { - _cursorTimer.cancel(); - _cursorTimer = null; - _showCursor = false; - } - - @override - Widget build(BuildContext context) { - final bool focused = Focus.at(config.focusKey.currentContext); + void _handleFocusChanged() { + final bool focused = config.focusNode.hasFocus; _attachOrDetachKeyboard(focused); if (_cursorTimer == null && focused && config.value.selection.isCollapsed) @@ -408,7 +403,33 @@ class EditableTextState extends State implements TextInputClient { _selectionOverlay = null; } } + } + @override + void dispose() { + if (_isAttachedToKeyboard) { + _textInputConnection.close(); + _textInputConnection = null; + } + assert(!_isAttachedToKeyboard); + if (_cursorTimer != null) + _stopCursorTimer(); + assert(_cursorTimer == null); + _selectionOverlay?.dispose(); + _selectionOverlay = null; + config.focusNode.removeListener(_handleFocusChanged); + super.dispose(); + } + + void _stopCursorTimer() { + _cursorTimer.cancel(); + _cursorTimer = null; + _showCursor = false; + } + + @override + Widget build(BuildContext context) { + FocusScope.of(context).reparentIfNeeded(config.focusNode); return new Scrollable( axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, controller: _scrollController, diff --git a/packages/flutter/lib/src/widgets/focus.dart b/packages/flutter/lib/src/widgets/focus.dart deleted file mode 100644 index 786d42842d8..00000000000 --- a/packages/flutter/lib/src/widgets/focus.dart +++ /dev/null @@ -1,386 +0,0 @@ -// Copyright 2015 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -import 'basic.dart'; -import 'framework.dart'; -import 'media_query.dart'; -import 'scrollable.dart'; - -// _noFocusedScope is used by Focus to track the case where none of the Focus -// widget's subscopes (e.g. dialogs) are focused. This is distinct from the -// focused scope being null, which means that we haven't yet decided which scope -// is focused and whichever is the first scope to ask for focus will get it. -final GlobalKey _noFocusedScope = new GlobalKey(); - -class _FocusScope extends InheritedWidget { - _FocusScope({ - Key key, - this.focusState, - @required this.scopeFocused, - this.focusedScope, - this.focusedWidget, - @required Widget child, - }) : super(key: key, child: child) { - assert(scopeFocused != null); - } - - /// The state for this focus scope. - /// - /// This widget is always our direct parent widget. - final _FocusState focusState; - - /// Whether this scope is focused in our ancestor focus scope. - final bool scopeFocused; - - // These are mutable because we implicitly change them when they're null in - // certain cases, basically pretending retroactively that we were constructed - // with the right keys. - - /// Which of our descendant scopes is focused, if any. - GlobalKey focusedScope; - - /// Which of our descendant widgets is focused, if any. - GlobalKey focusedWidget; - - // The _setFocusedWidgetIfUnset() methodsdon't need to notify descendants - // because by definition they are only going to make a change the very first - // time that our state is checked. - - void _setFocusedWidgetIfUnset(GlobalKey key) { - focusState._setFocusedWidgetIfUnset(key); - focusedWidget = focusState._focusedWidget; - focusedScope = focusState._focusedScope == _noFocusedScope ? null : focusState._focusedScope; - } - - @override - bool updateShouldNotify(_FocusScope oldWidget) { - if (scopeFocused != oldWidget.scopeFocused) - return true; - if (!scopeFocused) - return false; - if (focusedScope != oldWidget.focusedScope) - return true; - if (focusedScope != null) - return false; - if (focusedWidget != oldWidget.focusedWidget) - return true; - return false; - } - - @override - void debugFillDescription(List description) { - super.debugFillDescription(description); - if (scopeFocused) - description.add('this scope has focus'); - if (focusedScope != null) - description.add('focused subscope: $focusedScope'); - if (focusedWidget != null) - description.add('focused widget: $focusedWidget'); - } -} - -/// A scope for managing the focus state of descendant widgets. -/// -/// The focus represents where the user's attention is directed. If the use -/// interacts with the system in a way that isn't visually directed at a -/// particular widget (e.g., by typing on a keyboard), the interaction is -/// directed to the currently focused widget. -/// -/// The focus system consists of a tree of Focus widgets, which is embedded in -/// the widget tree. Focus widgets themselves can be focused in their enclosing -/// Focus widget, which means that their subtree is the one that has the current -/// focus. For example, a dialog creates a Focus widget to maintain focus -/// within the dialog. When the dialog closes, its Focus widget is removed from -/// the tree and focus is restored to whichever other part of the Focus tree -/// previously had focus. -/// -/// In addition to tracking which enclosed Focus widget has focus, each Focus -/// widget also tracks a GlobalKey, which represents the currently focused -/// widget in this part of the focus tree. If this Focus widget is the currently -/// focused subtree of the focus system (i.e., the path from it to the root is -/// focused at each level and it hasn't focused any of its enclosed Focus -/// widgets), then the widget with this global key actually has the focus in the -/// entire system. -class Focus extends StatefulWidget { - /// Creates a scope for managing focus. - /// - /// The [key] argument must not be null. - Focus({ - @required GlobalKey key, - this.initiallyFocusedScope, - @required this.child, - }) : super(key: key) { - assert(key != null); - } - - /// The global key of the [Focus] widget below this widget in the tree that - /// will be focused initially. - /// - /// If non-null, a [Focus] widget with this key must be added to the tree - /// before the end of the current microtask in which the [Focus] widget was - /// initially constructed. - final GlobalKey initiallyFocusedScope; - - /// The widget below this widget in the tree. - final Widget child; - - /// The key that currently has focus globally in the entire focus tree. - /// - /// This field is always null except in checked mode. - static GlobalKey debugOnlyFocusedKey; - - /// Whether the focus is current at the given context. - /// - /// If autofocus is true, the given context will become focused if no other - /// widget is already focused. - static bool at(BuildContext context, { bool autofocus: false }) { - assert(context != null); - assert(context.widget != null); - assert(context.widget.key != null); - assert(context.widget.key is GlobalKey); - final _FocusScope focusScope = context.inheritFromWidgetOfExactType(_FocusScope); - if (focusScope != null) { - if (autofocus) - focusScope._setFocusedWidgetIfUnset(context.widget.key); - return focusScope.scopeFocused && - focusScope.focusedScope == null && - focusScope.focusedWidget == context.widget.key; - } - assert(() { - if (debugOnlyFocusedKey?.currentContext == null) - debugOnlyFocusedKey = context.widget.key; - if (debugOnlyFocusedKey != context.widget.key) { - throw new FlutterError( - 'Missing Focus scope.\n' - 'Two focusable widgets with different keys, $debugOnlyFocusedKey and ${context.widget.key}, ' - 'exist in the widget tree simultaneously, but they have no Focus widget ancestor.\n' - 'If you have more than one focusable widget, then you should put them inside a Focus. ' - 'Normally, this is done for you using a Route, via Navigator, WidgetsApp, or MaterialApp.' - ); - } - return true; - }); - return true; - } - - static bool _atScope(BuildContext context) { - assert(context != null); - assert(context.widget != null); - assert(context.widget is Focus); - assert(context.widget.key != null); - final _FocusScope focusScope = context.inheritFromWidgetOfExactType(_FocusScope); - if (focusScope != null) { - return focusScope.scopeFocused && - focusScope.focusedScope == context.widget.key; - } - return true; - } - - /// Focuses a particular widget, identified by its GlobalKey. - /// The widget must be in the widget tree. - /// - /// Don't call moveTo() from your build() functions, it's intended to be - /// called from event listeners, e.g. in response to a finger tap or tab key. - static void moveTo(GlobalKey key) { - final BuildContext focusedContext = key.currentContext; - assert(focusedContext != null); - final _FocusScope focusScope = key.currentContext.ancestorWidgetOfExactType(_FocusScope); - if (focusScope != null) { - focusScope.focusState._setFocusedWidget(key); - Scrollable.ensureVisible(focusedContext); - } - } - - /// Unfocuses the currently focused widget (if any) in the Focus that most - /// tightly encloses the given context. - static void clear(BuildContext context) { - final _FocusScope focusScope = context.ancestorWidgetOfExactType(_FocusScope); - if (focusScope != null) - focusScope.focusState._clearFocusedWidget(); - } - - /// Focuses a particular focus scope, identified by its GlobalKey. - /// - /// Don't call moveScopeTo() from your build() functions, it's intended to be - /// called from event listeners, e.g. in response to a finger tap or tab key. - static void moveScopeTo(GlobalKey key, { BuildContext context }) { - _FocusScope focusScope; - final BuildContext searchContext = key.currentContext; - if (searchContext != null) { - assert(key.currentWidget is Focus); - focusScope = searchContext.ancestorWidgetOfExactType(_FocusScope); - assert(context == null || focusScope == context.ancestorWidgetOfExactType(_FocusScope)); - } else { - focusScope = context.ancestorWidgetOfExactType(_FocusScope); - } - if (focusScope != null) - focusScope.focusState._setFocusedScope(key); - } - - @override - _FocusState createState() => new _FocusState(); -} - -class _FocusState extends State { - @override - void initState() { - super.initState(); - _focusedScope = config.initiallyFocusedScope; - _updateWidgetRemovalListener(_focusedWidget); - _updateScopeRemovalListener(_focusedScope); - - assert(() { - if (_focusedScope != null) - scheduleMicrotask(_debugCheckInitiallyFocusedScope); - return true; - }); - } - - @override - void dispose() { - _updateWidgetRemovalListener(null); - _updateScopeRemovalListener(null); - super.dispose(); - } - - void _debugCheckInitiallyFocusedScope() { - assert(config.initiallyFocusedScope != null); - assert(() { - if (!mounted) - return true; - final Widget widget = config.initiallyFocusedScope.currentWidget; - if (widget == null) { - throw new FlutterError( - 'The initially focused scope is not in the tree.\n' - 'When a Focus widget is given an initially focused scope, that focus ' - 'scope must be added to the tree before the end of the microtask in ' - 'which the Focus widget was first built. However, it is the end of ' - 'the microtask and ${config.initiallyFocusedScope} is not in the ' - 'tree.' - ); - } - if (widget is! Focus) { - throw new FlutterError( - 'The initially focused scope was not a Focus widget.\n' - 'The initially focused scope for a Focus widget must be another ' - 'Focus widget. Instead, the initially focused scope was a ' - '${widget.runtimeType} widget.' - ); - } - return true; - }); - } - - GlobalKey _focusedWidget; // when null, the first widget to ask if it's focused will get the focus - GlobalKey _currentlyRegisteredWidgetRemovalListenerKey; - - void _setFocusedWidget(GlobalKey key) { - setState(() { - _focusedWidget = key; - if (_focusedScope == null) - _focusedScope = _noFocusedScope; - }); - _updateWidgetRemovalListener(key); - } - - void _setFocusedWidgetIfUnset(GlobalKey key) { - if (_focusedWidget == null && (_focusedScope == null || _focusedScope == _noFocusedScope)) { - _focusedWidget = key; - _focusedScope = _noFocusedScope; - _updateWidgetRemovalListener(key); - } - } - - void _clearFocusedWidget() { - if (_focusedWidget != null) { - _updateWidgetRemovalListener(null); - setState(() { - _focusedWidget = null; - }); - } - } - - void _handleWidgetRemoved(GlobalKey key) { - assert(key != null); - assert(_focusedWidget == key); - _clearFocusedWidget(); - } - - void _updateWidgetRemovalListener(GlobalKey key) { - if (_currentlyRegisteredWidgetRemovalListenerKey != key) { - if (_currentlyRegisteredWidgetRemovalListenerKey != null) - GlobalKey.unregisterRemoveListener(_currentlyRegisteredWidgetRemovalListenerKey, _handleWidgetRemoved); - if (key != null) - GlobalKey.registerRemoveListener(key, _handleWidgetRemoved); - _currentlyRegisteredWidgetRemovalListenerKey = key; - } - } - - GlobalKey _focusedScope; // when null, the first scope to ask if it's focused will get the focus - GlobalKey _currentlyRegisteredScopeRemovalListenerKey; - - void _setFocusedScope(GlobalKey key) { - setState(() { - _focusedScope = key; - }); - _updateScopeRemovalListener(key); - } - - void _scopeRemoved(GlobalKey key) { - assert(_focusedScope == key); - GlobalKey.unregisterRemoveListener(_currentlyRegisteredScopeRemovalListenerKey, _scopeRemoved); - _currentlyRegisteredScopeRemovalListenerKey = null; - setState(() { - _focusedScope = null; - }); - } - - void _updateScopeRemovalListener(GlobalKey key) { - if (_currentlyRegisteredScopeRemovalListenerKey != key) { - if (_currentlyRegisteredScopeRemovalListenerKey != null) - GlobalKey.unregisterRemoveListener(_currentlyRegisteredScopeRemovalListenerKey, _scopeRemoved); - if (key != null) - GlobalKey.registerRemoveListener(key, _scopeRemoved); - _currentlyRegisteredScopeRemovalListenerKey = key; - } - } - - Size _mediaSize; - EdgeInsets _mediaPadding; - - void _ensureVisibleIfFocused() { - if (!Focus._atScope(context)) - return; - final BuildContext focusedContext = _focusedWidget?.currentContext; - if (focusedContext == null) - return; - Scrollable.ensureVisible(focusedContext); - } - - @override - Widget build(BuildContext context) { - final MediaQueryData data = MediaQuery.of(context); - final Size newMediaSize = data.size; - final EdgeInsets newMediaPadding = data.padding; - if (newMediaSize != _mediaSize || newMediaPadding != _mediaPadding) { - _mediaSize = newMediaSize; - _mediaPadding = newMediaPadding; - scheduleMicrotask(_ensureVisibleIfFocused); - } - return new Semantics( - container: true, - child: new _FocusScope( - focusState: this, - scopeFocused: Focus._atScope(context), - focusedScope: _focusedScope == _noFocusedScope ? null : _focusedScope, - focusedWidget: _focusedWidget, - child: config.child - ) - ); - } -} diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart new file mode 100644 index 00000000000..b6862119b0d --- /dev/null +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -0,0 +1,422 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// A leaf node in the focus tree that can receive focus. +/// +/// The focus tree keeps track of which widget is the user's current focus. The +/// focused widget often listens for keyboard events. +/// +/// To request focus, find the [FocusScopeNode] for the current [BuildContext] +/// and call the [FocusScopeNode.requestFocus] method: +/// +/// ```dart +/// FocusScope.of(context).requestFocus(focusNode); +/// ``` +/// +/// If your widget requests focus, be sure to call +/// `FocusScope.of(context).reparentIfNeeded(focusNode);` in your `build` +/// method to reparent your [FocusNode] if your widget moves from one +/// location in the tree to another. +/// +/// See also: +/// +/// * [FocusScopeNode], which is an interior node in the focus tree. +/// * [FocusScope.of], which provides the [FocusScopeNode] for a given +/// [BuildContext]. +class FocusNode extends ChangeNotifier { + FocusScopeNode _parent; + FocusManager _manager; + + /// Whether this node has the overall focus. + /// + /// A [FocusNode] has the overall focus when the node is focused in its + /// parent [FocusScopeNode] and [FocusScopeNode.isFirstFocus] is true for + /// that scope and all its ancestor scopes. + /// + /// To request focus, find the [FocusScopeNode] for the current [BuildContext] + /// and call the [FocusScopeNode.requestFocus] method: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(focusNode); + /// ``` + /// + /// This object notifies its listeners whenever this value changes. + bool get hasFocus => _manager?._currentFocus == this; + + /// Cancels any oustanding requests for focus. + /// + /// This method is safe to call regardless of whether this node has ever + /// requested focus. + void unfocus() { + _parent?._resignFocus(this); + assert(_parent == null); + assert(_manager == null); + } + + @override + void dispose() { + _manager?._willDisposeFocusNode(this); + _parent?._resignFocus(this); + assert(_parent == null); + assert(_manager == null); + super.dispose(); + } + + void _notify() { + notifyListeners(); + } + + @override + String toString() => '$runtimeType#$hashCode${hasFocus ? '(FOCUSED)' : ''}'; +} + +/// An interior node in the focus tree. +/// +/// The focus tree keeps track of which widget is the user's current focus. The +/// focused widget often listens for keyboard events. +/// +/// The interior nodes in the focus tree cannot themselves be focused but +/// instead remember previous focus states. A scope is currently active in its +/// parent whenever [isFirstFocus] is true. If that scope is detached from its +/// parent, its previous sibling becomes the parent's first focus. +/// +/// A [FocusNode] has the overall focus when the node is focused in its +/// parent [FocusScopeNode] and [FocusScopeNode.isFirstFocus] is true for +/// that scope and all its ancestor scopes. +/// +/// See also: +/// +/// * [FocusNode], which is a leaf node in the focus tree that can receive +/// focus. +/// * [FocusScope.of], which provides the [FocusScopeNode] for a given +/// [BuildContext]. +/// * [FocusScope], which is a widget that associates a [FocusScopeNode] with +/// its location in the tree. +class FocusScopeNode extends Object with TreeDiagnosticsMixin { + FocusManager _manager; + FocusScopeNode _parent; + + FocusScopeNode _nextSibling; + FocusScopeNode _previousSibling; + + FocusScopeNode _firstChild; + FocusScopeNode _lastChild; + + FocusNode _focus; + + /// Whether this scope is currently active in its parent scope. + bool get isFirstFocus => _parent == null || _parent._firstChild == this; + + void _prepend(FocusScopeNode child) { + assert(child != this); + assert(child != _firstChild); + assert(child != _lastChild); + assert(child._parent == null); + assert(child._manager == null); + assert(child._nextSibling == null); + assert(child._previousSibling == null); + assert(() { + FocusScopeNode node = this; + while (node._parent != null) + node = node._parent; + assert(node != child); // indicates we are about to create a cycle + return true; + }); + child._parent = this; + child._nextSibling = _firstChild; + if (_firstChild != null) + _firstChild._previousSibling = child; + _firstChild = child; + if (_lastChild == null) + _lastChild = child; + child._updateManager(_manager); + } + + void _updateManager(FocusManager manager) { + void update(FocusScopeNode child) { + if (child._manager == manager) + return; + child._manager = manager; + // We don't proactively null out the manager for FocusNodes because the + // manager holds the currently active focus node until the end of the + // microtask, even if that node is detached from the focus tree. + if (manager != null) + child._focus?._manager = manager; + child._visitChildren(update); + } + + update(this); + } + + void _visitChildren(void visitor(FocusScopeNode child)) { + FocusScopeNode child = _firstChild; + while (child != null) { + visitor(child); + child = child._nextSibling; + } + } + + bool _debugUltimatePreviousSiblingOf(FocusScopeNode child, { FocusScopeNode equals }) { + while (child._previousSibling != null) { + assert(child._previousSibling != child); + child = child._previousSibling; + } + return child == equals; + } + + bool _debugUltimateNextSiblingOf(FocusScopeNode child, { FocusScopeNode equals }) { + while (child._nextSibling != null) { + assert(child._nextSibling != child); + child = child._nextSibling; + } + return child == equals; + } + + void _remove(FocusScopeNode child) { + assert(child._parent == this); + assert(child._manager == _manager); + assert(_debugUltimatePreviousSiblingOf(child, equals: _firstChild)); + assert(_debugUltimateNextSiblingOf(child, equals: _lastChild)); + if (child._previousSibling == null) { + assert(_firstChild == child); + _firstChild = child._nextSibling; + } else { + child._previousSibling._nextSibling = child._nextSibling; + } + if (child._nextSibling == null) { + assert(_lastChild == child); + _lastChild = child._previousSibling; + } else { + child._nextSibling._previousSibling = child._previousSibling; + } + child._previousSibling = null; + child._nextSibling = null; + child._parent = null; + child._updateManager(null); + } + + void _didChangeFocusChain() { + if (isFirstFocus) + _manager?._markNeedsUpdate(); + } + + /// Requests that the given node becomes the focus for this scope. + /// + /// If the given node is currently focused in another scope, the node will + /// first be unfocused in that scope. + /// + /// The node will receive the overall focus if this [isFirstFocus] is true + /// in this scope and all its ancestor scopes. The node is notified that it + /// has received the overall focus in a microtask. + void requestFocus(FocusNode node) { + assert(node != null); + if (_focus == node) + return; + assert(node._parent == null); + _focus?.unfocus(); + assert(_focus == null); + _focus = node; + _focus._parent = this; + _focus._manager = _manager; + _didChangeFocusChain(); + } + + /// If this scope lacks a focus, request that the given node becomes the + /// focus. + /// + /// Useful for widgets that wish to grab the focus if no other widget already + /// has the focus. + /// + /// The node is notified that it has received the overall focus in a + /// microtask. + void autofocus(FocusNode node) { + assert(node != null); + if (_focus == null) + requestFocus(node); + } + + /// Adopts the given node if it is focused in another scope. + /// + /// A widget that requests that a node is focused should call this method + /// during its `build` method in case the widget is moved from one location + /// in the tree to another location that has a different focus scope. + void reparentIfNeeded(FocusNode node) { + assert(node != null); + if (node._parent == null || node._parent == this) + return; + node.unfocus(); + assert(node._parent == null); + autofocus(node); + } + + void _resignFocus(FocusNode node) { + assert(node != null); + if (_focus != node) + return; + _focus._parent = null; + _focus._manager = null; + _focus = null; + _didChangeFocusChain(); + } + + /// Makes the given child the first focus of this scope. + /// + /// If the child has another parent scope, the child is first removed from + /// that scope. After this method returns [isFirstFocus] will be true for + /// the child. + void setFirstFocus(FocusScopeNode child) { + assert(child != null); + assert(child._parent == null || child._parent == this); + if (_firstChild == child) + return; + child.detach(); + _prepend(child); + assert(child._parent == this); + _didChangeFocusChain(); + } + + /// Adopts the given scope if it is the first focus of another scope. + /// + /// A widget that sets a scope as the first focus of another scope should + /// call this method during its `build` method in case the widget is moved + /// from one location in the tree to another location that has a different + /// focus scope. + /// + /// If the given scope is not the first focus of its old parent, the scope + /// is simply detached from its old parent. + void reparentScopeIfNeeded(FocusScopeNode child) { + assert(child != null); + if (child._parent == null || child._parent == this) + return; + if (child.isFirstFocus) + setFirstFocus(child); + else + child.detach(); + } + + /// Remove this scope from its parent child list. + /// + /// This method is safe to call even if this scope does not have a parent. + /// + /// A widget that sets a scope as the first focus of another scope should + /// call this method during [State.dispose] to avoid leaving dangling + /// children in their parent scope. + void detach() { + _didChangeFocusChain(); + _parent?._remove(this); + assert(_parent == null); + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (_focus != null) + description.add('focus: $_focus'); + } + + @override + String debugDescribeChildren(String prefix) { + final StringBuffer buffer = new StringBuffer(); + if (_firstChild != null) { + FocusScopeNode child = _firstChild; + int count = 1; + while (child != _lastChild) { + buffer.write(child.toStringDeep("$prefix \u251C\u2500child $count: ", "$prefix \u2502")); + count += 1; + child = child._nextSibling; + } + if (child != null) { + assert(child == _lastChild); + buffer.write(child.toStringDeep("$prefix \u2514\u2500child $count: ", "$prefix ")); + } + } + return buffer.toString(); + } +} + +/// Manages the focus tree. +/// +/// The focus tree keeps track of which widget is the user's current focus. The +/// focused widget often listens for keyboard events. +/// +/// The focus manager is responsible for holding the [FocusScopeNode] that is +/// the root of the focus tree and tracking which [FocusNode] has the overall +/// focus. +/// +/// The [FocusManager] is held by the [WidgetBinding] as +/// [WidgetBinding.focusManager]. The [FocusManager] is rarely accessed +/// directly. Instead, to find the [FocusScopeNode] for a given [BuildContext], +/// use [FocusScope.of]. +/// +/// See also: +/// +/// * [FocusNode], which is a leaf node in the focus tree that can receive +/// focus. +/// * [FocusScopeNode], which is an interior node in the focus tree. +/// * [FocusScope.of], which provides the [FocusScopeNode] for a given +/// [BuildContext]. +class FocusManager { + /// Creates an object that manages the focus tree. + /// + /// This constructor is rarely called directly. To access the [FocusManager], + /// consider using [WidgetBinding.focusManager] instead. + FocusManager() { + rootScope._manager = this; + assert(rootScope._firstChild == null); + assert(rootScope._lastChild == null); + } + + /// The root [FocusScopeNode] in the focus tree. + /// + /// This field is rarely used direction. Instead, to find the + /// [FocusScopeNode] for a given [BuildContext], use [FocusScope.of]. + final FocusScopeNode rootScope = new FocusScopeNode(); + + FocusNode _currentFocus; + + void _willDisposeFocusNode(FocusNode node) { + assert(node != null); + if (_currentFocus == node) + _currentFocus = null; + } + + bool _haveScheduledUpdate = false; + void _markNeedsUpdate() { + if (_haveScheduledUpdate) + return; + _haveScheduledUpdate = true; + scheduleMicrotask(_update); + } + + FocusNode _findNextFocus() { + FocusScopeNode scope = rootScope; + while (scope._firstChild != null) + scope = scope._firstChild; + return scope._focus; + } + + void _update() { + _haveScheduledUpdate = false; + final FocusNode nextFocus = _findNextFocus(); + if (_currentFocus == nextFocus) + return; + final FocusNode previousFocus = _currentFocus; + _currentFocus = nextFocus; + previousFocus?._notify(); + _currentFocus?._notify(); + } + + @override + String toString() { + final String status = _haveScheduledUpdate ? ' UPDATE SCHEDULED' : ''; + final String indent = ' '; + return '$runtimeType#$hashCode$status\n' + '${indent}currentFocus: $_currentFocus\n' + '${rootScope.toStringDeep(indent, indent)}'; + } +} diff --git a/packages/flutter/lib/src/widgets/focus_scope.dart b/packages/flutter/lib/src/widgets/focus_scope.dart new file mode 100644 index 00000000000..efa67ea498a --- /dev/null +++ b/packages/flutter/lib/src/widgets/focus_scope.dart @@ -0,0 +1,114 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import 'basic.dart'; +import 'binding.dart'; +import 'focus_manager.dart'; +import 'framework.dart'; + +class _FocusScopeMarker extends InheritedWidget { + _FocusScopeMarker({ + Key key, + @required this.node, + Widget child, + }) : super(key: key, child: child) { + assert(node != null); + } + + final FocusScopeNode node; + + @override + bool updateShouldNotify(_FocusScopeMarker oldWidget) { + return node != oldWidget.node; + } +} + +/// Establishes a scope in which widgets can receive focus. +/// +/// The focus tree keeps track of which widget is the user's current focus. The +/// focused widget often listens for keyboard events. +/// +/// The a focus scope does not itself receive focus but instead helps remember +/// previous focus states. A scope is currently active when its [node] is the +/// first focus of its parent scope. To activate a [FocusScope], either use the +/// [autofocus] property or explicitly make the [node] the first focus in the +/// parent scope: +/// +/// ```dart +/// FocusScope.of(context).setFirstFocus(node); +/// ``` +/// +/// When a [FocusScope] is removed from the tree, the previously active +/// [FocusScope] becomes active again. +/// +/// See also: +/// +/// * [FocusScopeNode], which is the associated node in the focus tree. +/// * [FocusNode], which is a leaf node in the focus tree that can receive +/// focus. +class FocusScope extends StatefulWidget { + /// Creates a scope in which widgets can receive focus. + /// + /// The [node] argument must not be null. + FocusScope({ + Key key, + @required this.node, + this.autofocus: false, + this.child, + }) : super(key: key) { + assert(node != null); + assert(autofocus != null); + } + + /// Controls whether this scope is currently active. + final FocusScopeNode node; + + /// Whether this scope should attempt to become active when first added to + /// the tree. + final bool autofocus; + + /// The widget below this widget in the tree. + final Widget child; + + static FocusScopeNode of(BuildContext context) { + final _FocusScopeMarker scope = context.inheritFromWidgetOfExactType(_FocusScopeMarker); + return scope?.node ?? WidgetsBinding.instance.focusManager.rootScope; + } + + @override + _FocusScopeState createState() => new _FocusScopeState(); +} + +class _FocusScopeState extends State { + bool _didAutofocus = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_didAutofocus && config.autofocus) { + FocusScope.of(context).setFirstFocus(config.node); + _didAutofocus = true; + } + } + + @override + void dispose() { + config.node.detach(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + FocusScope.of(context).reparentScopeIfNeeded(config.node); + return new Semantics( + container: true, + child: new _FocusScopeMarker( + node: config.node, + child: config.child, + ), + ); + } +} diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index ee52518b58a..1db776f43a8 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -9,7 +9,8 @@ import 'package:flutter/rendering.dart'; import 'basic.dart'; import 'binding.dart'; -import 'focus.dart'; +import 'focus_manager.dart'; +import 'focus_scope.dart'; import 'framework.dart'; import 'overlay.dart'; import 'ticker_provider.dart'; @@ -28,12 +29,6 @@ abstract class Route { /// The overlay entries for this route. List get overlayEntries => const []; - /// The key this route will use for its root [Focus] widget, if any. - /// - /// If this route is the first route shown by the navigator, the navigator - /// will initialize its [Focus] to this key. - GlobalKey get focusKey => null; - /// A future that completes when this route is popped off the navigator. /// /// The future completes with the value given to [Navigator.pop], if any. @@ -700,6 +695,9 @@ class NavigatorState extends State with TickerProviderStateMixin { final List> _history = >[]; final Set> _poppedRoutes = new Set>(); + /// The [FocusScopeNode] for the [FocusScope] that encloses the routes. + final FocusScopeNode focusScopeNode = new FocusScopeNode(); + @override void initState() { super.initState(); @@ -736,6 +734,7 @@ class NavigatorState extends State with TickerProviderStateMixin { route.dispose(); _poppedRoutes.clear(); _history.clear(); + focusScopeNode.detach(); super.dispose(); assert(() { _debugLocked = false; return true; }); } @@ -1112,10 +1111,6 @@ class NavigatorState extends State with TickerProviderStateMixin { WidgetsBinding.instance.cancelPointer(pointer); } - // TODO(abarth): We should be able to take a focusScopeKey as configuration - // information in case our parent wants to control whether we are focused. - final GlobalKey _focusScopeKey = new GlobalKey(); - @override Widget build(BuildContext context) { assert(!_debugLocked); @@ -1127,9 +1122,9 @@ class NavigatorState extends State with TickerProviderStateMixin { onPointerCancel: _handlePointerUpOrCancel, child: new AbsorbPointer( absorbing: false, - child: new Focus( - key: _focusScopeKey, - initiallyFocusedScope: initialRoute.focusKey, + child: new FocusScope( + node: focusScopeNode, + autofocus: true, child: new Overlay( key: _overlayKey, initialEntries: initialRoute.overlayEntries, diff --git a/packages/flutter/lib/src/widgets/raw_keyboard_listener.dart b/packages/flutter/lib/src/widgets/raw_keyboard_listener.dart index 2825aaae503..abc54913973 100644 --- a/packages/flutter/lib/src/widgets/raw_keyboard_listener.dart +++ b/packages/flutter/lib/src/widgets/raw_keyboard_listener.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'basic.dart'; +import 'focus_manager.dart'; import 'framework.dart'; /// A widget that calls a callback whenever the user presses or releases a key @@ -29,18 +30,16 @@ class RawKeyboardListener extends StatefulWidget { /// on-screen keyboards and input method editors (IMEs). RawKeyboardListener({ Key key, - this.focused: false, - this.onKey, + @required this.focusNode, + @required this.onKey, @required this.child, }) : super(key: key) { + assert(focusNode != null); assert(child != null); } - /// Whether this widget should actually listen for raw keyboard events. - /// - /// Typically set to the value returned by [Focus.at] for the [GlobalKey] of - /// the widget that builds the raw keyboard listener. - final bool focused; + /// Controls whether this widget has keyboard focus. + final FocusNode focusNode; /// Called whenever this widget receives a raw keyboard event. final ValueChanged onKey; @@ -56,22 +55,26 @@ class _RawKeyboardListenerState extends State { @override void initState() { super.initState(); - _attachOrDetachKeyboard(); + config.focusNode.addListener(_handleFocusChanged); } @override void didUpdateConfig(RawKeyboardListener oldConfig) { - _attachOrDetachKeyboard(); + if (config.focusNode != oldConfig.focusNode) { + oldConfig.focusNode.removeListener(_handleFocusChanged); + config.focusNode.addListener(_handleFocusChanged); + } } @override void dispose() { + config.focusNode.removeListener(_handleFocusChanged); _detachKeyboardIfAttached(); super.dispose(); } - void _attachOrDetachKeyboard() { - if (config.focused) + void _handleFocusChanged() { + if (config.focusNode.hasFocus) _attachKeyboardIfDetached(); else _detachKeyboardIfAttached(); diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index 8ae45ad7072..5d4ccbe6de0 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -7,7 +7,8 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'basic.dart'; -import 'focus.dart'; +import 'focus_manager.dart'; +import 'focus_scope.dart'; import 'framework.dart'; import 'modal_barrier.dart'; import 'navigator.dart'; @@ -449,8 +450,8 @@ class _ModalScopeState extends State<_ModalScope> { @override Widget build(BuildContext context) { - return new Focus( - key: config.route.focusKey, + return new FocusScope( + node: config.route.focusScopeNode, child: new Offstage( offstage: config.route.offstage, child: new IgnorePointer( @@ -575,8 +576,8 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute new GlobalObjectKey(this); + /// The node this route will use for its root [FocusScope] widget. + final FocusScopeNode focusScopeNode = new FocusScopeNode(); @override void install(OverlayEntry insertionPoint) { @@ -587,27 +588,14 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute didPush() { - if (!settings.isInitialRoute) { - final BuildContext overlayContext = navigator.overlay?.context; - assert(() { - if (overlayContext == null) { - throw new FlutterError( - 'Unable to find the BuildContext for the Navigator\'s overlay.\n' - 'Did you remember to pass the settings object to the route\'s ' - 'constructor in your onGenerateRoute callback?' - ); - } - return true; - }); - Focus.moveScopeTo(focusKey, context: overlayContext); - } + navigator.focusScopeNode.setFirstFocus(focusScopeNode); return super.didPush(); } @override - void didPopNext(Route nextRoute) { - Focus.moveScopeTo(focusKey, context: navigator.overlay.context); - super.didPopNext(nextRoute); + void dispose() { + focusScopeNode.detach(); + super.dispose(); } // The API for subclasses to override - used by this class diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index ec894d21b23..2fe1aee917e 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -21,7 +21,8 @@ export 'src/widgets/debug.dart'; export 'src/widgets/dismissible.dart'; export 'src/widgets/drag_target.dart'; export 'src/widgets/editable_text.dart'; -export 'src/widgets/focus.dart'; +export 'src/widgets/focus_manager.dart'; +export 'src/widgets/focus_scope.dart'; export 'src/widgets/form.dart'; export 'src/widgets/framework.dart'; export 'src/widgets/gesture_detector.dart'; diff --git a/packages/flutter/test/foundation/tree_diagnostics_mixin_test.dart b/packages/flutter/test/foundation/tree_diagnostics_mixin_test.dart new file mode 100644 index 00000000000..b2a19f2c351 --- /dev/null +++ b/packages/flutter/test/foundation/tree_diagnostics_mixin_test.dart @@ -0,0 +1,58 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:test/test.dart'; + +class TestTree extends Object with TreeDiagnosticsMixin { + TestTree({ + this.name, + this.children: const [], + }); + + final String name; + final List children; + + @override + String debugDescribeChildren(String prefix) { + final StringBuffer buffer = new StringBuffer(); + for (TestTree child in children) + buffer.write(child.toStringDeep('$prefix \u251C\u2500child ${child.name}: ', '$prefix \u2502')); + return buffer.toString(); + } +} + +void main() { + test('TreeDiagnosticsMixin control test', () async { + final TestTree tree = new TestTree( + children: [ + new TestTree(name: 'node A'), + new TestTree( + name: 'node B', + children: [ + new TestTree(name: 'node B1'), + new TestTree(name: 'node B2'), + new TestTree(name: 'node B3'), + ], + ), + new TestTree(name: 'node C'), + ], + ); + + final String dump = tree.toStringDeep().replaceAll(new RegExp(r'#\d+'), ''); + expect(dump, equals('''TestTree + ├─child node A: TestTree + │ + ├─child node B: TestTree + │ ├─child node B1: TestTree + │ │ + │ ├─child node B2: TestTree + │ │ + │ ├─child node B3: TestTree + │ │ + ├─child node C: TestTree + │ +''')); + }); +} diff --git a/packages/flutter/test/material/app_test.dart b/packages/flutter/test/material/app_test.dart index fae37586f94..195337cc63c 100644 --- a/packages/flutter/test/material/app_test.dart +++ b/packages/flutter/test/material/app_test.dart @@ -39,16 +39,16 @@ void main() { }); testWidgets('Focus handling', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); + final FocusNode focusNode = new FocusNode(); await tester.pumpWidget(new MaterialApp( home: new Material( child: new Center( - child: new Input(key: inputKey, autofocus: true) + child: new Input(focusNode: focusNode, autofocus: true) ) ) )); - expect(Focus.at(inputKey.currentContext), isTrue); + expect(focusNode.hasFocus, isTrue); }); testWidgets('Can show grid without losing sync', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/will_pop_test.dart b/packages/flutter/test/material/will_pop_test.dart index b9e2d8847b6..7493f418b83 100644 --- a/packages/flutter/test/material/will_pop_test.dart +++ b/packages/flutter/test/material/will_pop_test.dart @@ -137,7 +137,7 @@ void main() { return new SampleForm( callback: () => new Future.value(willPopValue), ); - } + }, )); }, ), diff --git a/packages/flutter/test/widgets/focus_test.dart b/packages/flutter/test/widgets/focus_test.dart index b4eea38920a..e1e4a598915 100644 --- a/packages/flutter/test/widgets/focus_test.dart +++ b/packages/flutter/test/widgets/focus_test.dart @@ -5,75 +5,103 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/widgets.dart'; -class TestFocusable extends StatelessWidget { +class TestFocusable extends StatefulWidget { TestFocusable({ - GlobalKey key, + Key key, this.no, this.yes, - this.autofocus: true + this.autofocus: true, }) : super(key: key); final String no; final String yes; final bool autofocus; + @override + TestFocusableState createState() => new TestFocusableState(); +} + +class TestFocusableState extends State { + final FocusNode focusNode = new FocusNode(); + bool _didAutofocus = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_didAutofocus && config.autofocus) { + _didAutofocus = true; + FocusScope.of(context).autofocus(focusNode); + } + } + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final bool focused = Focus.at(context, autofocus: autofocus); return new GestureDetector( - onTap: () { Focus.moveTo(key); }, - child: new Text(focused ? yes : no) + onTap: () { FocusScope.of(context).requestFocus(focusNode); }, + child: new AnimatedBuilder( + animation: focusNode, + builder: (BuildContext context, Widget child) { + // print('focusNode.hasFocus = ${focusNode.hasFocus} ${focusNode.hashCode} ${focusNode.hasFocus ? config.yes : config.no}'); + return new Text(focusNode.hasFocus ? config.yes : config.no); + }, + ), ); } } void main() { testWidgets('Can have multiple focused children and they update accordingly', (WidgetTester tester) async { - final GlobalKey keyFocus = new GlobalKey(); - final GlobalKey keyA = new GlobalKey(); - final GlobalKey keyB = new GlobalKey(); await tester.pumpWidget( - new Focus( - key: keyFocus, - child: new Column( - children: [ - new TestFocusable( - key: keyA, - no: 'a', - yes: 'A FOCUSED' - ), - new TestFocusable( - key: keyB, - no: 'b', - yes: 'B FOCUSED' - ), - ] - ) - ) + new Column( + children: [ + new TestFocusable( + no: 'a', + yes: 'A FOCUSED', + ), + new TestFocusable( + no: 'b', + yes: 'B FOCUSED', + ), + ], + ), ); + + // Autofocus is delayed one frame. + await tester.pump(); + expect(find.text('a'), findsNothing); expect(find.text('A FOCUSED'), findsOneWidget); expect(find.text('b'), findsOneWidget); expect(find.text('B FOCUSED'), findsNothing); await tester.tap(find.text('A FOCUSED')); + await tester.idle(); await tester.pump(); expect(find.text('a'), findsNothing); expect(find.text('A FOCUSED'), findsOneWidget); expect(find.text('b'), findsOneWidget); expect(find.text('B FOCUSED'), findsNothing); await tester.tap(find.text('A FOCUSED')); + await tester.idle(); await tester.pump(); expect(find.text('a'), findsNothing); expect(find.text('A FOCUSED'), findsOneWidget); expect(find.text('b'), findsOneWidget); expect(find.text('B FOCUSED'), findsNothing); await tester.tap(find.text('b')); + await tester.idle(); await tester.pump(); expect(find.text('a'), findsOneWidget); expect(find.text('A FOCUSED'), findsNothing); expect(find.text('b'), findsNothing); expect(find.text('B FOCUSED'), findsOneWidget); await tester.tap(find.text('a')); + await tester.idle(); await tester.pump(); expect(find.text('a'), findsNothing); expect(find.text('A FOCUSED'), findsOneWidget); @@ -82,30 +110,27 @@ void main() { }); testWidgets('Can blur', (WidgetTester tester) async { - final GlobalKey keyFocus = new GlobalKey(); - final GlobalKey keyA = new GlobalKey(); await tester.pumpWidget( - new Focus( - key: keyFocus, - child: new TestFocusable( - key: keyA, - no: 'a', - yes: 'A FOCUSED', - autofocus: false - ) - ) + new TestFocusable( + no: 'a', + yes: 'A FOCUSED', + autofocus: false, + ), ); expect(find.text('a'), findsOneWidget); expect(find.text('A FOCUSED'), findsNothing); - Focus.moveTo(keyA); + final TestFocusableState state = tester.state(find.byType(TestFocusable)); + FocusScope.of(state.context).requestFocus(state.focusNode); + await tester.idle(); await tester.pump(); expect(find.text('a'), findsNothing); expect(find.text('A FOCUSED'), findsOneWidget); - Focus.clear(keyA.currentContext); + state.focusNode.unfocus(); + await tester.idle(); await tester.pump(); expect(find.text('a'), findsOneWidget); @@ -113,76 +138,77 @@ void main() { }); testWidgets('Can move focus to scope', (WidgetTester tester) async { - final GlobalKey keyParentFocus = new GlobalKey(); - final GlobalKey keyChildFocus = new GlobalKey(); - final GlobalKey keyA = new GlobalKey(); + final FocusScopeNode parentFocusScope = new FocusScopeNode(); + final FocusScopeNode childFocusScope = new FocusScopeNode(); + await tester.pumpWidget( - new Focus( - key: keyParentFocus, + new FocusScope( + node: parentFocusScope, + autofocus: true, child: new Row( children: [ new TestFocusable( - key: keyA, no: 'a', yes: 'A FOCUSED', - autofocus: false - ) - ] - ) - ) + autofocus: false, + ), + ], + ), + ), ); expect(find.text('a'), findsOneWidget); expect(find.text('A FOCUSED'), findsNothing); - Focus.moveTo(keyA); + final TestFocusableState state = tester.state(find.byType(TestFocusable)); + FocusScope.of(state.context).requestFocus(state.focusNode); + await tester.idle(); await tester.pump(); expect(find.text('a'), findsNothing); expect(find.text('A FOCUSED'), findsOneWidget); - Focus.moveScopeTo(keyChildFocus, context: keyA.currentContext); + parentFocusScope.setFirstFocus(childFocusScope); + await tester.idle(); await tester.pumpWidget( - new Focus( - key: keyParentFocus, + new FocusScope( + node: parentFocusScope, child: new Row( children: [ new TestFocusable( - key: keyA, no: 'a', yes: 'A FOCUSED', - autofocus: false + autofocus: false, ), - new Focus( - key: keyChildFocus, + new FocusScope( + node: childFocusScope, child: new Container( width: 50.0, - height: 50.0 - ) - ) - ] - ) - ) + height: 50.0, + ), + ), + ], + ), + ), ); expect(find.text('a'), findsOneWidget); expect(find.text('A FOCUSED'), findsNothing); await tester.pumpWidget( - new Focus( - key: keyParentFocus, + new FocusScope( + node: parentFocusScope, child: new Row( children: [ new TestFocusable( - key: keyA, no: 'a', yes: 'A FOCUSED', - autofocus: false - ) - ] - ) - ) + autofocus: false, + ), + ], + ), + ), ); // Focus has received the removal notification but we haven't rebuilt yet. @@ -193,5 +219,7 @@ void main() { expect(find.text('a'), findsNothing); expect(find.text('A FOCUSED'), findsOneWidget); + + parentFocusScope.detach(); }); } diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart index 8cf3ffd05b5..fb6119f347d 100644 --- a/packages/flutter/test/widgets/form_test.dart +++ b/packages/flutter/test/widgets/form_test.dart @@ -120,7 +120,6 @@ void main() { testWidgets('Multiple Inputs communicate', (WidgetTester tester) async { final GlobalKey formKey = new GlobalKey(); final GlobalKey> fieldKey = new GlobalKey>(); - final GlobalKey focusKey = new GlobalKey(); // Input 2's validator depends on a input 1's value. String errorText(InputValue input) => fieldKey.currentState.value?.text.toString() + '/error'; @@ -130,21 +129,18 @@ void main() { child: new Form( key: formKey, autovalidate: true, - child: new Focus( - key: focusKey, - child: new ListView( - children: [ - new TextField( - key: fieldKey - ), - new TextField( - validator: errorText, - ), - ] - ) + child: new ListView( + children: [ + new TextField( + key: fieldKey, + ), + new TextField( + validator: errorText, + ), + ], ), - ) - ) + ), + ), ); } diff --git a/packages/flutter/test/widgets/input_test.dart b/packages/flutter/test/widgets/input_test.dart index dddf3efc826..0632836bcb4 100644 --- a/packages/flutter/test/widgets/input_test.dart +++ b/packages/flutter/test/widgets/input_test.dart @@ -795,26 +795,22 @@ void main() { testWidgets('Input label text animates', (WidgetTester tester) async { final GlobalKey inputKey = new GlobalKey(); - final GlobalKey focusKey = new GlobalKey(); Widget innerBuilder() { return new Center( child: new Material( - child: new Focus( - key: focusKey, - child: new Column( - children: [ - new Input( - labelText: 'First' - ), - new Input( - key: inputKey, - labelText: 'Second' - ), - ] - ) - ) - ) + child: new Column( + children: [ + new Input( + labelText: 'First', + ), + new Input( + key: inputKey, + labelText: 'Second', + ), + ], + ), + ), ); } Widget builder() => overlay(innerBuilder()); @@ -825,6 +821,7 @@ void main() { // Focus the Input. The label should start animating upwards. await tester.tap(find.byKey(inputKey)); + await tester.idle(); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart index 68e2111ffe8..b03804ebcc3 100644 --- a/packages/flutter/test/widgets/navigator_test.dart +++ b/packages/flutter/test/widgets/navigator_test.dart @@ -177,18 +177,6 @@ void main() { expect('$exception', startsWith('Navigator operation requested with a context')); }); - testWidgets('Missing settings in onGenerateRoute throws exception', (WidgetTester tester) async { - await tester.pumpWidget(new Navigator( - onGenerateRoute: (RouteSettings settings) { - return new MaterialPageRoute( - builder: (BuildContext context) => new Container() - ); - } - )); - final Object exception = tester.takeException(); - expect(exception is FlutterError, isTrue); - }); - testWidgets('Gestures between push and build are ignored', (WidgetTester tester) async { final List log = []; final Map routes = { diff --git a/packages/flutter/test/widgets/raw_keyboard_listener_test.dart b/packages/flutter/test/widgets/raw_keyboard_listener_test.dart index a6540f24f5a..2c25ccdfebd 100644 --- a/packages/flutter/test/widgets/raw_keyboard_listener_test.dart +++ b/packages/flutter/test/widgets/raw_keyboard_listener_test.dart @@ -15,16 +15,20 @@ void sendFakeKeyEvent(Map data) { void main() { testWidgets('Can dispose without keyboard', (WidgetTester tester) async { - await tester.pumpWidget(new RawKeyboardListener(child: new Container())); - await tester.pumpWidget(new RawKeyboardListener(child: new Container())); + final FocusNode focusNode = new FocusNode(); + await tester.pumpWidget(new RawKeyboardListener(focusNode: focusNode, onKey: null, child: new Container())); + await tester.pumpWidget(new RawKeyboardListener(focusNode: focusNode, onKey: null, child: new Container())); await tester.pumpWidget(new Container()); }); testWidgets('Fuchsia key event', (WidgetTester tester) async { final List events = []; + final FocusNode focusNode = new FocusNode(); + tester.binding.focusManager.rootScope.requestFocus(focusNode); + await tester.pumpWidget(new RawKeyboardListener( - focused: true, + focusNode: focusNode, onKey: events.add, child: new Container(), )); @@ -46,14 +50,20 @@ void main() { expect(typedData.hidUsage, 0x04); expect(typedData.codePoint, 0x64); expect(typedData.modifiers, 0x08); + + await tester.pumpWidget(new Container()); + focusNode.dispose(); }); testWidgets('Defunct listeners do not receive events', (WidgetTester tester) async { final List events = []; + final FocusNode focusNode = new FocusNode(); + tester.binding.focusManager.rootScope.requestFocus(focusNode); + await tester.pumpWidget(new RawKeyboardListener( - focused: true, + focusNode: focusNode, onKey: events.add, child: new Container(), )); @@ -84,5 +94,8 @@ void main() { await tester.idle(); expect(events.length, 0); + + await tester.pumpWidget(new Container()); + focusNode.dispose(); }); }