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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user