Skip to content

Commit 24e964d

Browse files
authored
Delay focus trap unfocus until post-frame (#101847)
1 parent 37b3fee commit 24e964d

2 files changed

Lines changed: 224 additions & 2 deletions

File tree

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2152,6 +2152,7 @@ class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior {
21522152
Expando<BoxHitTestResult> cachedResults = Expando<BoxHitTestResult>();
21532153

21542154
FocusScopeNode _focusScopeNode;
2155+
FocusNode? _previousFocus;
21552156
FocusScopeNode get focusScopeNode => _focusScopeNode;
21562157
set focusScopeNode(FocusScopeNode value) {
21572158
if (focusScopeNode == value)
@@ -2188,6 +2189,18 @@ class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior {
21882189
}
21892190
}
21902191

2192+
void _checkForUnfocus() {
2193+
if (_previousFocus == null) {
2194+
return;
2195+
}
2196+
// Only continue to unfocus if the previous focus matches the current focus.
2197+
// If the focus has changed in the meantime, it was probably intentional.
2198+
if (FocusManager.instance.primaryFocus == _previousFocus) {
2199+
_previousFocus!.unfocus();
2200+
}
2201+
_previousFocus = null;
2202+
}
2203+
21912204
@override
21922205
void handleEvent(PointerEvent event, HitTestEntry entry) {
21932206
assert(debugHandleEvent(event, entry));
@@ -2219,7 +2232,13 @@ class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior {
22192232
break;
22202233
}
22212234
}
2222-
if (!hitCurrentFocus)
2223-
focusNode.unfocus();
2235+
if (!hitCurrentFocus) {
2236+
_previousFocus = focusNode;
2237+
// Check post-frame to see that the focus hasn't changed before
2238+
// unfocusing. This also allows a button tap to capture the previously
2239+
// active focus before FocusTrap tries to unfocus it, and avoids a bounce
2240+
// through the scope's focus node in between.
2241+
SchedulerBinding.instance.scheduleTask<void>(_checkForUnfocus, Priority.touch);
2242+
}
22242243
}
22252244
}

packages/flutter/test/widgets/routes_test.dart

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import 'dart:collection';
66
import 'dart:ui';
77

8+
import 'package:flutter/foundation.dart';
89
import 'package:flutter/material.dart';
910
import 'package:flutter/services.dart';
1011
import 'package:flutter_test/flutter_test.dart';
@@ -1976,6 +1977,143 @@ void main() {
19761977
await tester.restoreFrom(restorationData);
19771978
expect(find.byType(AlertDialog), findsOneWidget);
19781979
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615
1980+
1981+
testWidgets('FocusTrap moves focus to given focus scope when triggered', (WidgetTester tester) async {
1982+
final FocusScopeNode focusScope = FocusScopeNode();
1983+
final FocusNode focusNode = FocusNode(debugLabel: 'Test');
1984+
await tester.pumpWidget(
1985+
Directionality(
1986+
textDirection: TextDirection.ltr,
1987+
child: FocusScope(
1988+
node: focusScope,
1989+
child: FocusTrap(
1990+
focusScopeNode: focusScope,
1991+
child: Column(
1992+
children: <Widget>[
1993+
const Text('Other Widget'),
1994+
FocusTrapTestWidget('Focusable', focusNode: focusNode, onTap: () {
1995+
focusNode.requestFocus();
1996+
}),
1997+
],
1998+
),
1999+
),
2000+
),
2001+
),
2002+
);
2003+
2004+
await tester.pump();
2005+
2006+
Future<void> click(Finder finder) async {
2007+
final TestGesture gesture = await tester.startGesture(
2008+
tester.getCenter(finder),
2009+
kind: PointerDeviceKind.mouse,
2010+
);
2011+
await gesture.up();
2012+
await gesture.removePointer();
2013+
}
2014+
2015+
expect(focusScope.hasFocus, isFalse);
2016+
expect(focusNode.hasFocus, isFalse);
2017+
2018+
await click(find.text('Focusable'));
2019+
await tester.pump(const Duration(seconds: 1));
2020+
2021+
expect(focusScope.hasFocus, isTrue);
2022+
expect(focusNode.hasPrimaryFocus, isTrue);
2023+
2024+
await click(find.text('Other Widget'));
2025+
// Have to wait out the double click timer.
2026+
await tester.pump(const Duration(seconds: 1));
2027+
2028+
switch (defaultTargetPlatform) {
2029+
case TargetPlatform.iOS:
2030+
case TargetPlatform.android:
2031+
if (kIsWeb) {
2032+
// Web is a desktop platform.
2033+
expect(focusScope.hasPrimaryFocus, isTrue);
2034+
expect(focusNode.hasFocus, isFalse);
2035+
} else {
2036+
expect(focusScope.hasFocus, isTrue);
2037+
expect(focusNode.hasPrimaryFocus, isTrue);
2038+
}
2039+
break;
2040+
case TargetPlatform.fuchsia:
2041+
case TargetPlatform.linux:
2042+
case TargetPlatform.macOS:
2043+
case TargetPlatform.windows:
2044+
expect(focusScope.hasPrimaryFocus, isTrue);
2045+
expect(focusNode.hasFocus, isFalse);
2046+
break;
2047+
}
2048+
}, variant: TargetPlatformVariant.all());
2049+
2050+
testWidgets("FocusTrap doesn't unfocus if focus was set to something else before the frame ends", (WidgetTester tester) async {
2051+
final FocusScopeNode focusScope = FocusScopeNode();
2052+
final FocusNode focusNode = FocusNode(debugLabel: 'Test');
2053+
final FocusNode otherFocusNode = FocusNode(debugLabel: 'Other');
2054+
FocusNode? previousFocus;
2055+
await tester.pumpWidget(
2056+
Directionality(
2057+
textDirection: TextDirection.ltr,
2058+
child: FocusScope(
2059+
node: focusScope,
2060+
child: FocusTrap(
2061+
focusScopeNode: focusScope,
2062+
child: Column(
2063+
children: <Widget>[
2064+
FocusTrapTestWidget(
2065+
'Other Widget',
2066+
focusNode: otherFocusNode,
2067+
onTap: () {
2068+
previousFocus = FocusManager.instance.primaryFocus;
2069+
otherFocusNode.requestFocus();
2070+
},
2071+
),
2072+
FocusTrapTestWidget(
2073+
'Focusable',
2074+
focusNode: focusNode,
2075+
onTap: () {
2076+
focusNode.requestFocus();
2077+
},
2078+
),
2079+
],
2080+
),
2081+
),
2082+
),
2083+
),
2084+
);
2085+
2086+
Future<void> click(Finder finder) async {
2087+
final TestGesture gesture = await tester.startGesture(
2088+
tester.getCenter(finder),
2089+
kind: PointerDeviceKind.mouse,
2090+
);
2091+
await gesture.up();
2092+
await gesture.removePointer();
2093+
}
2094+
2095+
await tester.pump();
2096+
expect(focusScope.hasFocus, isFalse);
2097+
expect(focusNode.hasPrimaryFocus, isFalse);
2098+
2099+
await click(find.text('Focusable'));
2100+
2101+
expect(focusScope.hasFocus, isTrue);
2102+
expect(focusNode.hasPrimaryFocus, isTrue);
2103+
2104+
await click(find.text('Other Widget'));
2105+
await tester.pump(const Duration(seconds: 1));
2106+
2107+
// The previous focus as collected by the "Other Widget" should be the
2108+
// previous focus, not be unfocused to the scope, since the primary focus
2109+
// was set by something other than the FocusTrap (the "Other Widget") during
2110+
// the frame.
2111+
expect(previousFocus, equals(focusNode));
2112+
2113+
expect(focusScope.hasFocus, isTrue);
2114+
expect(focusNode.hasPrimaryFocus, isFalse);
2115+
expect(otherFocusNode.hasPrimaryFocus, isTrue);
2116+
}, variant: TargetPlatformVariant.all());
19792117
}
19802118

