fix(ui,ios): apply community workaround for flutter/flutter#181474 (iOS 26 keyboard stale-focus)

User reported: 'input field still need to click twice' on iPhone +
'a bit lag after keyboard pop up'. The previous Listener-based
approach (020be77 \u2192 23bd1c7) actively made both symptoms worse.

Root cause is a confirmed open Flutter framework bug:

  flutter/flutter#181474 \u2014 [iPadOS] Keyboard is dismissed, but the
  TextField keeps focus, causing subsequent taps not to trigger
  keyboard presentation.
  Open, P2, triaged-text-input, platform-ios, e: OS-version specific.
  Reported on iPadOS 26.2 + Flutter 3.38.1 in Jan 2026 by
  Crazymuyang.

Reproduction matches our symptom exactly: iOS 26 dismisses the soft
keyboard (e.g. on tap-outside, or in some fresh-launch states), but
the EditableText's FocusNode keeps hasFocus = true. Because the
node is already focused, the next user tap is a no-op from the
focus system's perspective, so the platform TextInput channel is
never re-opened and iOS keeps the soft keyboard hidden until a
second tap finally triggers an explicit re-focus path.

Why our previous attempts failed:

  * 020be77 wrapped each TextField in a Listener that called
    requestFocus pre-arena. That's racing the wrong layer \u2014 it
    doesn't help when the bug is 'node is already focused, so
    requestFocus is a no-op'.

  * 23bd1c7 added SystemChannels.textInput.invokeMethod('TextInput.show')
    to the same Listener. This forced the keyboard up but raced
    EditableText's own attach path, producing the post-attach
    typing lag the user reported.

Fix: the community-recommended workaround in the issue thread \u2014
on every TextField tap, **unfocus first, then re-request focus on
the next microtask**. This forces a real false\u2192true focus-change
transition that re-opens TextInput on the first tap. No platform
channel races, no gesture-arena fighting, no Listener wrappers.

  void _kickFocus(FocusNode node) {
    if (node.hasFocus) node.unfocus();
    Future.microtask(() {
      if (!mounted) return;
      node.requestFocus();
    });
  }

  TextField(
    onTap: () => _kickFocus(_hostFocus),
    ...
  )

Wired into all three connect-form fields (host / nickname /
password). Removed the now-redundant _focusOnTap Listener helper.

No-op on hosts where #181474 doesn't reproduce \u2014 the unfocus call
is a no-op when the node isn't focused, and the microtask
requestFocus is what TextField would have done anyway via its own
TapGestureRecognizer.

