fix(ui,ios): first-tap on TextField now opens the keyboard

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: <its own 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).
This commit is contained in:
EdisonJwa
2026-05-16 15:50:27 +08:00
parent d4c04b6a72
commit 7c0e5caa92
2 changed files with 65 additions and 8 deletions
@@ -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)
+52 -6
View File
@@ -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,
),
],
),