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\u219223bd1c7) 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: *020be77wrapped 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'. *23bd1c7added 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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user