From 0c1fd1c2e3ddd9488544ae778e880db6bdb76d73 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 19:18:54 +0800 Subject: [PATCH] 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). --- apps/chanora_flutter/lib/main.dart | 78 +++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index c90fcf7..a81b6c0 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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> { ], ), ], - ), ); } }