fix(ui,ios): AppBar OVERFLOWED-BY strip + first-tap TextField via Listener

Two distinct fixes prompted by user reports from the iPhone build:

1. 'strange text on Chanora (RFLOWED BY)' \u2014 the Flutter debug
   overlay's 'OVERFLOWED BY N PIXELS' strip was appearing next to
   the AppBar title because the title Row ('Chanora' + channel
   pill) plus 5-6 trailing IconButton actions exceeded a typical
   iPhone AppBar width. User saw the strip clipped to '...RFLOWED
   BY...' since only its end fit on screen.

   _AppBarTitle now drops the 'Chanora' label on narrow widths
   (<840 dp). Title shows only the channel pill when in voice
   channel; the user already knows they're in Chanora because
   they just opened it. Wide widths (tablet/desktop, >= 840 dp)
   keep the full 'Chanora \u00b7 #channel-pill' title because there's
   room. Eliminates the overflow.

   Note: the OVERFLOWED-BY strip only renders in debug builds
   anyway; release builds suppress the overlay. But the
   underlying Row overflow was a real layout bug worth fixing.

2. First-tap TextField still failed on iPhone after the earlier
   FocusNode + TextField.onTap fix. Root cause: TextField.onTap
   fires AFTER the gesture-arena resolves, so if the enclosing
   SingleChildScrollView wins the arena (which it does on iOS
   for the very first tap), the focus request never fires.

   Wrap each connect-form TextField in a Listener with
   HitTestBehavior.translucent and onPointerDown: requestFocus.
   Listener fires synchronously on PointerDownEvent BEFORE arena
   resolution, so even if the scrollable would have won the arena
   we have already grabbed focus. Translucent means the pointer
   ALSO propagates down to the TextField so its normal touch
   handling still runs (text selection / cursor placement).
   _focusOnTap helper added; wraps all three TextFields
   (host, nick, password).

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: 18.4 s, Runner.app
30.0 MB.
This commit is contained in:
EdisonJwa
2026-05-16 17:42:05 +08:00
parent a93109ac37
commit 020be77faf
+91 -43
View File
@@ -1160,11 +1160,26 @@ class _AppBarTitle extends StatelessWidget {
if (phase != _Phase.connected || !inChannel || channelName.isEmpty) { if (phase != _Phase.connected || !inChannel || channelName.isEmpty) {
return Text(l10n.appTitle); return Text(l10n.appTitle);
} }
// When in a voice channel on a narrow phone width, the AppBar
// is already crowded with mic / headset / settings / about /
// diagnostics / disconnect icons (5-6 action buttons). Keeping
// the "Chanora" app-name label in the title here causes the
// Row to overflow at typical iPhone widths and Flutter renders
// its yellow-and-black "OVERFLOWED BY X PIXELS" debug strip
// next to the title — the user reported seeing "RFLOWED BY"
// there. Drop the app-name label on narrow widths and let the
// channel pill be the only title content; the user knows
// they're in Chanora because they just opened it. On wide
// widths (>= 840 dp, tablet/desktop) restore the app name
// because there's plenty of room.
final isNarrow = MediaQuery.of(context).size.width < 840.0;
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(l10n.appTitle), if (!isNarrow) ...[
const SizedBox(width: 12), Text(l10n.appTitle),
const SizedBox(width: 12),
],
Flexible( Flexible(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
@@ -1223,19 +1238,43 @@ class _ConnectForm extends StatefulWidget {
} }
class _ConnectFormState extends State<_ConnectForm> { class _ConnectFormState extends State<_ConnectForm> {
// FocusNodes + explicit tap-to-focus handlers work around a // FocusNodes + Listener(onPointerDown) wrappers fix a Flutter-on-
// Flutter-on-iOS gesture-arena issue where the enclosing // iOS gesture-arena issue where enclosing scrollables (in our
// SingleChildScrollView absorbs the first tap on each TextField // case, the SingleChildScrollView wrapping the connect form, and
// (the second tap then succeeds because the scrollable's tap- // the ListView wrapping the channel tree) absorb the first tap
// arena participant has already decided not to handle a drag). // as a possible scroll-intent. The second tap then succeeds
// Calling requestFocus() in onTap forces focus immediately on // because the scrollable's tap-arena participant has already
// the first tap, before the gesture arena resolves. // decided not to handle a drag.
// https://github.com/flutter/flutter/issues/19027 — long-standing //
// and still reproducible in Flutter 3.x on iOS. // Two-layer fix:
// * focusNode + autofocus-on-tap inside TextField — works when
// the arena resolves the tap as a TextField gesture
// * Listener(onPointerDown) wrapping the TextField with
// HitTestBehavior.translucent — fires synchronously on
// pointer-down BEFORE arena resolution, so even if the
// scrollable were to win the arena we have already grabbed
// focus. Translucent means the pointer ALSO propagates down
// to the TextField so its normal touch handling still runs.
//
// This is the documented workaround in
// https://github.com/flutter/flutter/issues/22680 and many
// related arena-race threads.
final FocusNode _hostFocus = FocusNode(); final FocusNode _hostFocus = FocusNode();
final FocusNode _nickFocus = FocusNode(); final FocusNode _nickFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode(); final FocusNode _passwordFocus = FocusNode();
Widget _focusOnTap(Widget child, FocusNode node) {
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) {
if (!node.hasFocus) {
node.requestFocus();
}
},
child: child,
);
}
@override @override
void dispose() { void dispose() {
_hostFocus.dispose(); _hostFocus.dispose();
@@ -1250,45 +1289,54 @@ class _ConnectFormState extends State<_ConnectForm> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TextField( _focusOnTap(
controller: widget.hostCtl, TextField(
focusNode: _hostFocus, controller: widget.hostCtl,
onTap: () => _hostFocus.requestFocus(), focusNode: _hostFocus,
textInputAction: TextInputAction.next, onTap: () => _hostFocus.requestFocus(),
autocorrect: false, textInputAction: TextInputAction.next,
enableSuggestions: false, autocorrect: false,
decoration: InputDecoration( enableSuggestions: false,
labelText: l10n.fieldServerHost, decoration: InputDecoration(
border: const OutlineInputBorder(), labelText: l10n.fieldServerHost,
border: const OutlineInputBorder(),
),
), ),
_hostFocus,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( _focusOnTap(
controller: widget.nickCtl, TextField(
focusNode: _nickFocus, controller: widget.nickCtl,
onTap: () => _nickFocus.requestFocus(), focusNode: _nickFocus,
textInputAction: TextInputAction.next, onTap: () => _nickFocus.requestFocus(),
autocorrect: false, textInputAction: TextInputAction.next,
enableSuggestions: false, autocorrect: false,
decoration: InputDecoration( enableSuggestions: false,
labelText: l10n.fieldNickname, decoration: InputDecoration(
border: const OutlineInputBorder(), labelText: l10n.fieldNickname,
border: const OutlineInputBorder(),
),
), ),
_nickFocus,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( _focusOnTap(
controller: widget.passwordCtl, TextField(
focusNode: _passwordFocus, controller: widget.passwordCtl,
onTap: () => _passwordFocus.requestFocus(), focusNode: _passwordFocus,
obscureText: true, onTap: () => _passwordFocus.requestFocus(),
textInputAction: TextInputAction.done, obscureText: true,
autocorrect: false, textInputAction: TextInputAction.done,
enableSuggestions: false, autocorrect: false,
decoration: InputDecoration( enableSuggestions: false,
labelText: l10n.fieldServerPassword, decoration: InputDecoration(
helperText: l10n.fieldServerPasswordHelp, labelText: l10n.fieldServerPassword,
border: const OutlineInputBorder(), helperText: l10n.fieldServerPasswordHelp,
border: const OutlineInputBorder(),
),
), ),
_passwordFocus,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(