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 _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();
}
SystemChannels.textInput.invokeMethod<void>('TextInput.show');
},
child: child,
);
/// 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();
}
Future.microtask(() {
if (!mounted) return;
node.requestFocus();
});
}
@override
@@ -1318,85 +1312,76 @@ class _ConnectFormState extends State<_ConnectForm> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_focusOnTap(
TextField(
controller: widget.hostCtl,
focusNode: _hostFocus,
onTap: () => _hostFocus.requestFocus(),
// Server addresses are URL-shaped: hostname or
// hostname:port, all-lowercase ASCII, never user-
// friendly prose. Configure the on-screen keyboard
// accordingly:
// * keyboardType: .url \u2014 surfaces ".", "/",
// ":" on the primary keyboard plane so the user
// doesn't have to switch to the symbols pane to
// type kr.teamspeak.app:9987.
// * textInputAction: .next \u2014 return key advances
// to the nickname field.
// * autocorrect / enableSuggestions: false \u2014 iOS
// should not autocorrect 'kr.teamspeak.app' to
// 'kr.teamspeak.lap' or suggest 'KR' in caps.
// * textCapitalization: .none \u2014 don't capitalise the
// first letter the way iOS does for sentences.
// * inputFormatters: deny whitespace and uppercase\u2014
// belt-and-braces in case the user pastes from a
// formatted source (e.g. tab-indented copy).
keyboardType: TextInputType.url,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp(r'\s')),
TextInputFormatter.withFunction(
(oldValue, newValue) => newValue.copyWith(
text: newValue.text.toLowerCase(),
selection: newValue.selection,
),
TextField(
controller: widget.hostCtl,
focusNode: _hostFocus,
onTap: () => _kickFocus(_hostFocus),
// Server addresses are URL-shaped: hostname or
// hostname:port, all-lowercase ASCII, never user-
// friendly prose. Configure the on-screen keyboard
// accordingly:
// * keyboardType: .url \u2014 surfaces ".", "/",
// ":" on the primary keyboard plane so the user
// doesn't have to switch to the symbols pane to
// type kr.teamspeak.app:9987.
// * textInputAction: .next \u2014 return key advances
// to the nickname field.
// * autocorrect / enableSuggestions: false \u2014 iOS
// should not autocorrect 'kr.teamspeak.app' to
// 'kr.teamspeak.lap' or suggest 'KR' in caps.
// * textCapitalization: .none \u2014 don't capitalise the
// first letter the way iOS does for sentences.
// * inputFormatters: deny whitespace and uppercase\u2014
// belt-and-braces in case the user pastes from a
// formatted source (e.g. tab-indented copy).
keyboardType: TextInputType.url,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp(r'\s')),
TextInputFormatter.withFunction(
(oldValue, newValue) => newValue.copyWith(
text: newValue.text.toLowerCase(),
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),
_focusOnTap(
TextField(
controller: widget.nickCtl,
focusNode: _nickFocus,
onTap: () => _nickFocus.requestFocus(),
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.fieldNickname,
border: const OutlineInputBorder(),
),
TextField(
controller: widget.nickCtl,
focusNode: _nickFocus,
onTap: () => _kickFocus(_nickFocus),
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.fieldNickname,
border: const OutlineInputBorder(),
),
_nickFocus,
),
const SizedBox(height: 8),
_focusOnTap(
TextField(
controller: widget.passwordCtl,
focusNode: _passwordFocus,
onTap: () => _passwordFocus.requestFocus(),
obscureText: true,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.fieldServerPassword,
helperText: l10n.fieldServerPasswordHelp,
border: const OutlineInputBorder(),
),
TextField(
controller: widget.passwordCtl,
focusNode: _passwordFocus,
onTap: () => _kickFocus(_passwordFocus),
obscureText: true,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.fieldServerPassword,
helperText: l10n.fieldServerPasswordHelp,
border: const OutlineInputBorder(),
),
_passwordFocus,
),
const SizedBox(height: 16),
Row(