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:
EdisonJwa
2026-05-16 19:18:54 +08:00
parent 6955547cec
commit 0c1fd1c2e3
+55 -23
View File
@@ -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> {
],
),
],
),
);
}
}