From 79f83604da319197e6665a3e1b2f9cd46902c704 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 18:31:48 +0800 Subject: [PATCH] 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. --- apps/chanora_flutter/lib/main.dart | 197 +++++++++++++---------------- 1 file changed, 91 insertions(+), 106 deletions(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 4eeb489..d384a64 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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('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(