Commit 89aaaa9c authored by Adam Barth's avatar Adam Barth Committed by GitHub

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.
parent 60e05e9a
......@@ -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<RawKeyboardDemo> {
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<RawKeyboardDemo> {
@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: <Widget>[
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: <Widget>[
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),
],
);
},
),
);
}
}
......@@ -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';
// 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<String> description = <String>[];
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<String> description) { }
/// Returns a description of this node's children for use by [toStringDeep].
@protected
String debugDescribeChildren(String prefix) => '';
}
......@@ -158,8 +158,7 @@ class DrawerControllerState extends State<DrawerController> 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<DrawerController> 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<DrawerController> 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<DrawerController> with SingleTickerPro
alignment: FractionalOffset.centerRight,
widthFactor: _controller.value,
child: new RepaintBoundary(
child: new Focus(
child: new FocusScope(
key: _drawerKey,
node: _focusScopeNode,
child: config.child
),
),
......
......@@ -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<InputField> {
final GlobalKey<EditableTextState> _editableTextKey = new GlobalKey<EditableTextState>();
final GlobalKey<EditableTextState> _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<InputField> {
final List<Widget> stackChildren = <Widget>[
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<Input> {
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<Input> {
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<Input> {
class TextField extends FormField<InputValue> {
TextField({
Key key,
GlobalKey focusKey,
FocusNode focusNode,
TextInputType keyboardType: TextInputType.text,
Icon icon,
String labelText,
......@@ -664,7 +675,7 @@ class TextField extends FormField<InputValue> {
validator: validator,
builder: (FormFieldState<InputValue> field) {
return new Input(
key: focusKey,
focusNode: focusNode,
keyboardType: keyboardType,
icon: icon,
labelText: labelText,
......
......@@ -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<String> description = <String>[];
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<String> 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]
......
......@@ -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<WidgetsBindingObserver> _observers = <WidgetsBindingObserver>[];
/// Registers the given object as a binding observer. Binding
......
......@@ -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<EditableText> 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<EditableText> 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<EditableText> 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<EditableText> implements TextInputClient {
}
_clearComposing();
}
_requestingFocus = false;
_didRequestKeyboard = false;
}
void _clearComposing() {
......@@ -286,10 +302,11 @@ class EditableTextState extends State<EditableText> 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<EditableText> implements TextInputClient {
@override
void performAction(TextInputAction action) {
_clearComposing();
Focus.clear(context);
config.focusNode.unfocus();
if (config.onSubmitted != null)
config.onSubmitted(_currentValue);
}
......@@ -369,6 +386,25 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
_cursorTimer = new Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick);
}
void _handleFocusChanged() {
final bool focused = config.focusNode.hasFocus;
_attachOrDetachKeyboard(focused);
if (_cursorTimer == null && focused && config.value.selection.isCollapsed)
_startCursorTimer();
else if (_cursorTimer != null && (!focused || !config.value.selection.isCollapsed))
_stopCursorTimer();
if (_selectionOverlay != null) {
if (focused) {
_selectionOverlay.update(config.value);
} else {
_selectionOverlay?.dispose();
_selectionOverlay = null;
}
}
}
@override
void dispose() {
if (_isAttachedToKeyboard) {
......@@ -381,6 +417,7 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
assert(_cursorTimer == null);
_selectionOverlay?.dispose();
_selectionOverlay = null;
config.focusNode.removeListener(_handleFocusChanged);
super.dispose();
}
......@@ -392,23 +429,7 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
@override
Widget build(BuildContext context) {
final bool focused = Focus.at(config.focusKey.currentContext);
_attachOrDetachKeyboard(focused);
if (_cursorTimer == null && focused && config.value.selection.isCollapsed)
_startCursorTimer();
else if (_cursorTimer != null && (!focused || !config.value.selection.isCollapsed))
_stopCursorTimer();
if (_selectionOverlay != null) {
if (focused) {
_selectionOverlay.update(config.value);
} else {
_selectionOverlay?.dispose();
_selectionOverlay = null;
}
}
FocusScope.of(context).reparentIfNeeded(config.focusNode);
return new Scrollable(
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
controller: _scrollController,
......
This diff is collapsed.
This diff is collapsed.
// 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<FocusScope> {
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,
),
);
}
}
......@@ -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<T> {
/// The overlay entries for this route.
List<OverlayEntry> get overlayEntries => const <OverlayEntry>[];
/// 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<Navigator> with TickerProviderStateMixin {
final List<Route<dynamic>> _history = <Route<dynamic>>[];
final Set<Route<dynamic>> _poppedRoutes = new Set<Route<dynamic>>();
/// 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<Navigator> 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<Navigator> 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<Navigator> 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,
......
......@@ -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<RawKeyEvent> onKey;
......@@ -56,22 +55,26 @@ class _RawKeyboardListenerState extends State<RawKeyboardListener> {
@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();
......
......@@ -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<T> extends TransitionRoute<T> with LocalHistoryRoute<T
return child;
}
@override
GlobalKey get focusKey => 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<T> extends TransitionRoute<T> with LocalHistoryRoute<T
@override
Future<Null> 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<dynamic> 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
......
......@@ -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';
......
// 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 <TestTree>[],
});
final String name;
final List<TestTree> 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: <TestTree>[
new TestTree(name: 'node A'),
new TestTree(
name: 'node B',
children: <TestTree>[
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
'''));
});
}
......@@ -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 {
......
......@@ -137,7 +137,7 @@ void main() {
return new SampleForm(
callback: () => new Future<bool>.value(willPopValue),
);
}
},
));
},
),
......
......@@ -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<TestFocusable> {
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: <Widget>[
new TestFocusable(
key: keyA,
no: 'a',
yes: 'A FOCUSED'
),
new TestFocusable(
key: keyB,
no: 'b',
yes: 'B FOCUSED'
),
]
)
)
new Column(
children: <Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
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();
});
}
......@@ -120,7 +120,6 @@ void main() {
testWidgets('Multiple Inputs communicate', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = new GlobalKey<FormState>();
final GlobalKey<FormFieldState<InputValue>> fieldKey = new GlobalKey<FormFieldState<InputValue>>();
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: <Widget>[
new TextField(
key: fieldKey
),
new TextField(
validator: errorText,
),
]
)
child: new ListView(
children: <Widget>[
new TextField(
key: fieldKey,
),
new TextField(
validator: errorText,
),
],
),
)
)
),
),
);
}
......
......@@ -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: <Widget>[
new Input(
labelText: 'First'
),
new Input(
key: inputKey,
labelText: 'Second'
),
]
)
)
)
child: new Column(
children: <Widget>[
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));
......
......@@ -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<Null>(
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<String> log = <String>[];
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
......
......@@ -15,16 +15,20 @@ void sendFakeKeyEvent(Map<String, dynamic> 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<RawKeyEvent> events = <RawKeyEvent>[];
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<RawKeyEvent> events = <RawKeyEvent>[];
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();
});
}
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