fix(ui,ios): use TextField.onTapOutside instead of outer GestureDetector (fixes _dependents.isEmpty assert + keyboard lag)
User reported 'package:flutter/src/widgets/framework.dart line 6268
_dependents.isEmpty is not true' assertion AND the persistent
keyboard lag.
Root cause analysis:
framework.dart:6268 is the assertion in
InheritedElement.debugDeactivated() that fires when an
InheritedElement is being deactivated while descendants still
depend on it. This fires in debug builds; release builds skip it.
The previous tap-outside-to-dismiss implementation used:
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => FocusScope.of(context).unfocus(),
child: Column(...),
);
The FocusScope.of(context) call subscribes this GestureDetector's
Element to the _FocusScopeMarker InheritedWidget on every build.
During the modal-sheet-pop / Navigator-deactivate sequence, the
ancestor _FocusScopeMarker InheritedElement can deactivate before
the descendant GestureDetector clears its dependency, tripping the
debug assertion.
This was the same root cause as the keyboard lag: an outer
GestureDetector in the gesture arena ahead of the TextField's own
recognizer ALWAYS interferes, either via the opaque-vs-translucent
arena race (causing lag) or via the InheritedWidget subscription
race (causing the assert).
Fix: remove the outer GestureDetector entirely. Use the built-in
TextField.onTapOutside callback added in Flutter 3.10+ instead:
TextField(
...
onTapOutside: _onTapOutside,
)
void _onTapOutside(PointerDownEvent _) {
FocusManager.instance.primaryFocus?.unfocus();
}
Why this is strictly better:
* FocusManager.instance is a global singleton with NO
BuildContext dependency. No InheritedWidget is subscribed; no
_dependents map grows; no race possible on dispose.
* TextField.onTapOutside uses the framework's TapRegion machinery
internally. Taps inside the field's TapRegion don't fire the
callback; only true outside-region taps do. Zero gesture-arena
interference with the TextField's own tap recognizer \u2014
keyboard appears synchronously on the first tap with no lag.
* iOS's default _EditableTextTapOutsideAction at
flutter/widgets/editable_text.dart:6748-6755 is intentionally a
no-op for touch on mobile (Apple convention: dismiss via Done
or swipe-down). Our explicit onTapOutside override is the
correct way to opt into tap-outside-to-dismiss on mobile
without fighting iOS conventions or the framework's gesture
arena.
Wired onTapOutside on all three connect form fields (host /
nickname / password).
flutter build ios --release --no-codesign: 20.5 s, Runner.app
30.2 MB (unchanged). flutter analyze: 6 pre-existing Radio
deprecation infos (unchanged).
This commit is contained in:
@@ -1315,6 +1315,23 @@ class _ConnectFormState extends State<_ConnectForm> {
|
||||
//
|
||||
// and wire onTap: () => _kickFocus(_xxxFocus) on each TextField.
|
||||
|
||||
/// Dismisses the soft keyboard when the user taps outside any
|
||||
/// TextField in this form. Wired into each TextField via the
|
||||
/// built-in `onTapOutside` parameter (Flutter 3.10+), which uses
|
||||
/// the framework's TapRegion machinery to detect taps outside the
|
||||
/// field's region without us having to install an outer
|
||||
/// GestureDetector (which previously caused a
|
||||
/// '_dependents.isEmpty' assertion when modal-sheet pop sequences
|
||||
/// raced the InheritedWidget dependency cleanup).
|
||||
///
|
||||
/// Uses FocusManager.instance \u2014 a global singleton with no
|
||||
/// BuildContext dependency \u2014 instead of FocusScope.of(context),
|
||||
/// to avoid subscribing this widget's Element to any
|
||||
/// InheritedWidget.
|
||||
void _onTapOutside(PointerDownEvent _) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hostFocus.dispose();
|
||||
@@ -1328,32 +1345,46 @@ class _ConnectFormState extends State<_ConnectForm> {
|
||||
final l10n = AppL10n.of(context);
|
||||
// Tap-outside-to-unfocus.
|
||||
//
|
||||
// Initial implementation used HitTestBehavior.translucent which
|
||||
// dispatched the tap to BOTH this GestureDetector and any
|
||||
// descendant TextField. That meant every field tap fired
|
||||
// FocusScope.unfocus() at the same time the TextField's own
|
||||
// gesture path was attaching TextInput \u2014 the two animations
|
||||
// raced each other, producing the user-visible 500\u20131000 ms lag +
|
||||
// 'stuck' keyboard behaviour reported on iPhone iOS 18.
|
||||
// Iteration history:
|
||||
// * 020be77/23bd1c7: Listener pre-arena requestFocus +
|
||||
// SystemChannels.textInput.show. Caused first-tap-not-focusing
|
||||
// AND post-attach keystroke lag (Listener wins arena,
|
||||
// EditableText loses).
|
||||
// * 79f8360: _kickFocus(unfocus + Future.microtask refocus) on
|
||||
// every TextField.onTap. Caused 500-1000 ms keyboard slide-up
|
||||
// lag (microtask deferral collides with iOS keyboard
|
||||
// CAAnimation).
|
||||
// * 6955547: GestureDetector(HitTestBehavior.opaque, onTap:
|
||||
// FocusScope.of(context).unfocus()) wrapping the Column.
|
||||
// Caused 'package:flutter/src/widgets/framework.dart line
|
||||
// 6268 _dependents.isEmpty' assertion: the
|
||||
// FocusScope.of(context) call subscribes the GestureDetector's
|
||||
// Element to the _FocusScopeMarker InheritedWidget on every
|
||||
// build, and the modal-sheet pop sequence deactivates that
|
||||
// InheritedElement before the dependents fully clear,
|
||||
// tripping the debug assert.
|
||||
//
|
||||
// Fixed by switching to HitTestBehavior.opaque. With opaque, the
|
||||
// gesture arena still routes a tap on a TextField to that
|
||||
// TextField's own recognizer (only one recognizer wins), so the
|
||||
// outer onTap does NOT fire when the user taps a field. The
|
||||
// outer onTap only fires when the user taps somewhere this
|
||||
// GestureDetector receives that no child claimed \u2014 i.e. the
|
||||
// empty padding between fields, the spacers, or below the
|
||||
// bottom button row. That's exactly the tap-outside semantic we
|
||||
// want, with zero interference on field taps.
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Current approach (no outer GestureDetector at all):
|
||||
// * Use TextField.onTapOutside (added Flutter 3.10+). Wired
|
||||
// per field. Uses FocusManager.instance (global, no
|
||||
// BuildContext dependency, no InheritedWidget race).
|
||||
// * iOS framework default for touch+mobile is to do NOTHING
|
||||
// on tap-outside (see flutter/widgets/editable_text.dart
|
||||
// _EditableTextTapOutsideAction:6748-6755) because the iOS
|
||||
// UX convention is 'swipe-down on the keyboard' or 'tap
|
||||
// Done' \u2014 not tap-outside. We override that explicitly
|
||||
// because most users expect tap-outside-to-dismiss in a
|
||||
// server-connect form context.
|
||||
// * No outer GestureDetector means zero gesture-arena
|
||||
// interference with the TextField's own tap recognizer \u2014
|
||||
// keyboard appears synchronously on first tap, no lag.
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: widget.hostCtl,
|
||||
focusNode: _hostFocus,
|
||||
onTapOutside: _onTapOutside,
|
||||
// Server addresses are URL-shaped: hostname or
|
||||
// hostname:port, all-lowercase ASCII, never user-
|
||||
// friendly prose. Configure the on-screen keyboard
|
||||
@@ -1397,6 +1428,7 @@ class _ConnectFormState extends State<_ConnectForm> {
|
||||
TextField(
|
||||
controller: widget.nickCtl,
|
||||
focusNode: _nickFocus,
|
||||
onTapOutside: _onTapOutside,
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
@@ -1409,6 +1441,7 @@ class _ConnectFormState extends State<_ConnectForm> {
|
||||
TextField(
|
||||
controller: widget.passwordCtl,
|
||||
focusNode: _passwordFocus,
|
||||
onTapOutside: _onTapOutside,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
@@ -1438,7 +1471,6 @@ class _ConnectFormState extends State<_ConnectForm> {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user