Skip to content

Commit 2900347

Browse files
author
Jonah Williams
authored
[flutter] prevent errant text field clicks from losing focus (flutter#86041)
1 parent f6e5227 commit 2900347

File tree

6 files changed

+135
-35
lines changed

6 files changed

+135
-35
lines changed

packages/flutter/lib/src/material/text_field.dart

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1306,30 +1306,33 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
13061306
semanticsMaxValueLength = null;
13071307
}
13081308

1309-
return MouseRegion(
1310-
cursor: effectiveMouseCursor,
1311-
onEnter: (PointerEnterEvent event) => _handleHover(true),
1312-
onExit: (PointerExitEvent event) => _handleHover(false),
1313-
child: IgnorePointer(
1314-
ignoring: !_isEnabled,
1315-
child: AnimatedBuilder(
1316-
animation: controller, // changes the _currentLength
1317-
builder: (BuildContext context, Widget? child) {
1318-
return Semantics(
1319-
maxValueLength: semanticsMaxValueLength,
1320-
currentValueLength: _currentLength,
1321-
onTap: widget.readOnly ? null : () {
1322-
if (!_effectiveController.selection.isValid)
1323-
_effectiveController.selection = TextSelection.collapsed(offset: _effectiveController.text.length);
1324-
_requestKeyboard();
1325-
},
1326-
onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus,
1309+
return FocusTrapArea(
1310+
focusNode: focusNode,
1311+
child: MouseRegion(
1312+
cursor: effectiveMouseCursor,
1313+
onEnter: (PointerEnterEvent event) => _handleHover(true),
1314+
onExit: (PointerExitEvent event) => _handleHover(false),
1315+
child: IgnorePointer(
1316+
ignoring: !_isEnabled,
1317+
child: AnimatedBuilder(
1318+
animation: controller, // changes the _currentLength
1319+
builder: (BuildContext context, Widget? child) {
1320+
return Semantics(
1321+
maxValueLength: semanticsMaxValueLength,
1322+
currentValueLength: _currentLength,
1323+
onTap: widget.readOnly ? null : () {
1324+
if (!_effectiveController.selection.isValid)
1325+
_effectiveController.selection = TextSelection.collapsed(offset: _effectiveController.text.length);
1326+
_requestKeyboard();
1327+
},
1328+
onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus,
1329+
child: child,
1330+
);
1331+
},
1332+
child: _selectionGestureDetectorBuilder.buildGestureDetector(
1333+
behavior: HitTestBehavior.translucent,
13271334
child: child,
1328-
);
1329-
},
1330-
child: _selectionGestureDetectorBuilder.buildGestureDetector(
1331-
behavior: HitTestBehavior.translucent,
1332-
child: child,
1335+
),
13331336
),
13341337
),
13351338
),

packages/flutter/lib/src/widgets/editable_text.dart

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1654,12 +1654,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
16541654
widget.focusNode.addListener(_handleFocusChanged);
16551655
updateKeepAlive();
16561656
}
1657+
16571658
if (!_shouldCreateInputConnection) {
16581659
_closeInputConnectionIfNeeded();
1659-
} else {
1660-
if (oldWidget.readOnly && _hasFocus) {
1661-
_openInputConnection();
1662-
}
1660+
} else if (oldWidget.readOnly && _hasFocus) {
1661+
_openInputConnection();
16631662
}
16641663

16651664
if (kIsWeb && _hasInputConnection) {

packages/flutter/lib/src/widgets/routes.dart

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -814,7 +814,7 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
814814
controller: primaryScrollController,
815815
child: FocusScope(
816816
node: focusScopeNode, // immutable
817-
child: _FocusTrap(
817+
child: FocusTrap(
818818
focusScopeNode: focusScopeNode,
819819
child: RepaintBoundary(
820820
child: AnimatedBuilder(
@@ -1987,16 +1987,32 @@ typedef RoutePageBuilder = Widget Function(BuildContext context, Animation<doubl
19871987
/// See [ModalRoute.buildTransitions] for complete definition of the parameters.
19881988
typedef RouteTransitionsBuilder = Widget Function(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child);
19891989

1990+
/// The [FocusTrap] widget removes focus when a mouse primary pointer makes contact with another
1991+
/// region of the screen.
1992+
///
19901993
/// When a primary pointer makes contact with the screen, this widget determines if that pointer
19911994
/// contacted an existing focused widget. If not, this asks the [FocusScopeNode] to reset the
19921995
/// focus state. This allows [TextField]s and other focusable widgets to give up their focus
19931996
/// state, without creating a gesture detector that competes with others on screen.
1994-
class _FocusTrap extends SingleChildRenderObjectWidget {
1995-
const _FocusTrap({
1997+
///
1998+
/// In cases where focus is conceptually larger than the focused render object, a [FocusTrapArea]
1999+
/// can be used to expand the focus area to include all render objects below that. This is used by
2000+
/// the [TextField] widgets to prevent a loss of focus when interacting with decorations on the
2001+
/// text area.
2002+
///
2003+
/// See also:
2004+
///
2005+
/// * [FocusTrapArea], the widget that allows expanding the conceptual focus area.
2006+
class FocusTrap extends SingleChildRenderObjectWidget {
2007+
2008+
/// Create a new [FocusTrap] widget scoped to the provided [focusScopeNode].
2009+
const FocusTrap({
19962010
required this.focusScopeNode,
19972011
required Widget child,
1998-
}) : super(child: child);
2012+
Key? key,
2013+
}) : super(child: child, key: key);
19992014

2015+
/// The [focusScopeNode] that this focus trap widget operates on.
20002016
final FocusScopeNode focusScopeNode;
20012017

20022018
@override
@@ -2005,11 +2021,50 @@ class _FocusTrap extends SingleChildRenderObjectWidget {
20052021
}
20062022

20072023
@override
2008-
void updateRenderObject(BuildContext context, covariant _RenderFocusTrap renderObject) {
2009-
renderObject.focusScopeNode = focusScopeNode;
2024+
void updateRenderObject(BuildContext context, RenderObject renderObject) {
2025+
if (renderObject is _RenderFocusTrap)
2026+
renderObject.focusScopeNode = focusScopeNode;
2027+
}
2028+
}
2029+
2030+
/// Declares a widget subtree which is part of the provided [focusNode]'s focus area
2031+
/// without attaching focus to that region.
2032+
///
2033+
/// This is used by text field widgets which decorate a smaller editable text area.
2034+
/// This area is conceptually part of the editable text, but not attached to the
2035+
/// focus context. The [FocusTrapArea] is used to inform the framework of this
2036+
/// relationship, so that primary pointer contact inside of this region but above
2037+
/// the editable text focus will not trigger loss of focus.
2038+
///
2039+
/// See also:
2040+
///
2041+
/// * [FocusTrap], the widget which removes focus based on primary pointer interactions.
2042+
class FocusTrapArea extends SingleChildRenderObjectWidget {
2043+
2044+
/// Create a new [FocusTrapArea] that expands the area of the provided [focusNode].
2045+
const FocusTrapArea({required this.focusNode, Key? key, Widget? child}) : super(key: key, child: child);
2046+
2047+
/// The [FocusNode] that the focus trap area will expand to.
2048+
final FocusNode focusNode;
2049+
2050+
@override
2051+
RenderObject createRenderObject(BuildContext context) {
2052+
return _RenderFocusTrapArea(focusNode);
2053+
}
2054+
2055+
@override
2056+
void updateRenderObject(BuildContext context, RenderObject renderObject) {
2057+
if (renderObject is _RenderFocusTrapArea)
2058+
renderObject.focusNode = focusNode;
20102059
}
20112060
}
20122061

2062+
class _RenderFocusTrapArea extends RenderProxyBox {
2063+
_RenderFocusTrapArea(this.focusNode);
2064+
2065+
FocusNode focusNode;
2066+
}
2067+
20132068
class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior {
20142069
_RenderFocusTrap(this._focusScopeNode);
20152070

@@ -2079,6 +2134,10 @@ class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior {
20792134
hitCurrentFocus = true;
20802135
break;
20812136
}
2137+
if (target is _RenderFocusTrapArea && target.focusNode == focusNode) {
2138+
hitCurrentFocus = true;
2139+
break;
2140+
}
20822141
}
20832142
if (!hitCurrentFocus)
20842143
focusNode.unfocus(disposition: UnfocusDisposition.scope);

packages/flutter/test/material/debug_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ void main() {
134134
' _FadeUpwardsPageTransition\n'
135135
' AnimatedBuilder\n'
136136
' RepaintBoundary\n'
137-
' _FocusTrap\n'
137+
' FocusTrap\n'
138138
' _FocusMarker\n'
139139
' Semantics\n'
140140
' FocusScope\n'

packages/flutter/test/material/text_field_focus_test.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,45 @@ void main() {
431431
expect(focusNodeB.hasFocus, true);
432432
}, variant: TargetPlatformVariant.desktop());
433433

434+
testWidgets('A Focused text-field will not lose focus when clicking on its decoration', (WidgetTester tester) async {
435+
final FocusNode focusNodeA = FocusNode();
436+
final Key iconKey = UniqueKey();
437+
438+
await tester.pumpWidget(
439+
MaterialApp(
440+
home: Material(
441+
child: ListView(
442+
children: <Widget>[
443+
TextField(
444+
focusNode: focusNodeA,
445+
decoration: InputDecoration(
446+
icon: Icon(Icons.copy_all, key: iconKey),
447+
),
448+
),
449+
],
450+
),
451+
),
452+
),
453+
);
454+
455+
final TestGesture down1 = await tester.startGesture(tester.getCenter(find.byType(TextField).first), kind: PointerDeviceKind.mouse);
456+
await tester.pump();
457+
await tester.pumpAndSettle();
458+
await down1.up();
459+
await down1.removePointer();
460+
461+
expect(focusNodeA.hasFocus, true);
462+
463+
// Click on the icon which has a different RO than the text field's focus node context
464+
final TestGesture down2 = await tester.startGesture(tester.getCenter(find.byKey(iconKey)), kind: PointerDeviceKind.mouse);
465+
await tester.pump();
466+
await tester.pumpAndSettle();
467+
await down2.up();
468+
await down2.removePointer();
469+
470+
expect(focusNodeA.hasFocus, true);
471+
}, variant: TargetPlatformVariant.desktop());
472+
434473
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 {
435474
final FocusNode focusNodeA = FocusNode();
436475
final FocusNode focusNodeB = FocusNode();

packages/flutter_test/test/widget_tester_live_device_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ Some possible finders for the widgets at Offset(400.0, 300.0):
5252
find.byType(FadeTransition)
5353
find.byType(FractionalTranslation)
5454
find.byType(SlideTransition)
55+
find.widgetWithText(FocusTrap, 'Test')
5556
find.widgetWithText(PrimaryScrollController, 'Test')
5657
find.widgetWithText(PageStorage, 'Test')
57-
find.widgetWithText(Offstage, 'Test')
5858
'''.trim().split('\n')));
5959
printedMessages.clear();
6060

0 commit comments

Comments
 (0)