Unverified Commit 2900347a authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

[flutter] prevent errant text field clicks from losing focus (#86041)

parent f6e5227b
...@@ -1306,30 +1306,33 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1306,30 +1306,33 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
semanticsMaxValueLength = null; semanticsMaxValueLength = null;
} }
return MouseRegion( return FocusTrapArea(
cursor: effectiveMouseCursor, focusNode: focusNode,
onEnter: (PointerEnterEvent event) => _handleHover(true), child: MouseRegion(
onExit: (PointerExitEvent event) => _handleHover(false), cursor: effectiveMouseCursor,
child: IgnorePointer( onEnter: (PointerEnterEvent event) => _handleHover(true),
ignoring: !_isEnabled, onExit: (PointerExitEvent event) => _handleHover(false),
child: AnimatedBuilder( child: IgnorePointer(
animation: controller, // changes the _currentLength ignoring: !_isEnabled,
builder: (BuildContext context, Widget? child) { child: AnimatedBuilder(
return Semantics( animation: controller, // changes the _currentLength
maxValueLength: semanticsMaxValueLength, builder: (BuildContext context, Widget? child) {
currentValueLength: _currentLength, return Semantics(
onTap: widget.readOnly ? null : () { maxValueLength: semanticsMaxValueLength,
if (!_effectiveController.selection.isValid) currentValueLength: _currentLength,
_effectiveController.selection = TextSelection.collapsed(offset: _effectiveController.text.length); onTap: widget.readOnly ? null : () {
_requestKeyboard(); if (!_effectiveController.selection.isValid)
}, _effectiveController.selection = TextSelection.collapsed(offset: _effectiveController.text.length);
onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, _requestKeyboard();
},
onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus,
child: child,
);
},
child: _selectionGestureDetectorBuilder.buildGestureDetector(
behavior: HitTestBehavior.translucent,
child: child, child: child,
); ),
},
child: _selectionGestureDetectorBuilder.buildGestureDetector(
behavior: HitTestBehavior.translucent,
child: child,
), ),
), ),
), ),
......
...@@ -1654,12 +1654,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1654,12 +1654,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
widget.focusNode.addListener(_handleFocusChanged); widget.focusNode.addListener(_handleFocusChanged);
updateKeepAlive(); updateKeepAlive();
} }
if (!_shouldCreateInputConnection) { if (!_shouldCreateInputConnection) {
_closeInputConnectionIfNeeded(); _closeInputConnectionIfNeeded();
} else { } else if (oldWidget.readOnly && _hasFocus) {
if (oldWidget.readOnly && _hasFocus) { _openInputConnection();
_openInputConnection();
}
} }
if (kIsWeb && _hasInputConnection) { if (kIsWeb && _hasInputConnection) {
......
...@@ -814,7 +814,7 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> { ...@@ -814,7 +814,7 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
controller: primaryScrollController, controller: primaryScrollController,
child: FocusScope( child: FocusScope(
node: focusScopeNode, // immutable node: focusScopeNode, // immutable
child: _FocusTrap( child: FocusTrap(
focusScopeNode: focusScopeNode, focusScopeNode: focusScopeNode,
child: RepaintBoundary( child: RepaintBoundary(
child: AnimatedBuilder( child: AnimatedBuilder(
...@@ -1987,16 +1987,32 @@ typedef RoutePageBuilder = Widget Function(BuildContext context, Animation<doubl ...@@ -1987,16 +1987,32 @@ typedef RoutePageBuilder = Widget Function(BuildContext context, Animation<doubl
/// See [ModalRoute.buildTransitions] for complete definition of the parameters. /// See [ModalRoute.buildTransitions] for complete definition of the parameters.
typedef RouteTransitionsBuilder = Widget Function(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child); typedef RouteTransitionsBuilder = Widget Function(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child);
/// The [FocusTrap] widget removes focus when a mouse primary pointer makes contact with another
/// region of the screen.
///
/// When a primary pointer makes contact with the screen, this widget determines if that pointer /// When a primary pointer makes contact with the screen, this widget determines if that pointer
/// contacted an existing focused widget. If not, this asks the [FocusScopeNode] to reset the /// contacted an existing focused widget. If not, this asks the [FocusScopeNode] to reset the
/// focus state. This allows [TextField]s and other focusable widgets to give up their focus /// focus state. This allows [TextField]s and other focusable widgets to give up their focus
/// state, without creating a gesture detector that competes with others on screen. /// state, without creating a gesture detector that competes with others on screen.
class _FocusTrap extends SingleChildRenderObjectWidget { ///
const _FocusTrap({ /// In cases where focus is conceptually larger than the focused render object, a [FocusTrapArea]
/// can be used to expand the focus area to include all render objects below that. This is used by
/// the [TextField] widgets to prevent a loss of focus when interacting with decorations on the
/// text area.
///
/// See also:
///
/// * [FocusTrapArea], the widget that allows expanding the conceptual focus area.
class FocusTrap extends SingleChildRenderObjectWidget {
/// Create a new [FocusTrap] widget scoped to the provided [focusScopeNode].
const FocusTrap({
required this.focusScopeNode, required this.focusScopeNode,
required Widget child, required Widget child,
}) : super(child: child); Key? key,
}) : super(child: child, key: key);
/// The [focusScopeNode] that this focus trap widget operates on.
final FocusScopeNode focusScopeNode; final FocusScopeNode focusScopeNode;
@override @override
...@@ -2005,11 +2021,50 @@ class _FocusTrap extends SingleChildRenderObjectWidget { ...@@ -2005,11 +2021,50 @@ class _FocusTrap extends SingleChildRenderObjectWidget {
} }
@override @override
void updateRenderObject(BuildContext context, covariant _RenderFocusTrap renderObject) { void updateRenderObject(BuildContext context, RenderObject renderObject) {
renderObject.focusScopeNode = focusScopeNode; if (renderObject is _RenderFocusTrap)
renderObject.focusScopeNode = focusScopeNode;
}
}
/// Declares a widget subtree which is part of the provided [focusNode]'s focus area
/// without attaching focus to that region.
///
/// This is used by text field widgets which decorate a smaller editable text area.
/// This area is conceptually part of the editable text, but not attached to the
/// focus context. The [FocusTrapArea] is used to inform the framework of this
/// relationship, so that primary pointer contact inside of this region but above
/// the editable text focus will not trigger loss of focus.
///
/// See also:
///
/// * [FocusTrap], the widget which removes focus based on primary pointer interactions.
class FocusTrapArea extends SingleChildRenderObjectWidget {
/// Create a new [FocusTrapArea] that expands the area of the provided [focusNode].
const FocusTrapArea({required this.focusNode, Key? key, Widget? child}) : super(key: key, child: child);
/// The [FocusNode] that the focus trap area will expand to.
final FocusNode focusNode;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderFocusTrapArea(focusNode);
}
@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
if (renderObject is _RenderFocusTrapArea)
renderObject.focusNode = focusNode;
} }
} }
class _RenderFocusTrapArea extends RenderProxyBox {
_RenderFocusTrapArea(this.focusNode);
FocusNode focusNode;
}
class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior { class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior {
_RenderFocusTrap(this._focusScopeNode); _RenderFocusTrap(this._focusScopeNode);
...@@ -2079,6 +2134,10 @@ class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior { ...@@ -2079,6 +2134,10 @@ class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior {
hitCurrentFocus = true; hitCurrentFocus = true;
break; break;
} }
if (target is _RenderFocusTrapArea && target.focusNode == focusNode) {
hitCurrentFocus = true;
break;
}
} }
if (!hitCurrentFocus) if (!hitCurrentFocus)
focusNode.unfocus(disposition: UnfocusDisposition.scope); focusNode.unfocus(disposition: UnfocusDisposition.scope);
......
...@@ -134,7 +134,7 @@ void main() { ...@@ -134,7 +134,7 @@ void main() {
' _FadeUpwardsPageTransition\n' ' _FadeUpwardsPageTransition\n'
' AnimatedBuilder\n' ' AnimatedBuilder\n'
' RepaintBoundary\n' ' RepaintBoundary\n'
' _FocusTrap\n' ' FocusTrap\n'
' _FocusMarker\n' ' _FocusMarker\n'
' Semantics\n' ' Semantics\n'
' FocusScope\n' ' FocusScope\n'
......
...@@ -431,6 +431,45 @@ void main() { ...@@ -431,6 +431,45 @@ void main() {
expect(focusNodeB.hasFocus, true); expect(focusNodeB.hasFocus, true);
}, variant: TargetPlatformVariant.desktop()); }, variant: TargetPlatformVariant.desktop());
testWidgets('A Focused text-field will not lose focus when clicking on its decoration', (WidgetTester tester) async {
final FocusNode focusNodeA = FocusNode();
final Key iconKey = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ListView(
children: <Widget>[
TextField(
focusNode: focusNodeA,
decoration: InputDecoration(
icon: Icon(Icons.copy_all, key: iconKey),
),
),
],
),
),
),
);
final TestGesture down1 = await tester.startGesture(tester.getCenter(find.byType(TextField).first), kind: PointerDeviceKind.mouse);
await tester.pump();
await tester.pumpAndSettle();
await down1.up();
await down1.removePointer();
expect(focusNodeA.hasFocus, true);
// Click on the icon which has a different RO than the text field's focus node context
final TestGesture down2 = await tester.startGesture(tester.getCenter(find.byKey(iconKey)), kind: PointerDeviceKind.mouse);
await tester.pump();
await tester.pumpAndSettle();
await down2.up();
await down2.removePointer();
expect(focusNodeA.hasFocus, true);
}, variant: TargetPlatformVariant.desktop());
testWidgets('A Focused text-field will lose focus when clicking outside of its hitbox with a mouse on desktop after tab navigation', (WidgetTester tester) async { testWidgets('A Focused text-field will lose focus when clicking outside of its hitbox with a mouse on desktop after tab navigation', (WidgetTester tester) async {
final FocusNode focusNodeA = FocusNode(); final FocusNode focusNodeA = FocusNode();
final FocusNode focusNodeB = FocusNode(); final FocusNode focusNodeB = FocusNode();
......
...@@ -52,9 +52,9 @@ Some possible finders for the widgets at Offset(400.0, 300.0): ...@@ -52,9 +52,9 @@ Some possible finders for the widgets at Offset(400.0, 300.0):
find.byType(FadeTransition) find.byType(FadeTransition)
find.byType(FractionalTranslation) find.byType(FractionalTranslation)
find.byType(SlideTransition) find.byType(SlideTransition)
find.widgetWithText(FocusTrap, 'Test')
find.widgetWithText(PrimaryScrollController, 'Test') find.widgetWithText(PrimaryScrollController, 'Test')
find.widgetWithText(PageStorage, 'Test') find.widgetWithText(PageStorage, 'Test')
find.widgetWithText(Offstage, 'Test')
'''.trim().split('\n'))); '''.trim().split('\n')));
printedMessages.clear(); printedMessages.clear();
......
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