From 7c0e5caa92a894fe5c142704c3e428092a90d7ad Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 15:50:27 +0800 Subject: [PATCH] fix(ui,ios): first-tap on TextField now opens the keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: 'every first time to tap to input box nothing happened'. Symptom is iPhone-specific: the very first tap on any of the Host / Nickname / Password text boxes in the connect form fails to focus + open the keyboard. The second tap on the same field works. Two distinct causes, both addressed: apps/chanora_flutter/lib/main.dart The connect form lives inside a SingleChildScrollView. Flutter on iOS has a long-standing issue (flutter#19027) where the enclosing Scrollable's gesture-arena participant absorbs the first tap as a possible scroll-intent, leaving the TextField unfocused; the second tap reaches the field because the scrollable has already declined to handle a drag. Fix: _ConnectForm converted from StatelessWidget to StatefulWidget so it can own FocusNodes for the three text fields. Each TextField gains: * focusNode: * onTap: () => focusNode.requestFocus() — forces focus on tap-down regardless of arena outcome * textInputAction: TextInputAction.next (host, nick) / TextInputAction.done (password) for return-key flow * autocorrect: false, enableSuggestions: false — these are server-host / nickname / password fields, the iOS auto-correct + suggestion bar is wrong for all three. Also set keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior .onDrag on the SingleChildScrollView so the keyboard hides when the user starts scrolling the bookmark list below. apps/chanora_flutter/ios/Runner/AppDelegate.swift AVAudioSession.sharedInstance().requestRecordPermission was fired synchronously from didFinishLaunchingWithOptions. The permission alert can race with iOS's text-input subsystem initialisation: if the alert appears before the keyboard layer finishes wiring up, subsequent text-field focus requests are dropped silently. Fix: DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) to defer the permission request until ~1 s after the app shell is on screen. Long enough for iOS's text-input layer to fully initialise; short enough that the user reads the prompt before tapping a field. flutter analyze: clean (6 pre-existing Radio.groupValue infos). flutter build ios --release --no-codesign: 26.8 s clean (Runner.app 29.8 MB). --- .../ios/Runner/AppDelegate.swift | 15 ++++- apps/chanora_flutter/lib/main.dart | 58 +++++++++++++++++-- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index 40c5d93..101b0ed 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -47,8 +47,19 @@ import AVFoundation // the audio engine fails to open the input device. The // permission state is cached by iOS so subsequent launches // skip the prompt. - AVAudioSession.sharedInstance().requestRecordPermission { granted in - NSLog("chanora_flutter: microphone permission granted=\(granted)") + // + // Deferred ~1 s so iOS finishes initialising the keyboard / + // text-input subsystem before the permission alert appears. + // Firing the alert too early steals focus from the not-yet- + // ready text-input layer, with the symptom that the first tap + // on a TextField does nothing (the second tap works because + // by then iOS has caught up). DispatchQueue.main.asyncAfter + // keeps everything on the main thread; the permission API + // itself must be called there too. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + AVAudioSession.sharedInstance().requestRecordPermission { granted in + NSLog("chanora_flutter: microphone permission granted=\(granted)") + } } return super.application(application, didFinishLaunchingWithOptions: launchOptions) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 773d15f..bd60071 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -926,6 +926,11 @@ class _BetaHomeState extends State<_BetaHome> { if (_phase == _Phase.idle) ...[ Expanded( child: SingleChildScrollView( + // Dismiss keyboard when the user drags away from + // a focused field — friendlier mobile UX than + // forcing them to tap outside. + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1038,7 +1043,7 @@ class _BetaHomeState extends State<_BetaHome> { } } -class _ConnectForm extends StatelessWidget { +class _ConnectForm extends StatefulWidget { const _ConnectForm({ required this.hostCtl, required this.nickCtl, @@ -1053,6 +1058,32 @@ class _ConnectForm extends StatelessWidget { final VoidCallback onConnect; final VoidCallback onAddBookmark; + @override + State<_ConnectForm> createState() => _ConnectFormState(); +} + +class _ConnectFormState extends State<_ConnectForm> { + // FocusNodes + explicit tap-to-focus handlers work around a + // Flutter-on-iOS gesture-arena issue where the enclosing + // SingleChildScrollView absorbs the first tap on each TextField + // (the second tap then succeeds because the scrollable's tap- + // arena participant has already decided not to handle a drag). + // Calling requestFocus() in onTap forces focus immediately on + // the first tap, before the gesture arena resolves. + // https://github.com/flutter/flutter/issues/19027 — long-standing + // and still reproducible in Flutter 3.x on iOS. + final FocusNode _hostFocus = FocusNode(); + final FocusNode _nickFocus = FocusNode(); + final FocusNode _passwordFocus = FocusNode(); + + @override + void dispose() { + _hostFocus.dispose(); + _nickFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); @@ -1060,7 +1091,12 @@ class _ConnectForm extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( - controller: hostCtl, + controller: widget.hostCtl, + focusNode: _hostFocus, + onTap: () => _hostFocus.requestFocus(), + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, decoration: InputDecoration( labelText: l10n.fieldServerHost, border: const OutlineInputBorder(), @@ -1068,7 +1104,12 @@ class _ConnectForm extends StatelessWidget { ), const SizedBox(height: 8), TextField( - controller: nickCtl, + controller: widget.nickCtl, + focusNode: _nickFocus, + onTap: () => _nickFocus.requestFocus(), + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, decoration: InputDecoration( labelText: l10n.fieldNickname, border: const OutlineInputBorder(), @@ -1076,8 +1117,13 @@ class _ConnectForm extends StatelessWidget { ), const SizedBox(height: 8), TextField( - controller: passwordCtl, + 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, @@ -1091,14 +1137,14 @@ class _ConnectForm extends StatelessWidget { child: FilledButton.icon( icon: const Icon(Icons.login), label: Text(l10n.connectAction), - onPressed: onConnect, + onPressed: widget.onConnect, ), ), const SizedBox(width: 8), OutlinedButton.icon( icon: const Icon(Icons.bookmark_add_outlined), label: Text(l10n.bookmarkAddAction), - onPressed: onAddBookmark, + onPressed: widget.onAddBookmark, ), ], ),