fix(tvos): unify remote and text input ownership

This commit is contained in:
edde746
2026-07-26 07:08:01 +02:00
parent fcaa81bf3c
commit 71735354b9
17 changed files with 651 additions and 97 deletions
+261 -69
View File
@@ -11,30 +11,50 @@ import 'dpad_navigator.dart';
import 'key_event_utils.dart'; import 'key_event_utils.dart';
import 'owned_focus_node_binding.dart'; import 'owned_focus_node_binding.dart';
bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isTV(); enum TvTextInputPresentation {
/// Use the native platform keyboard for single-line Apple TV input and the
/// Flutter overlay on other TVs or for multiline input.
automatic,
/// Always use the platform text input implementation.
platform,
/// Always use Plezy's in-app Flutter keyboard overlay.
flutterOverlay,
}
bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputType? keyboardType, int? maxLines}) {
if (!PlatformDetector.isTV()) return false;
return switch (presentation) {
TvTextInputPresentation.automatic =>
!PlatformDetector.isAppleTV() || _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines),
TvTextInputPresentation.platform => false,
TvTextInputPresentation.flutterOverlay => true,
};
}
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText; String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
enum TvKeyboardAutoOpenBehavior { enum TvTextInputAutoOpenBehavior {
/// Open the TV virtual keyboard whenever the field receives focus. /// Open the selected TV text input presentation whenever the field receives
/// focus.
onFocus, onFocus,
/// Keep initial focus on the field without opening the keyboard, then open /// Keep initial focus on the field without opening text input, then open it
/// automatically on later focus entries. Explicit tap/select still opens it. /// automatically on later focus entries. Explicit tap/select still opens it.
afterFirstFocus, afterFirstFocus,
/// Never auto-open the TV virtual keyboard on focus. Explicit tap/select /// Never auto-open text input on focus. Explicit tap/select still opens it.
/// still opens it.
never, never,
} }
/// Imperative handle to the TV on-screen keyboard of a [FocusableTextField] / /// Imperative handle to TV text input for a [FocusableTextField] /
/// [FocusableTextFormField]. Pass the same instance to the field's /// [FocusableTextFormField]. Pass the same instance to the field's
/// `tvKeyboardController`; the field's host attaches itself on mount. /// `tvTextInputController`; the field's host attaches itself on mount.
/// ///
/// Only meaningful on TV. On other platforms the OSK never exists, so every /// Only meaningful on TV. On other platforms every method is an effective
/// method is an effective no-op. /// no-op.
class TvKeyboardController { class TvTextInputController {
_FocusableTextInputHostState? _host; _FocusableTextInputHostState? _host;
void _attach(_FocusableTextInputHostState host) => _host = host; void _attach(_FocusableTextInputHostState host) => _host = host;
@@ -42,14 +62,13 @@ class TvKeyboardController {
if (identical(_host, host)) _host = null; if (identical(_host, host)) _host = null;
} }
/// Dismiss the OSK if it is open (and prevent it from auto-reopening while /// Dismiss the Flutter overlay if it is open and prevent it from reopening
/// the field keeps focus). No-op when nothing is open. /// while the field keeps focus.
void closeKeyboard() => _host?._dismissTvKeyboard(); void closeTextInput() => _host?._dismissTvKeyboard();
/// Focus the field but do NOT open the OSK for this focus entry. Used as a /// Focus the field without opening either native or Flutter text input for
/// fallback landing spot (e.g. a remote search that returned no results) so /// this focus entry.
/// the remote isn't stranded on an off-screen element. void focusInputWithoutOpening() => _host?._focusWithoutKeyboard();
void focusInputWithoutKeyboard() => _host?._focusWithoutKeyboard();
} }
String _describeTextInputKey(KeyEvent event) { String _describeTextInputKey(KeyEvent event) {
@@ -117,6 +136,8 @@ KeyEventResult _handleInputKey({
required bool usesTvKeyboard, required bool usesTvKeyboard,
required bool enabled, required bool enabled,
required VoidCallback openKeyboard, required VoidCallback openKeyboard,
required bool activateNativeTextInput,
required VoidCallback activateNativeTextInputCallback,
required KeyEvent event, required KeyEvent event,
TextInputType? keyboardType, TextInputType? keyboardType,
TextInputAction? textInputAction, TextInputAction? textInputAction,
@@ -154,7 +175,17 @@ KeyEventResult _handleInputKey({
); );
} }
if (_shouldPassNativeTvKeyToPlatform(usesTvKeyboard: usesTvKeyboard, enabled: enabled, event: event)) { if (activateNativeTextInput && enabled && event.isTvSelectEvent) {
if (event is KeyDownEvent) activateNativeTextInputCallback();
return finish(KeyEventResult.handled, 'activate-native-tv-text-input');
}
if (_shouldPassNativeTvKeyToPlatform(
usesTvKeyboard: usesTvKeyboard,
nativeTextInputActive: !activateNativeTextInput,
enabled: enabled,
event: event,
)) {
return finish(KeyEventResult.skipRemainingHandlers, 'pass-native-tv-key-to-platform'); return finish(KeyEventResult.skipRemainingHandlers, 'pass-native-tv-key-to-platform');
} }
@@ -247,11 +278,17 @@ KeyEventResult _handleInputKey({
return finish(KeyEventResult.ignored, 'fall-through'); return finish(KeyEventResult.ignored, 'fall-through');
} }
bool _shouldPassNativeTvKeyToPlatform({required bool usesTvKeyboard, required bool enabled, required KeyEvent event}) { bool _shouldPassNativeTvKeyToPlatform({
if (!enabled || usesTvKeyboard || !PlatformDetector.isTV()) { required bool usesTvKeyboard,
required bool nativeTextInputActive,
required bool enabled,
required KeyEvent event,
}) {
if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isTV()) {
if (TextInputDiagnostics.enabled) { if (TextInputDiagnostics.enabled) {
_logTvTextInput( _logTvTextInput(
'native-pass=false reason=disabled-or-custom-keyboard enabled=$enabled usesTvKeyboard=$usesTvKeyboard ' 'native-pass=false reason=inactive-disabled-or-custom enabled=$enabled '
'usesTvKeyboard=$usesTvKeyboard nativeTextInputActive=$nativeTextInputActive '
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})', 'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})',
); );
} }
@@ -546,9 +583,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
final VoidCallback? onBack; final VoidCallback? onBack;
final bool autofocus; final bool autofocus;
final bool enabled; final bool enabled;
final bool enableTvKeyboard; final TvTextInputPresentation tvTextInputPresentation;
final TvKeyboardAutoOpenBehavior tvKeyboardAutoOpenBehavior; final TvTextInputAutoOpenBehavior tvTextInputAutoOpenBehavior;
final TvKeyboardController? tvKeyboardController; final TvTextInputController? tvTextInputController;
final bool obscureText; final bool obscureText;
final bool autocorrect; final bool autocorrect;
final bool enableSuggestions; final bool enableSuggestions;
@@ -580,9 +617,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
this.onBack, this.onBack,
this.autofocus = false, this.autofocus = false,
this.enabled = true, this.enabled = true,
this.enableTvKeyboard = true, this.tvTextInputPresentation = TvTextInputPresentation.automatic,
this.tvKeyboardAutoOpenBehavior = TvKeyboardAutoOpenBehavior.onFocus, this.tvTextInputAutoOpenBehavior = TvTextInputAutoOpenBehavior.onFocus,
this.tvKeyboardController, this.tvTextInputController,
this.obscureText = false, this.obscureText = false,
this.autocorrect = true, this.autocorrect = true,
this.enableSuggestions = true, this.enableSuggestions = true,
@@ -599,7 +636,8 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
this.onNavigateDown, this.onNavigateDown,
}); });
bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard); bool get _hasTvKeyboard =>
_usesTvKeyboard(presentation: tvTextInputPresentation, keyboardType: keyboardType, maxLines: maxLines);
bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard; bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard;
VoidCallback? get _effectiveOnEditingComplete { VoidCallback? get _effectiveOnEditingComplete {
@@ -627,23 +665,41 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
bool? enableInteractiveSelection, bool? enableInteractiveSelection,
VoidCallback? onTap, VoidCallback? onTap,
}) })
_tvInputConfiguration(bool usesTvKeyboard, VoidCallback openKeyboard) { _tvInputConfiguration({
required bool usesTvKeyboard,
required bool nativeTextInputReadOnly,
required VoidCallback openKeyboard,
required VoidCallback activateNativeTextInput,
}) {
return ( return (
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType, keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
readOnly: usesTvKeyboard, readOnly: usesTvKeyboard || nativeTextInputReadOnly,
showCursor: usesTvKeyboard ? true : null, showCursor: usesTvKeyboard || nativeTextInputReadOnly ? true : null,
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection, enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
onTap: usesTvKeyboard ? openKeyboard : null, onTap: usesTvKeyboard
? openKeyboard
: nativeTextInputReadOnly
? activateNativeTextInput
: null,
); );
} }
KeyEventResult _handleKey(BuildContext context, FocusNode node, KeyEvent event, VoidCallback openKeyboard) { KeyEventResult _handleKey(
BuildContext context,
FocusNode node,
KeyEvent event,
VoidCallback openKeyboard, {
required bool activateNativeTextInput,
required VoidCallback activateNativeTextInputCallback,
}) {
return _handleInputKey( return _handleInputKey(
controller: controller, controller: controller,
node: node, node: node,
usesTvKeyboard: _hasTvKeyboard, usesTvKeyboard: _hasTvKeyboard,
enabled: enabled, enabled: enabled,
openKeyboard: openKeyboard, openKeyboard: openKeyboard,
activateNativeTextInput: activateNativeTextInput,
activateNativeTextInputCallback: activateNativeTextInputCallback,
event: event, event: event,
keyboardType: keyboardType, keyboardType: keyboardType,
textInputAction: textInputAction, textInputAction: textInputAction,
@@ -662,16 +718,23 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
); );
} }
Widget buildFocusableInput( Widget buildFocusableInput(_FocusableTextInputBuilder builder) {
Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder,
) {
return _FocusableTextInputHost(input: this, builder: builder); return _FocusableTextInputHost(input: this, builder: builder);
} }
} }
typedef _FocusableTextInputBuilder =
Widget Function({
required bool usesTvKeyboard,
required bool nativeTextInputReadOnly,
required FocusNode focusNode,
required VoidCallback openKeyboard,
required VoidCallback activateNativeTextInput,
});
class _FocusableTextInputHost extends StatefulWidget { class _FocusableTextInputHost extends StatefulWidget {
final _FocusableTextInputBase input; final _FocusableTextInputBase input;
final Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder; final _FocusableTextInputBuilder builder;
const _FocusableTextInputHost({required this.input, required this.builder}); const _FocusableTextInputHost({required this.input, required this.builder});
@@ -693,6 +756,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
bool _suppressTvKeyboardAutoOpen = false; bool _suppressTvKeyboardAutoOpen = false;
bool _hasSeenTvKeyboardFocus = false; bool _hasSeenTvKeyboardFocus = false;
bool _suppressTvKeyboardForCurrentFocus = false; bool _suppressTvKeyboardForCurrentFocus = false;
bool _nativeTextInputActivated = false;
bool _hasSeenNativeTextInputFocus = false;
bool _suppressNativeTextInputForCurrentFocus = false;
FocusNode get _effectiveFocusNode => _focusNodeBinding.node; FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
@@ -700,15 +766,15 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
void initState() { void initState() {
super.initState(); super.initState();
_focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput'); _focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput');
widget.input.tvKeyboardController?._attach(this); widget.input.tvTextInputController?._attach(this);
} }
@override @override
void didUpdateWidget(_FocusableTextInputHost oldWidget) { void didUpdateWidget(_FocusableTextInputHost oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.input.tvKeyboardController, widget.input.tvKeyboardController)) { if (!identical(oldWidget.input.tvTextInputController, widget.input.tvTextInputController)) {
oldWidget.input.tvKeyboardController?._detach(this); oldWidget.input.tvTextInputController?._detach(this);
widget.input.tvKeyboardController?._attach(this); widget.input.tvTextInputController?._attach(this);
} }
if (oldWidget.input.focusNode != widget.input.focusNode) { if (oldWidget.input.focusNode != widget.input.focusNode) {
// An open keyboard dialog intentionally survives rebuilds and focusNode // An open keyboard dialog intentionally survives rebuilds and focusNode
@@ -719,13 +785,24 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_tvKeyboardOpenScheduled = false; _tvKeyboardOpenScheduled = false;
_hasSeenTvKeyboardFocus = false; _hasSeenTvKeyboardFocus = false;
_suppressTvKeyboardForCurrentFocus = false; _suppressTvKeyboardForCurrentFocus = false;
_nativeTextInputActivated = false;
_hasSeenNativeTextInputFocus = false;
_suppressNativeTextInputForCurrentFocus = false;
} }
_handleFocusChanged(); if (oldWidget.input.tvTextInputPresentation != widget.input.tvTextInputPresentation ||
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior) {
_nativeTextInputActivated = false;
_hasSeenNativeTextInputFocus = false;
_suppressNativeTextInputForCurrentFocus = false;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _handleFocusChanged();
});
} }
@override @override
void dispose() { void dispose() {
widget.input.tvKeyboardController?._detach(this); widget.input.tvTextInputController?._detach(this);
_restoreInstalledHandler(); _restoreInstalledHandler();
// The keyboard is a navigator route — it must not outlive the field that // The keyboard is a navigator route — it must not outlive the field that
// opened it (e.g. a form section swapped out while the keyboard is up). // opened it (e.g. a form section swapped out while the keyboard is up).
@@ -739,10 +816,53 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
} }
void _handleFocusChanged() { void _handleFocusChanged() {
_syncNativeTextInputActivation();
_syncNativeTextInputFocus(); _syncNativeTextInputFocus();
_syncTvKeyboardAutoOpen(); _syncTvKeyboardAutoOpen();
} }
void _syncNativeTextInputActivation() {
final input = widget.input;
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
if (!focused) {
_suppressNativeTextInputForCurrentFocus = false;
_setNativeTextInputActivated(false);
return;
}
if (_suppressNativeTextInputForCurrentFocus) return;
switch (input.tvTextInputAutoOpenBehavior) {
case TvTextInputAutoOpenBehavior.onFocus:
_hasSeenNativeTextInputFocus = true;
_setNativeTextInputActivated(true);
case TvTextInputAutoOpenBehavior.afterFirstFocus:
if (!_hasSeenNativeTextInputFocus) {
_hasSeenNativeTextInputFocus = true;
_suppressNativeTextInputForCurrentFocus = true;
return;
}
_setNativeTextInputActivated(true);
case TvTextInputAutoOpenBehavior.never:
return;
}
}
void _setNativeTextInputActivated(bool activated) {
if (_nativeTextInputActivated == activated) return;
if (!mounted) {
_nativeTextInputActivated = activated;
return;
}
setState(() => _nativeTextInputActivated = activated);
}
void _activateNativeTextInput() {
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
_hasSeenNativeTextInputFocus = true;
_suppressNativeTextInputForCurrentFocus = false;
_setNativeTextInputActivated(true);
}
void _syncNativeTextInputFocus() { void _syncNativeTextInputFocus() {
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard; final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
if (TextInputDiagnostics.enabled) { if (TextInputDiagnostics.enabled) {
@@ -761,7 +881,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
if (TextInputDiagnostics.enabled) { if (TextInputDiagnostics.enabled) {
_logTvTextInput( _logTvTextInput(
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled ' 'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvKeyboardAutoOpenBehavior} ' 'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvTextInputAutoOpenBehavior} '
'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus ' 'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus '
'installed=${_installedFocusNode?.debugLabel} ' 'installed=${_installedFocusNode?.debugLabel} '
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} ' 'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
@@ -797,17 +917,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
} }
bool _shouldAutoOpenTvKeyboardForCurrentFocus() { bool _shouldAutoOpenTvKeyboardForCurrentFocus() {
switch (widget.input.tvKeyboardAutoOpenBehavior) { switch (widget.input.tvTextInputAutoOpenBehavior) {
case TvKeyboardAutoOpenBehavior.onFocus: case TvTextInputAutoOpenBehavior.onFocus:
return true; return true;
case TvKeyboardAutoOpenBehavior.afterFirstFocus: case TvTextInputAutoOpenBehavior.afterFirstFocus:
if (!_hasSeenTvKeyboardFocus) { if (!_hasSeenTvKeyboardFocus) {
_hasSeenTvKeyboardFocus = true; _hasSeenTvKeyboardFocus = true;
_suppressTvKeyboardForCurrentFocus = true; _suppressTvKeyboardForCurrentFocus = true;
return false; return false;
} }
return !_suppressTvKeyboardForCurrentFocus; return !_suppressTvKeyboardForCurrentFocus;
case TvKeyboardAutoOpenBehavior.never: case TvTextInputAutoOpenBehavior.never:
return false; return false;
} }
} }
@@ -886,19 +1006,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_tvKeyboardHandle?.close(); _tvKeyboardHandle?.close();
} }
/// Focus the field without opening the OSK for this focus entry. Suppress is /// Focus the field without opening either native or Flutter text input for
/// set BEFORE requestFocus so the resulting focus-change sync already sees it; /// this focus entry. Suppression is set before requestFocus so the resulting
/// the flag persists for this focus and resets on the next unfocus. If a /// focus-change callback cannot open either presentation.
/// same-turn focus request supersedes this one, clear the unused suppression
/// after Flutter resolves its pending focus change.
void _focusWithoutKeyboard() { void _focusWithoutKeyboard() {
_suppressTvKeyboardAutoOpen = true; _suppressTvKeyboardAutoOpen = true;
_suppressNativeTextInputForCurrentFocus = true;
_tvKeyboardOpenScheduled = false; _tvKeyboardOpenScheduled = false;
if (widget.input._usesNativeTvKeyboard) {
_setNativeTextInputActivated(false);
}
final focusNode = _installedFocusNode ?? _effectiveFocusNode; final focusNode = _installedFocusNode ?? _effectiveFocusNode;
focusNode.requestFocus(); focusNode.requestFocus();
scheduleMicrotask(() { scheduleMicrotask(() {
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return; if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
_suppressTvKeyboardAutoOpen = false; _suppressTvKeyboardAutoOpen = false;
_suppressNativeTextInputForCurrentFocus = false;
}); });
} }
@@ -921,7 +1044,41 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
); );
if (result != KeyEventResult.ignored) return result; if (result != KeyEventResult.ignored) return result;
} }
return widget.input._handleKey(context, node, event, _openTvKeyboard); var activateNativeTextInput = widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated;
final isRemoteNavigation = event.logicalKey.isDpadDirection || event.logicalKey.isBackKey || event.isTvSelectEvent;
if (PlatformDetector.isAppleTV() &&
widget.input._usesNativeTvKeyboard &&
_nativeTextInputActivated &&
event is KeyDownEvent &&
isRemoteNavigation) {
// Remote navigation events are system-owned while the native keyboard
// is active. Receiving one here proves that UIKit has dismissed the
// keyboard while Flutter focus stayed on the field. Restore the
// read-only gate so this press navigates Flutter instead of reopening
// the input connection.
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
activateNativeTextInput = true;
if (event.logicalKey.isBackKey) {
// This is the Menu press that dismissed UIKit's keyboard. Consume its
// Flutter continuation so one press cannot also pop the app route.
return KeyEventResult.handled;
}
if (event.isTvSelectEvent) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _activateNativeTextInput();
});
return KeyEventResult.handled;
}
}
return widget.input._handleKey(
context,
node,
event,
_openTvKeyboard,
activateNativeTextInput: activateNativeTextInput,
activateNativeTextInputCallback: _activateNativeTextInput,
);
} }
void _installKeyHandler(FocusNode node) { void _installKeyHandler(FocusNode node) {
@@ -946,6 +1103,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
void _restoreInstalledHandler() { void _restoreInstalledHandler() {
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}'); _logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
_setNativeTextInputFocused(false); _setNativeTextInputFocused(false);
_nativeTextInputActivated = false;
_suppressNativeTextInputForCurrentFocus = false;
final node = _installedFocusNode; final node = _installedFocusNode;
if (node != null) { if (node != null) {
node.removeListener(_focusListener); node.removeListener(_focusListener);
@@ -961,8 +1120,19 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final focusNode = _effectiveFocusNode; final focusNode = _effectiveFocusNode;
_installKeyHandler(focusNode); _installKeyHandler(focusNode);
_handleFocusChanged(); _syncNativeTextInputFocus();
return widget.builder(widget.input._hasTvKeyboard, focusNode, _openTvKeyboard); _syncTvKeyboardAutoOpen();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _handleFocusChanged();
});
final usesTvKeyboard = widget.input._hasTvKeyboard;
return widget.builder(
usesTvKeyboard: usesTvKeyboard,
nativeTextInputReadOnly: widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated,
focusNode: focusNode,
openKeyboard: _openTvKeyboard,
activateNativeTextInput: _activateNativeTextInput,
);
} }
} }
@@ -989,9 +1159,9 @@ class FocusableTextField extends _FocusableTextInputBase {
super.onBack, super.onBack,
super.autofocus, super.autofocus,
super.enabled, super.enabled,
super.enableTvKeyboard, super.tvTextInputPresentation,
super.tvKeyboardAutoOpenBehavior, super.tvTextInputAutoOpenBehavior,
super.tvKeyboardController, super.tvTextInputController,
super.obscureText, super.obscureText,
super.autocorrect, super.autocorrect,
super.enableSuggestions, super.enableSuggestions,
@@ -1010,11 +1180,22 @@ class FocusableTextField extends _FocusableTextInputBase {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) { return buildFocusableInput(({
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard); required bool usesTvKeyboard,
required bool nativeTextInputReadOnly,
required FocusNode focusNode,
required VoidCallback openKeyboard,
required VoidCallback activateNativeTextInput,
}) {
final tvInput = _tvInputConfiguration(
usesTvKeyboard: usesTvKeyboard,
nativeTextInputReadOnly: nativeTextInputReadOnly,
openKeyboard: openKeyboard,
activateNativeTextInput: activateNativeTextInput,
);
return TextField( return TextField(
controller: controller, controller: controller,
focusNode: effectiveFocusNode, focusNode: focusNode,
enabled: enabled, enabled: enabled,
decoration: decoration, decoration: decoration,
keyboardType: tvInput.keyboardType, keyboardType: tvInput.keyboardType,
@@ -1066,9 +1247,9 @@ class FocusableTextFormField extends _FocusableTextInputBase {
this.onSaved, this.onSaved,
super.autofocus, super.autofocus,
super.enabled, super.enabled,
super.enableTvKeyboard, super.tvTextInputPresentation,
super.tvKeyboardAutoOpenBehavior, super.tvTextInputAutoOpenBehavior,
super.tvKeyboardController, super.tvTextInputController,
super.obscureText, super.obscureText,
super.autocorrect, super.autocorrect,
super.enableSuggestions, super.enableSuggestions,
@@ -1087,11 +1268,22 @@ class FocusableTextFormField extends _FocusableTextInputBase {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) { return buildFocusableInput(({
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard); required bool usesTvKeyboard,
required bool nativeTextInputReadOnly,
required FocusNode focusNode,
required VoidCallback openKeyboard,
required VoidCallback activateNativeTextInput,
}) {
final tvInput = _tvInputConfiguration(
usesTvKeyboard: usesTvKeyboard,
nativeTextInputReadOnly: nativeTextInputReadOnly,
openKeyboard: openKeyboard,
activateNativeTextInput: activateNativeTextInput,
);
return TextFormField( return TextFormField(
controller: controller, controller: controller,
focusNode: effectiveFocusNode, focusNode: focusNode,
enabled: enabled, enabled: enabled,
decoration: decoration, decoration: decoration,
keyboardType: tvInput.keyboardType, keyboardType: tvInput.keyboardType,
+1
View File
@@ -72,6 +72,7 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
FocusableTextField( FocusableTextField(
controller: searchController, controller: searchController,
focusNode: searchFocusNode, focusNode: searchFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
+5 -4
View File
@@ -33,7 +33,7 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen> class _SearchScreenState extends State<SearchScreen>
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch { with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
String? _focusResultsForQuery; String? _focusResultsForQuery;
final _tvKeyboardController = TvKeyboardController(); final _tvTextInputController = TvTextInputController();
final _clearFocusNode = FocusNode(debugLabel: 'Search.clear'); final _clearFocusNode = FocusNode(debugLabel: 'Search.clear');
@override @override
@@ -130,13 +130,13 @@ class _SearchScreenState extends State<SearchScreen>
// Focusing the field normally auto-opens the OSK; a remote search must not // Focusing the field normally auto-opens the OSK; a remote search must not
// show it, and must dismiss one the TV user already had open (the phone's // show it, and must dismiss one the TV user already had open (the phone's
// Search chip sends tabSearch before the query arrives). // Search chip sends tabSearch before the query arrives).
_tvKeyboardController.closeKeyboard(); _tvTextInputController.closeTextInput();
if (trimmed.isEmpty) return; if (trimmed.isEmpty) return;
// Land focus on the (visible) input immediately so the D-pad remote is // Land focus on the (visible) input immediately so the D-pad remote is
// never stranded on the hidden previous tab — while the search is in // never stranded on the hidden previous tab — while the search is in
// flight, when it fails, and when it returns nothing. // flight, when it fails, and when it returns nothing.
_tvKeyboardController.focusInputWithoutKeyboard(); _tvTextInputController.focusInputWithoutOpening();
// Same path as the OSK Search key: jumps straight to already-matching // Same path as the OSK Search key: jumps straight to already-matching
// results, or cancels the debounce and runs now; the screen override arms // results, or cancels the debounce and runs now; the screen override arms
@@ -225,7 +225,8 @@ class _SearchScreenState extends State<SearchScreen>
FocusableTextField( FocusableTextField(
controller: searchController, controller: searchController,
focusNode: searchFocusNode, focusNode: searchFocusNode,
tvKeyboardController: _tvKeyboardController, tvTextInputController: _tvTextInputController,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onNavigateLeft: _navigateToSidebar, onNavigateLeft: _navigateToSidebar,
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
@@ -487,8 +487,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
FocusableTextFormField( FocusableTextFormField(
controller: _urlController, controller: _urlController,
focusNode: _urlFocus, focusNode: _urlFocus,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
autofocus: true, autofocus: true,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
minLines: 1, minLines: 1,
maxLines: 4, maxLines: 4,
@@ -99,6 +99,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
FocusableTextFormField( FocusableTextFormField(
controller: _urlsController, controller: _urlsController,
focusNode: _urlsFocus, focusNode: _urlsFocus,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
autofocus: true, autofocus: true,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
minLines: 1, minLines: 1,
@@ -312,6 +312,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
controller: _textController, controller: _textController,
focusNode: _textFieldFocusNode, focusNode: _textFieldFocusNode,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
maxLines: null, maxLines: null,
minLines: 12, minLines: 12,
decoration: InputDecoration( decoration: InputDecoration(
@@ -184,7 +184,7 @@ class _SeerrConnectScreenState extends State<SeerrConnectScreen> with AsyncFormS
controller: _urlController, controller: _urlController,
focusNode: _urlFocus, focusNode: _urlFocus,
autofocus: true, autofocus: true,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
+3
View File
@@ -257,6 +257,9 @@ class _TextInputDialogState extends State<_TextInputDialog> with ControllerDispo
controller: _controller, controller: _controller,
focusNode: _fieldFocusNode, focusNode: _fieldFocusNode,
autofocus: true, autofocus: true,
tvTextInputPresentation: widget.multiline
? TvTextInputPresentation.flutterOverlay
: TvTextInputPresentation.automatic,
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText, errorText: _errorText), decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText, errorText: _errorText),
keyboardType: widget.keyboardType ?? (widget.multiline ? TextInputType.multiline : null), keyboardType: widget.keyboardType ?? (widget.multiline ? TextInputType.multiline : null),
inputFormatters: widget.inputFormatters, inputFormatters: widget.inputFormatters,
+2 -1
View File
@@ -1905,7 +1905,8 @@ class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
FocusableTextField( FocusableTextField(
controller: _filterController, controller: _filterController,
focusNode: _filterFocusNode, focusNode: _filterFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
onNavigateDown: _firstItemFocusNode.requestFocus, onNavigateDown: _firstItemFocusNode.requestFocus,
decoration: pillInputDecoration( decoration: pillInputDecoration(
context, context,
@@ -284,6 +284,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
child: FocusableTextField( child: FocusableTextField(
controller: _titleController, controller: _titleController,
focusNode: _titleFocusNode, focusNode: _titleFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
decoration: pillInputDecoration( decoration: pillInputDecoration(
context, context,
hintText: widget.mediaTitle ?? t.metadataEdit.title, hintText: widget.mediaTitle ?? t.metadataEdit.title,
@@ -451,6 +452,7 @@ class _LanguagePickerViewState extends State<_LanguagePickerView> with Controlle
child: FocusableTextField( child: FocusableTextField(
controller: _filterController, controller: _filterController,
focusNode: _filterFocusNode, focusNode: _filterFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
autofocus: true, autofocus: true,
decoration: pillInputDecoration( decoration: pillInputDecoration(
context, context,
+5 -1
View File
@@ -74,7 +74,11 @@ void main() {
body: StatefulBuilder( body: StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
rebuild = setState; rebuild = setState;
return FocusableTextField(controller: controller, focusNode: node, enableTvKeyboard: false); return FocusableTextField(
controller: controller,
focusNode: node,
tvTextInputPresentation: TvTextInputPresentation.platform,
);
}, },
), ),
), ),
+21 -4
View File
@@ -19,8 +19,16 @@ void main() {
home: Scaffold( home: Scaffold(
body: Column( body: Column(
children: [ children: [
FocusableTextField(controller: c1, focusNode: first, enableTvKeyboard: false), FocusableTextField(
FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false), controller: c1,
focusNode: first,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
FocusableTextField(
controller: c2,
focusNode: second,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
], ],
), ),
), ),
@@ -56,8 +64,17 @@ void main() {
home: Scaffold( home: Scaffold(
body: Column( body: Column(
children: [ children: [
FocusableTextField(controller: c1, focusNode: first, maxLines: 4, enableTvKeyboard: false), FocusableTextField(
FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false), controller: c1,
focusNode: first,
maxLines: 4,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
FocusableTextField(
controller: c2,
focusNode: second,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
], ],
), ),
), ),
+226 -14
View File
@@ -149,7 +149,11 @@ void main() {
home: Scaffold( home: Scaffold(
body: TickerMode( body: TickerMode(
enabled: visible, enabled: visible,
child: FocusableTextField(controller: controller, focusNode: fieldFocusNode), child: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
),
), ),
), ),
), ),
@@ -181,7 +185,11 @@ void main() {
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: present body: present
? FocusableTextField(controller: controller, focusNode: fieldFocusNode) ? FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
)
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
), ),
@@ -211,7 +219,11 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode), body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
),
), ),
), ),
); );
@@ -227,6 +239,196 @@ void main() {
expect(find.byType(Dialog), findsNothing); expect(find.byType(Dialog), findsNothing);
}); });
testWidgets('Apple TV automatic single-line input uses the platform field', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_name_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
});
testWidgets('Apple TV navigation resumes after native keyboard dismissal', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
final nextFocusNode = FocusNode(debugLabel: 'save_button');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
addTearDown(nextFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
onNavigateDown: nextFocusNode.requestFocus,
),
FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Save')),
],
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown));
await tester.pumpAndSettle();
expect(result, KeyEventResult.handled);
expect(nextFocusNode.hasPrimaryFocus, isTrue);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
});
testWidgets('Apple TV Menu dismissal does not also invoke app back', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
var backCount = 0;
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode, onBack: () => backCount++),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
await tester.pumpAndSettle();
expect(result, KeyEventResult.handled);
expect(backCount, 0);
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
final secondResult = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
await tester.pump();
expect(secondResult, KeyEventResult.handled);
expect(backCount, 1);
});
testWidgets('Apple TV Select reopens native input after dismissal', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select));
await tester.pump();
expect(result, KeyEventResult.handled);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
await tester.pump();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
});
testWidgets('Apple TV after-first-focus waits for explicit Select', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
});
testWidgets('Apple TV after-first-focus auto-opens on refocus', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
final otherFocusNode = FocusNode(debugLabel: 'next_button');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
addTearDown(otherFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
),
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
],
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
otherFocusNode.requestFocus();
await tester.pumpAndSettle();
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
});
testWidgets('Android TV native keyboard done uses D-pad navigation', (tester) async { testWidgets('Android TV native keyboard done uses D-pad navigation', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true); await TvDetectionService.getInstance(forceTv: true);
@@ -246,7 +448,7 @@ void main() {
FocusableTextField( FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
enableTvKeyboard: false, tvTextInputPresentation: TvTextInputPresentation.platform,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onNavigateDown: nextFocusNode.requestFocus, onNavigateDown: nextFocusNode.requestFocus,
), ),
@@ -311,7 +513,7 @@ void main() {
FocusableTextFormField( FocusableTextFormField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
), ),
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)), Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
], ],
@@ -353,7 +555,7 @@ void main() {
body: FocusableTextFormField( body: FocusableTextFormField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
), ),
), ),
), ),
@@ -392,7 +594,7 @@ void main() {
FocusableTextFormField( FocusableTextFormField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
enableTvKeyboard: false, tvTextInputPresentation: TvTextInputPresentation.platform,
onNavigateDown: nextFocusNode.requestFocus, onNavigateDown: nextFocusNode.requestFocus,
onSelect: () => selects++, onSelect: () => selects++,
onBack: () => backs++, onBack: () => backs++,
@@ -467,7 +669,11 @@ void main() {
home: Scaffold( home: Scaffold(
body: Column( body: Column(
children: [ children: [
FocusableTextFormField(controller: controller, focusNode: fieldFocusNode, enableTvKeyboard: false), FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()), Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
], ],
), ),
@@ -557,11 +763,11 @@ void main() {
expect(find.byType(Dialog), findsNothing); expect(find.byType(Dialog), findsNothing);
}); });
testWidgets('tvOS engine-synthesized select is handled by the virtual keyboard', (tester) async { testWidgets('tvOS synthesized Select opens an explicit Flutter overlay', (tester) async {
// The custom Flutter tvOS engine emits Siri Remote center-dpad presses // The custom Flutter tvOS engine emits Siri Remote center-dpad presses
// as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the // as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the
// legacy `flutter/keyevent` Android DPAD_CENTER path). On Apple TV this // legacy `flutter/keyevent` path). Fields that explicitly retain Plezy's
// must open the on-screen keyboard, not submit the form. Previously // Flutter overlay must open it rather than submit the form.
// `isPhysicalKeyboardEnter` matched select+keyboard and routed through // `isPhysicalKeyboardEnter` matched select+keyboard and routed through
// `_submitTextInput`, which silently triggered form submit on every // `_submitTextInput`, which silently triggered form submit on every
// dpad center press (e.g. immediate validation error on empty fields). // dpad center press (e.g. immediate validation error on empty fields).
@@ -579,6 +785,7 @@ void main() {
body: FocusableTextField( body: FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
onSubmitted: (value) => submitted = value, onSubmitted: (value) => submitted = value,
), ),
), ),
@@ -609,6 +816,7 @@ void main() {
body: FocusableTextField( body: FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
maxLength: 2, maxLength: 2,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[ab]'))], inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[ab]'))],
onChanged: changes.add, onChanged: changes.add,
@@ -660,7 +868,11 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode), body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
),
), ),
), ),
); );
@@ -689,7 +901,7 @@ void main() {
body: FocusableTextField( body: FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
), ),
), ),
), ),
@@ -772,7 +984,7 @@ void main() {
body: FocusableTextField( body: FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
maxLength: 8, maxLength: 8,
inputFormatters: [ inputFormatters: [
TextInputFormatter.withFunction((_, nextValue) { TextInputFormatter.withFunction((_, nextValue) {
+4
View File
@@ -14,6 +14,7 @@
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; }; 35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; }; 5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
4F1000024F1000024F100002 /* FlutterNativeTextInputTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */; };
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */; }; 65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */; };
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; }; 691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; };
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; }; 6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; };
@@ -96,6 +97,7 @@
3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = "<group>"; }; 3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = "<group>"; };
41369A2B262AECB35079F6CC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; }; 41369A2B262AECB35079F6CC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterNativeTextInputTests.mm; sourceTree = "<group>"; };
5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = "<group>"; }; 5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = "<group>"; };
66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinator.swift; sourceTree = "<group>"; }; 66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinator.swift; sourceTree = "<group>"; };
67C3193A50DFDECE2B92D075 /* ConnectivityPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPlugin.swift; sourceTree = "<group>"; }; 67C3193A50DFDECE2B92D075 /* ConnectivityPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPlugin.swift; sourceTree = "<group>"; };
@@ -337,6 +339,7 @@
F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = { F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */,
2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */, 2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */,
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */, FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */, 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */,
@@ -590,6 +593,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
4F1000024F1000024F100002 /* FlutterNativeTextInputTests.mm in Sources */,
2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */, 2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */,
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */, D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */, 65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */,
@@ -0,0 +1,114 @@
#import <Flutter/Flutter.h>
#import <UIKit/UIKit.h>
#import <XCTest/XCTest.h>
@interface FlutterTextInputView : UIView
@property(nonatomic, readonly) UITextField* tvosTextInputProxy;
@end
@interface FlutterTextInputPlugin : NSObject
@property(nonatomic, weak) UIViewController* viewController;
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result;
- (UIView<UITextInput>*)textInputView;
@end
@interface FlutterEngine (NativeTextInputTests)
- (FlutterTextInputPlugin*)textInputPlugin;
@end
@interface FlutterViewController (NativeTextInputTests)
@property(nonatomic, assign, getter=isTvosNativeTextInputActive) BOOL tvosNativeTextInputActive;
@end
@interface FlutterNativeTextInputTests : XCTestCase
@end
@implementation FlutterNativeTextInputTests {
FlutterEngine* _engine;
FlutterViewController* _flutterViewController;
UIWindow* _window;
FlutterTextInputPlugin* _textInputPlugin;
}
- (void)setUp {
[super setUp];
id<UIApplicationDelegate> appDelegate = UIApplication.sharedApplication.delegate;
_window = appDelegate.window;
_flutterViewController = (FlutterViewController*)_window.rootViewController;
XCTAssertTrue([_flutterViewController isKindOfClass:[FlutterViewController class]]);
_engine = _flutterViewController.engine;
_textInputPlugin = [_engine textInputPlugin];
XCTAssertNotNil(_textInputPlugin);
}
- (void)tearDown {
[self invokeTextInput:@"TextInput.hide" arguments:nil];
[self invokeTextInput:@"TextInput.clearClient" arguments:nil];
_textInputPlugin = nil;
_engine = nil;
_flutterViewController = nil;
_window = nil;
[super tearDown];
}
- (void)testPackagedEngineUsesRealUITextFieldProxyAndNativeSessionRouting {
[self invokeTextInput:@"TextInput.setClient"
arguments:@[
@123,
@{
@"inputType" : @{@"name" : @"TextInputType.url"},
@"keyboardAppearance" : @"Brightness.dark",
@"obscureText" : @NO,
@"inputAction" : @"TextInputAction.done",
@"smartDashesType" : @"0",
@"smartQuotesType" : @"0",
@"autocorrect" : @NO,
@"enableInteractiveSelection" : @YES,
},
]];
[self invokeTextInput:@"TextInput.setEditingState"
arguments:@{
@"text" : @"plezy.local",
@"selectionBase" : @2,
@"selectionExtent" : @7,
@"selectionAffinity" : @"TextAffinity.downstream",
@"composingBase" : @(-1),
@"composingExtent" : @(-1),
}];
FlutterTextInputView* inputView = (FlutterTextInputView*)[_textInputPlugin textInputView];
UITextField* proxy = inputView.tvosTextInputProxy;
XCTAssertNotNil(proxy);
XCTAssertEqualObjects(proxy.text, @"plezy.local");
XCTAssertEqual(proxy.keyboardType, UIKeyboardTypeURL);
XCTAssertEqual(proxy.returnKeyType, UIReturnKeyDone);
XCTAssertEqual(proxy.autocorrectionType, UITextAutocorrectionTypeNo);
XCTAssertEqual([proxy offsetFromPosition:proxy.beginningOfDocument toPosition:proxy.selectedTextRange.start], 2);
XCTAssertEqual([proxy offsetFromPosition:proxy.beginningOfDocument toPosition:proxy.selectedTextRange.end], 7);
[self invokeTextInput:@"TextInput.show" arguments:nil];
[self waitForMainRunLoop];
XCTAssertTrue(proxy.isFirstResponder);
XCTAssertTrue(_flutterViewController.tvosNativeTextInputActive);
[self invokeTextInput:@"TextInput.hide" arguments:nil];
[self waitForMainRunLoop];
XCTAssertFalse(proxy.isFirstResponder);
XCTAssertFalse(_flutterViewController.tvosNativeTextInputActive);
}
- (void)invokeTextInput:(NSString*)method arguments:(id)arguments {
FlutterMethodCall* call = [FlutterMethodCall methodCallWithMethodName:method arguments:arguments];
[_textInputPlugin handleMethodCall:call
result:^(id result){
}];
}
- (void)waitForMainRunLoop {
XCTestExpectation* expectation = [self expectationWithDescription:@"main run loop"];
dispatch_async(dispatch_get_main_queue(), ^{
[expectation fulfill];
});
[self waitForExpectations:@[ expectation ] timeout:2];
}
@end
+1 -1
View File
@@ -1 +1 @@
278584b13cf4def805f9ceaa97ee4e318d5e5f75c9222286295135e5691d23ef 21dcdb1b8e369e8385cd41b7844e7248923680a22f4ea303a976494724a0829d
+1 -1
View File
@@ -1 +1 @@
3.44.0+4 3.44.0+5