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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user