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