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';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'actions.dart';
import 'basic.dart';
......@@ -811,6 +814,8 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
controller: primaryScrollController,
child: FocusScope(
node: focusScopeNode, // immutable
child: _FocusTrap(
focusScopeNode: focusScopeNode,
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _listenable, // immutable
......@@ -853,6 +858,7 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
),
),
),
),
);
},
),
......@@ -1980,3 +1986,110 @@ typedef RoutePageBuilder = Widget Function(BuildContext context, Animation<doubl
///
/// See [ModalRoute.buildTransitions] for complete definition of the parameters.
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() {
' _FadeUpwardsPageTransition\n'
' AnimatedBuilder\n'
' RepaintBoundary\n'
' _FocusTrap\n'
' _FocusMarker\n'
' Semantics\n'
' FocusScope\n'
......
......@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -370,4 +372,61 @@ void main() {
await tester.pumpWidget(Container());
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