19812119
double _getOpacity(GlobalKey key, WidgetTester tester) {
@@ -2232,3 +2370,68 @@ class _RestorableDialogTestWidget extends StatelessWidget {
22322370
);
22332371
}
22342372
}
2373+
2374+
class FocusTrapTestWidget extends StatefulWidget {
2375+
const FocusTrapTestWidget(
2376+
this.label, {
2377+
super.key,
2378+
required this.focusNode,
2379+
this.onTap,
2380+
this.autofocus = false,
2381+
});
2382+
2383+
final String label;
2384+
final FocusNode focusNode;
2385+
final VoidCallback? onTap;
2386+
final bool autofocus;
2387+
2388+
@override
2389+
State<FocusTrapTestWidget> createState() => _FocusTrapTestWidgetState();
2390+
}
2391+
2392+
class _FocusTrapTestWidgetState extends State<FocusTrapTestWidget> {
2393+
Color color = Colors.white;
2394+
2395+
@override
2396+
void initState() {
2397+
super.initState();
2398+
widget.focusNode.addListener(_handleFocusChange);
2399+
}
2400+
2401+
void _handleFocusChange() {
2402+
if (widget.focusNode.hasPrimaryFocus) {
2403+
setState(() {
2404+
color = Colors.grey.shade500;
2405+
});
2406+
} else {
2407+
setState(() {
2408+
color = Colors.white;
2409+
});
2410+
}
2411+
}
2412+
2413+
@override
2414+
void dispose() {
2415+
widget.focusNode.removeListener(_handleFocusChange);
2416+
widget.focusNode.dispose();
2417+
super.dispose();
2418+
}
2419+
2420+
@override
2421+
Widget build(BuildContext context) {
2422+
return Focus(
2423+
autofocus: widget.autofocus,
2424+
focusNode: widget.focusNode,
2425+
child: GestureDetector(
2426+
onTap: () {
2427+
widget.onTap?.call();
2428+
},
2429+
child: Container(
2430+
color: color,
2431+
alignment: Alignment.center,
2432+
child: Text(widget.label, style: const TextStyle(color: Colors.black)),
2433+
),
2434+
),
2435+
);
2436+
}
2437+
}

0 commit comments

Comments
 (0)