Unverified Commit 85be4f6c authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

[flutter] when primary mouse pointers don't contact a focused node, reset the focus (#82575)

parent a56a786f
...@@ -6,8 +6,11 @@ import 'dart:async'; ...@@ -6,8 +6,11 @@ import 'dart:async';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'actions.dart'; import 'actions.dart';
import 'basic.dart'; import 'basic.dart';
...@@ -811,42 +814,45 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> { ...@@ -811,42 +814,45 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
controller: primaryScrollController, controller: primaryScrollController,
child: FocusScope( child: FocusScope(
node: focusScopeNode, // immutable node: focusScopeNode, // immutable
child: RepaintBoundary( child: _FocusTrap(
child: AnimatedBuilder( focusScopeNode: focusScopeNode,
animation: _listenable, // immutable child: RepaintBoundary(
builder: (BuildContext context, Widget? child) { child: AnimatedBuilder(
return widget.route.buildTransitions( animation: _listenable, // immutable
context, builder: (BuildContext context, Widget? child) {
widget.route.animation!, return widget.route.buildTransitions(
widget.route.secondaryAnimation!, context,
// This additional AnimatedBuilder is include because if the widget.route.animation!,
// value of the userGestureInProgressNotifier changes, it's widget.route.secondaryAnimation!,
// only necessary to rebuild the IgnorePointer widget and set // This additional AnimatedBuilder is include because if the
// the focus node's ability to focus. // value of the userGestureInProgressNotifier changes, it's
AnimatedBuilder( // only necessary to rebuild the IgnorePointer widget and set
animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier<bool>(false), // the focus node's ability to focus.
builder: (BuildContext context, Widget? child) { AnimatedBuilder(
final bool ignoreEvents = _shouldIgnoreFocusRequest; animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier<bool>(false),
focusScopeNode.canRequestFocus = !ignoreEvents; builder: (BuildContext context, Widget? child) {
return IgnorePointer( final bool ignoreEvents = _shouldIgnoreFocusRequest;
ignoring: ignoreEvents, focusScopeNode.canRequestFocus = !ignoreEvents;
child: child, return IgnorePointer(
ignoring: ignoreEvents,
child: child,
);
},
child: child,
),
);
},
child: _page ??= RepaintBoundary(
key: widget.route._subtreeKey, // immutable
child: Builder(
builder: (BuildContext context) {
return widget.route.buildPage(
context,
widget.route.animation!,
widget.route.secondaryAnimation!,
); );
}, },
child: child,
), ),
);
},
child: _page ??= RepaintBoundary(
key: widget.route._subtreeKey, // immutable
child: Builder(
builder: (BuildContext context) {
return widget.route.buildPage(
context,
widget.route.animation!,
widget.route.secondaryAnimation!,
);
},
), ),
), ),
), ),
...@@ -1980,3 +1986,110 @@ typedef RoutePageBuilder = Widget Function(BuildContext context, Animation<doubl ...@@ -1980,3 +1986,110 @@ 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);
/// 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
/// 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.
class _FocusTrap extends SingleChildRenderObjectWidget {
const _FocusTrap({
required this.focusScopeNode,
required Widget child,
}) : super(child: child);
final FocusScopeNode focusScopeNode;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderFocusTrap(focusScopeNode);
}
@override
void updateRenderObject(BuildContext context, covariant _RenderFocusTrap renderObject) {
renderObject.focusScopeNode = focusScopeNode;
}
}
class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior {
_RenderFocusTrap(this._focusScopeNode) {
focusScopeNode.addListener(_currentFocusListener);
}
FocusNode? currentFocus;
Rect? currentFocusRect;
Expando<BoxHitTestResult> cachedResults = Expando<BoxHitTestResult>();
FocusScopeNode _focusScopeNode;
FocusScopeNode get focusScopeNode => _focusScopeNode;
set focusScopeNode(FocusScopeNode value) {
if (focusScopeNode == value)
return;
focusScopeNode.removeListener(_currentFocusListener);
_focusScopeNode = value;
focusScopeNode.addListener(_currentFocusListener);
}
void _currentFocusListener() {
currentFocus = focusScopeNode.focusedChild;
}
@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget) {
final BoxHitTestEntry entry = BoxHitTestEntry(this, position);
cachedResults[entry] = result;
result.add(entry);
}
}
return hitTarget;
}
/// The focus dropping behavior is only present on desktop platforms
/// and mobile browsers.
bool get _shouldIgnoreEvents {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
return !kIsWeb;
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
case TargetPlatform.fuchsia:
return false;
}
}
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is! PointerDownEvent
|| event.buttons != kPrimaryButton
|| event.kind != PointerDeviceKind.mouse
|| _shouldIgnoreEvents
|| currentFocus == null) {
return;
}
final BoxHitTestResult? result = cachedResults[entry];
final FocusNode? focusNode = currentFocus;
if (focusNode == null || result == null)
return;
final RenderObject? renderObject = focusNode.context?.findRenderObject();
if (renderObject == null)
return;
bool hitCurrentFocus = false;
for (final HitTestEntry entry in result.path) {
final HitTestTarget target = entry.target;
if (target == renderObject) {
hitCurrentFocus = true;
break;
}
}
if (!hitCurrentFocus)
focusNode.unfocus(disposition: UnfocusDisposition.scope);
}
}
...@@ -134,6 +134,7 @@ void main() { ...@@ -134,6 +134,7 @@ void main() {
' _FadeUpwardsPageTransition\n' ' _FadeUpwardsPageTransition\n'
' AnimatedBuilder\n' ' AnimatedBuilder\n'
' RepaintBoundary\n' ' RepaintBoundary\n'
' _FocusTrap\n'
' _FocusMarker\n' ' _FocusMarker\n'
' Semantics\n' ' Semantics\n'
' FocusScope\n' ' FocusScope\n'
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -370,4 +372,61 @@ void main() { ...@@ -370,4 +372,61 @@ void main() {
await tester.pumpWidget(Container()); await tester.pumpWidget(Container());
expect(tester.testTextInput.isVisible, isFalse); expect(tester.testTextInput.isVisible, isFalse);
}); });
testWidgets('A Focused text-field will lose focus when clicking outside of its hitbox with a mouse on desktop', (WidgetTester tester) async {
final FocusNode focusNodeA = FocusNode();
final FocusNode focusNodeB = FocusNode();
final Key key = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ListView(
children: <Widget>[
TextField(
focusNode: focusNodeA,
),
Container(
key: key,
height: 200,
),
TextField(
focusNode: focusNodeB,
),
],
),
),
),
);
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);
expect(focusNodeB.hasFocus, false);
// Click on the container to not hit either text field.
final TestGesture down2 = await tester.startGesture(tester.getCenter(find.byKey(key)), kind: PointerDeviceKind.mouse);
await tester.pump();
await tester.pumpAndSettle();
await down2.up();
await down2.removePointer();
expect(focusNodeA.hasFocus, false);
expect(focusNodeB.hasFocus, false);
// Second text field can still gain focus.
final TestGesture down3 = await tester.startGesture(tester.getCenter(find.byType(TextField).last), kind: PointerDeviceKind.mouse);
await tester.pump();
await tester.pumpAndSettle();
await down3.up();
await down3.removePointer();
expect(focusNodeA.hasFocus, false);
expect(focusNodeB.hasFocus, true);
}, variant: TargetPlatformVariant.desktop());
} }
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