From 6955547cec5bacda90c8b8699b0d2d136f4754ea Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 18:59:13 +0800 Subject: [PATCH] fix(ui,ios): switch tap-outside-unfocus to HitTestBehavior.opaque (eliminate keyboard race lag) User reported on iPhone iOS 18: 'still a bit lag and stuck' after the _kickFocus removal (1ceb47f). Web research (flutter/flutter keyboard performance issues) and code review of GestureDetector hit-test semantics identified the remaining race. Root cause: The outer GestureDetector wrapping the connect form Column was using HitTestBehavior.translucent. Translucent semantics dispatch the pointer event to BOTH the GestureDetector AND any descendant hit-test target. So when the user tapped a TextField, two things fired simultaneously: 1. GestureDetector.onTap -> FocusScope.of(context).unfocus() This drove the keyboard *down* via the platform TextInput.hide side effect of clearing focus. 2. TextField's own TapGestureRecognizer -> EditableText.attach This drove the keyboard *up* via TextInput.show. The two CAAnimations on iOS 18 raced each other inside the same UIKit transaction, producing: * 500-1000 ms of visible 'thinking' before the keyboard appeared (one full slide-down + one full slide-up). * Occasional 'stuck' state where the keyboard never came back up because UIResponder.becomeFirstResponder was called before resignFirstResponder finished. Fix: switch to HitTestBehavior.opaque. With opaque: * The GestureDetector still receives hit-test results for its entire bounds (so taps on empty padding between fields still reach onTap). * But the gesture arena routes a tap that lands on a TextField to that TextField's recognizer ONLY \u2014 the outer GestureDetector loses the arena and its onTap does not fire. * Net: tapping a field is exactly equivalent to having no outer GestureDetector at all (no race, no lag, no stuck). Tapping empty space still dismisses the keyboard cleanly. This is the canonical pattern that several Stack Overflow answers and the GestureDetector dartdoc recommend for 'tap-outside-to- dismiss-keyboard'. translucent is for cases where you want both the outer and inner to react simultaneously (rare). flutter build ios --release --no-codesign: 29.0 s, Runner.app 30.2 MB. --- apps/chanora_flutter/lib/main.dart | 33 ++++++++++++++++++------------ 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 7baafae..c90fcf7 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -1326,20 +1326,27 @@ class _ConnectFormState extends State<_ConnectForm> { @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); - // Wrap the form in a GestureDetector that unfocuses any focused - // TextField when the user taps an empty area of the form. This is - // the second half of the flutter/flutter#181474 workaround: - // _kickFocus handles the case where the user re-taps the same - // field after the keyboard auto-dismissed, and this outer - // gesture handler handles the more common case where the user - // taps outside (e.g. on the column padding) to dismiss the - // keyboard. By forcing an unfocus on tap-outside we guarantee - // the FocusNode is in the unfocused state when the next field - // tap arrives, so the focus transition is always false\u2192true - // (which is what iOS needs to re-open TextInput on the very - // first tap). + // 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. + // + // 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.translucent, + behavior: HitTestBehavior.opaque, onTap: () => FocusScope.of(context).unfocus(), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,