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.
This commit is contained in:
EdisonJwa
2026-05-16 18:59:13 +08:00
parent 1ceb47f1e3
commit 6955547cec
+20 -13
View File
@@ -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,