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
+91 -106
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( Future.microtask(() {
behavior: HitTestBehavior.translucent, if (!mounted) return;
onPointerDown: (_) { node.requestFocus();
if (!node.hasFocus) { });
node.requestFocus();
}
SystemChannels.textInput.invokeMethod<void>('TextInput.show');
},
child: child,
);
} }
@override @override
@@ -1318,85 +1312,76 @@ 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: () => _kickFocus(_hostFocus),
onTap: () => _hostFocus.requestFocus(), // 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 // accordingly:
// accordingly: // * keyboardType: .url \u2014 surfaces ".", "/",
// * keyboardType: .url \u2014 surfaces ".", "/", // ":" on the primary keyboard plane so the user
// ":" on the primary keyboard plane so the user // doesn't have to switch to the symbols pane to
// doesn't have to switch to the symbols pane to // type kr.teamspeak.app:9987.
// type kr.teamspeak.app:9987. // * textInputAction: .next \u2014 return key advances
// * textInputAction: .next \u2014 return key advances // to the nickname field.
// to the nickname field. // * autocorrect / enableSuggestions: false \u2014 iOS
// * autocorrect / enableSuggestions: false \u2014 iOS // should not autocorrect 'kr.teamspeak.app' to
// should not autocorrect 'kr.teamspeak.app' to // 'kr.teamspeak.lap' or suggest 'KR' in caps.
// 'kr.teamspeak.lap' or suggest 'KR' in caps. // * textCapitalization: .none \u2014 don't capitalise the
// * textCapitalization: .none \u2014 don't capitalise the // first letter the way iOS does for sentences.
// first letter the way iOS does for sentences. // * inputFormatters: deny whitespace and uppercase\u2014
// * inputFormatters: deny whitespace and uppercase\u2014 // belt-and-braces in case the user pastes from a
// belt-and-braces in case the user pastes from a // formatted source (e.g. tab-indented copy).
// formatted source (e.g. tab-indented copy). keyboardType: TextInputType.url,
keyboardType: TextInputType.url, textCapitalization: TextCapitalization.none,
textCapitalization: TextCapitalization.none, textInputAction: TextInputAction.next,
textInputAction: TextInputAction.next, autocorrect: false,
autocorrect: false, enableSuggestions: false,
enableSuggestions: false, inputFormatters: [
inputFormatters: [ FilteringTextInputFormatter.deny(RegExp(r'\s')),
FilteringTextInputFormatter.deny(RegExp(r'\s')), TextInputFormatter.withFunction(
TextInputFormatter.withFunction( (oldValue, newValue) => newValue.copyWith(
(oldValue, newValue) => newValue.copyWith( text: newValue.text.toLowerCase(),
text: newValue.text.toLowerCase(), selection: newValue.selection,
selection: newValue.selection,
),
), ),
],
decoration: InputDecoration(
labelText: l10n.fieldServerHost,
hintText: 'host[:port]',
prefixIcon: const Icon(Icons.dns_outlined),
border: const OutlineInputBorder(),
), ),
],
decoration: InputDecoration(
labelText: l10n.fieldServerHost,
hintText: 'host[:port]',
prefixIcon: const Icon(Icons.dns_outlined),
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: () => _kickFocus(_nickFocus),
onTap: () => _nickFocus.requestFocus(), textInputAction: TextInputAction.next,
textInputAction: TextInputAction.next, autocorrect: false,
autocorrect: false, enableSuggestions: false,
enableSuggestions: false, decoration: InputDecoration(
decoration: InputDecoration( labelText: l10n.fieldNickname,
labelText: l10n.fieldNickname, 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: () => _kickFocus(_passwordFocus),
onTap: () => _passwordFocus.requestFocus(), obscureText: true,
obscureText: true, textInputAction: TextInputAction.done,
textInputAction: TextInputAction.done, autocorrect: false,
autocorrect: false, enableSuggestions: false,
enableSuggestions: false, decoration: InputDecoration(
decoration: InputDecoration( labelText: l10n.fieldServerPassword,
labelText: l10n.fieldServerPassword, helperText: l10n.fieldServerPasswordHelp,
helperText: l10n.fieldServerPasswordHelp, border: const OutlineInputBorder(),
border: const OutlineInputBorder(),
),
), ),
_passwordFocus,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(