flutter analyze: 6 pre-existing Radio.groupValue deprecation infos
in voice_settings.dart (unchanged). flutter build ios --release
--no-codesign: 27.6 s, Runner.app 30.2 MB.
This commit is contained in:
EdisonJwa
2026-05-16 18:31:48 +08:00
parent 23bd1c7930
commit 79f83604da
+32 -47
View File
@@ -1266,42 +1266,36 @@ class _ConnectFormState extends State<_ConnectForm> {
final FocusNode _nickFocus = FocusNode(); final FocusNode _nickFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode(); final FocusNode _passwordFocus = FocusNode();
Widget _focusOnTap(Widget child, FocusNode node) { /// Workaround for flutter/flutter#181474 (open, P2, triaged-text-
// iOS first-tap-not-focusing workaround. /// input, iOS 26+): after the soft keyboard is dismissed, the
// /// FocusNode still reports `hasFocus == true`. Because the node is
// Symptom: the user taps a TextField the first time after the /// already focused, the next user tap on the same TextField does
// app launches (or after the field has not held focus this /// not trigger any focus-change side effect, so the platform
// session); the cursor doesn't appear and the on-screen keyboard /// TextInput channel is never re-opened, and iOS keeps the soft
// doesn't slide up. A second tap then works normally. /// keyboard hidden. The user has to tap twice (the second tap
// /// triggers an explicit re-focus path that re-shows the keyboard).
// Root cause: Flutter's EditableText opens the platform ///
// TextInput channel (which is what actually slides the iOS /// The community-recommended workaround in the issue thread is to
// keyboard up) only after a TapGestureRecognizer **wins** the /// **unfocus first, then re-request focus** on every tap. That
// gesture arena. Our outer Listener (HitTestBehavior.translucent, /// forces a real focus-change transition (`false -> true`) which
// onPointerDown) used to call node.requestFocus() pre-arena, /// re-opens TextInput and slides the keyboard up on the first tap.
// which marks the node focused in Flutter's tree but does not ///
// open the TextInput channel \u2014 so the OS keyboard stays hidden /// This costs nothing on hosts where the bug doesn't reproduce
// until the EditableText's own tap recognizer wins on a second /// (the unfocus call is a no-op when the node isn't focused; the
// tap. /// re-request just reaffirms focus and EditableText's own attach
// /// path handles the rest).
// Fix: in addition to requestFocus(), invoke 'TextInput.show' void _kickFocus(FocusNode node) {
// on the SystemChannels.textInput method channel. This is the // Drop focus synchronously, then re-grab it on the next frame.
// same private RPC EditableText uses internally; calling it // Doing both in one frame can race with EditableText's internal
// ourselves forces the OS keyboard up regardless of arena state. // bookkeeping; deferring the re-grab by one microtask sidesteps
// Belt-and-braces with the Listener guarantees keyboard-on-first- // that and matches the workaround pattern in the issue thread.
// tap on iPhone / iPad and is harmless on Android (the platform if (node.hasFocus) {
// ignores the redundant show call when the keyboard is already node.unfocus();
// up) and on desktop (no soft keyboard exists).
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) {
if (!node.hasFocus) {
node.requestFocus();
} }
SystemChannels.textInput.invokeMethod<void>('TextInput.show'); Future.microtask(() {
}, if (!mounted) return;
child: child, node.requestFocus();
); });
} }
@override @override
@@ -1318,11 +1312,10 @@ class _ConnectFormState extends State<_ConnectForm> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_focusOnTap(
TextField( TextField(
controller: widget.hostCtl, controller: widget.hostCtl,
focusNode: _hostFocus, focusNode: _hostFocus,
onTap: () => _hostFocus.requestFocus(), onTap: () => _kickFocus(_hostFocus),
// Server addresses are URL-shaped: hostname or // Server addresses are URL-shaped: hostname or
// hostname:port, all-lowercase ASCII, never user- // hostname:port, all-lowercase ASCII, never user-
// friendly prose. Configure the on-screen keyboard // friendly prose. Configure the on-screen keyboard
@@ -1362,14 +1355,11 @@ class _ConnectFormState extends State<_ConnectForm> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),
_hostFocus,
),
const SizedBox(height: 8), const SizedBox(height: 8),
_focusOnTap(
TextField( TextField(
controller: widget.nickCtl, controller: widget.nickCtl,
focusNode: _nickFocus, focusNode: _nickFocus,
onTap: () => _nickFocus.requestFocus(), onTap: () => _kickFocus(_nickFocus),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
@@ -1378,14 +1368,11 @@ class _ConnectFormState extends State<_ConnectForm> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),
_nickFocus,
),
const SizedBox(height: 8), const SizedBox(height: 8),
_focusOnTap(
TextField( TextField(
controller: widget.passwordCtl, controller: widget.passwordCtl,
focusNode: _passwordFocus, focusNode: _passwordFocus,
onTap: () => _passwordFocus.requestFocus(), onTap: () => _kickFocus(_passwordFocus),
obscureText: true, obscureText: true,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
autocorrect: false, autocorrect: false,
@@ -1396,8 +1383,6 @@ class _ConnectFormState extends State<_ConnectForm> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),
_passwordFocus,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
children: [ children: [