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 '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;
enum TvKeyboardAutoOpenBehavior {
/// Open the TV virtual keyboard whenever the field receives focus.
enum TvTextInputAutoOpenBehavior {
/// Open the selected TV text input presentation whenever the field receives
/// focus.
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.
afterFirstFocus,
/// Never auto-open the TV virtual keyboard on focus. Explicit tap/select
/// still opens it.
/// Never auto-open text input on focus. Explicit tap/select still opens it.
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
/// `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
/// method is an effective no-op.
class TvKeyboardController {
/// Only meaningful on TV. On other platforms every method is an effective
/// no-op.
class TvTextInputController {
_FocusableTextInputHostState? _host;
void _attach(_FocusableTextInputHostState host) => _host = host;
@@ -42,14 +62,13 @@ class TvKeyboardController {
if (identical(_host, host)) _host = null;
}
/// Dismiss the OSK if it is open (and prevent it from auto-reopening while
/// the field keeps focus). No-op when nothing is open.
void closeKeyboard() => _host?._dismissTvKeyboard();
/// Dismiss the Flutter overlay if it is open and prevent it from reopening
/// while the field keeps focus.
void closeTextInput() => _host?._dismissTvKeyboard();
/// Focus the field but do NOT open the OSK for this focus entry. Used as a
/// fallback landing spot (e.g. a remote search that returned no results) so
/// the remote isn't stranded on an off-screen element.
void focusInputWithoutKeyboard() => _host?._focusWithoutKeyboard();
/// Focus the field without opening either native or Flutter text input for
/// this focus entry.
void focusInputWithoutOpening() => _host?._focusWithoutKeyboard();
}
String _describeTextInputKey(KeyEvent event) {
@@ -117,6 +136,8 @@ KeyEventResult _handleInputKey({
required bool usesTvKeyboard,
required bool enabled,
required VoidCallback openKeyboard,
required bool activateNativeTextInput,
required VoidCallback activateNativeTextInputCallback,
required KeyEvent event,
TextInputType? keyboardType,
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');
}
@@ -247,11 +278,17 @@ KeyEventResult _handleInputKey({
return finish(KeyEventResult.ignored, 'fall-through');
}
bool _shouldPassNativeTvKeyToPlatform({required bool usesTvKeyboard, required bool enabled, required KeyEvent event}) {
if (!enabled || usesTvKeyboard || !PlatformDetector.isTV()) {
bool _shouldPassNativeTvKeyToPlatform({
required bool usesTvKeyboard,
required bool nativeTextInputActive,
required bool enabled,
required KeyEvent event,
}) {
if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isTV()) {
if (TextInputDiagnostics.enabled) {
_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)})',
);
}
@@ -546,9 +583,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
final VoidCallback? onBack;
final bool autofocus;
final bool enabled;
final bool enableTvKeyboard;
final TvKeyboardAutoOpenBehavior tvKeyboardAutoOpenBehavior;
final TvKeyboardController? tvKeyboardController;
final TvTextInputPresentation tvTextInputPresentation;
final TvTextInputAutoOpenBehavior tvTextInputAutoOpenBehavior;
final TvTextInputController? tvTextInputController;
final bool obscureText;
final bool autocorrect;
final bool enableSuggestions;
@@ -580,9 +617,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
this.onBack,
this.autofocus = false,
this.enabled = true,
this.enableTvKeyboard = true,
this.tvKeyboardAutoOpenBehavior = TvKeyboardAutoOpenBehavior.onFocus,
this.tvKeyboardController,
this.tvTextInputPresentation = TvTextInputPresentation.automatic,
this.tvTextInputAutoOpenBehavior = TvTextInputAutoOpenBehavior.onFocus,
this.tvTextInputController,
this.obscureText = false,
this.autocorrect = true,
this.enableSuggestions = true,
@@ -599,7 +636,8 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
this.onNavigateDown,
});
bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard);
bool get _hasTvKeyboard =>
_usesTvKeyboard(presentation: tvTextInputPresentation, keyboardType: keyboardType, maxLines: maxLines);
bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard;
VoidCallback? get _effectiveOnEditingComplete {
@@ -627,23 +665,41 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
bool? enableInteractiveSelection,
VoidCallback? onTap,
})
_tvInputConfiguration(bool usesTvKeyboard, VoidCallback openKeyboard) {
_tvInputConfiguration({
required bool usesTvKeyboard,
required bool nativeTextInputReadOnly,
required VoidCallback openKeyboard,
required VoidCallback activateNativeTextInput,
}) {
return (
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
readOnly: usesTvKeyboard,
showCursor: usesTvKeyboard ? true : null,
readOnly: usesTvKeyboard || nativeTextInputReadOnly,
showCursor: usesTvKeyboard || nativeTextInputReadOnly ? true : null,
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(
controller: controller,
node: node,
usesTvKeyboard: _hasTvKeyboard,
enabled: enabled,
openKeyboard: openKeyboard,
activateNativeTextInput: activateNativeTextInput,
activateNativeTextInputCallback: activateNativeTextInputCallback,
event: event,
keyboardType: keyboardType,
textInputAction: textInputAction,
@@ -662,16 +718,23 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
);
}
Widget buildFocusableInput(
Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder,
) {
Widget buildFocusableInput(_FocusableTextInputBuilder 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 {
final _FocusableTextInputBase input;
final Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder;
final _FocusableTextInputBuilder builder;
const _FocusableTextInputHost({required this.input, required this.builder});
@@ -693,6 +756,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
bool _suppressTvKeyboardAutoOpen = false;
bool _hasSeenTvKeyboardFocus = false;
bool _suppressTvKeyboardForCurrentFocus = false;
bool _nativeTextInputActivated = false;
bool _hasSeenNativeTextInputFocus = false;
bool _suppressNativeTextInputForCurrentFocus = false;
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
@@ -700,15 +766,15 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
void initState() {
super.initState();
_focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput');
widget.input.tvKeyboardController?._attach(this);
widget.input.tvTextInputController?._attach(this);
}
@override
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.input.tvKeyboardController, widget.input.tvKeyboardController)) {
oldWidget.input.tvKeyboardController?._detach(this);
widget.input.tvKeyboardController?._attach(this);
if (!identical(oldWidget.input.tvTextInputController, widget.input.tvTextInputController)) {
oldWidget.input.tvTextInputController?._detach(this);
widget.input.tvTextInputController?._attach(this);
}
if (oldWidget.input.focusNode != widget.input.focusNode) {
// An open keyboard dialog intentionally survives rebuilds and focusNode
@@ -719,13 +785,24 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_tvKeyboardOpenScheduled = false;
_hasSeenTvKeyboardFocus = 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
void dispose() {
widget.input.tvKeyboardController?._detach(this);
widget.input.tvTextInputController?._detach(this);
_restoreInstalledHandler();
// 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).
@@ -739,10 +816,53 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
}
void _handleFocusChanged() {
_syncNativeTextInputActivation();
_syncNativeTextInputFocus();
_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() {
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
if (TextInputDiagnostics.enabled) {
@@ -761,7 +881,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
if (TextInputDiagnostics.enabled) {
_logTvTextInput(
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvKeyboardAutoOpenBehavior} '
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvTextInputAutoOpenBehavior} '
'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus '
'installed=${_installedFocusNode?.debugLabel} '
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
@@ -797,17 +917,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
}
bool _shouldAutoOpenTvKeyboardForCurrentFocus() {
switch (widget.input.tvKeyboardAutoOpenBehavior) {
case TvKeyboardAutoOpenBehavior.onFocus:
switch (widget.input.tvTextInputAutoOpenBehavior) {
case TvTextInputAutoOpenBehavior.onFocus:
return true;
case TvKeyboardAutoOpenBehavior.afterFirstFocus:
case TvTextInputAutoOpenBehavior.afterFirstFocus:
if (!_hasSeenTvKeyboardFocus) {
_hasSeenTvKeyboardFocus = true;
_suppressTvKeyboardForCurrentFocus = true;
return false;
}
return !_suppressTvKeyboardForCurrentFocus;
case TvKeyboardAutoOpenBehavior.never:
case TvTextInputAutoOpenBehavior.never:
return false;
}
}
@@ -886,19 +1006,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_tvKeyboardHandle?.close();
}
/// Focus the field without opening the OSK for this focus entry. Suppress is
/// set BEFORE requestFocus so the resulting focus-change sync already sees it;
/// the flag persists for this focus and resets on the next unfocus. If a
/// same-turn focus request supersedes this one, clear the unused suppression
/// after Flutter resolves its pending focus change.
/// Focus the field without opening either native or Flutter text input for
/// this focus entry. Suppression is set before requestFocus so the resulting
/// focus-change callback cannot open either presentation.
void _focusWithoutKeyboard() {
_suppressTvKeyboardAutoOpen = true;
_suppressNativeTextInputForCurrentFocus = true;
_tvKeyboardOpenScheduled = false;
if (widget.input._usesNativeTvKeyboard) {
_setNativeTextInputActivated(false);
}
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
focusNode.requestFocus();
scheduleMicrotask(() {
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
_suppressTvKeyboardAutoOpen = false;
_suppressNativeTextInputForCurrentFocus = false;
});
}
@@ -921,7 +1044,41 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
);
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) {
@@ -946,6 +1103,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
void _restoreInstalledHandler() {
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
_setNativeTextInputFocused(false);
_nativeTextInputActivated = false;
_suppressNativeTextInputForCurrentFocus = false;
final node = _installedFocusNode;
if (node != null) {
node.removeListener(_focusListener);
@@ -961,8 +1120,19 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
Widget build(BuildContext context) {
final focusNode = _effectiveFocusNode;
_installKeyHandler(focusNode);
_handleFocusChanged();
return widget.builder(widget.input._hasTvKeyboard, focusNode, _openTvKeyboard);
_syncNativeTextInputFocus();
_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.autofocus,
super.enabled,
super.enableTvKeyboard,
super.tvKeyboardAutoOpenBehavior,
super.tvKeyboardController,
super.tvTextInputPresentation,
super.tvTextInputAutoOpenBehavior,
super.tvTextInputController,
super.obscureText,
super.autocorrect,
super.enableSuggestions,
@@ -1010,11 +1180,22 @@ class FocusableTextField extends _FocusableTextInputBase {
@override
Widget build(BuildContext context) {
return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
return buildFocusableInput(({
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(
controller: controller,
focusNode: effectiveFocusNode,
focusNode: focusNode,
enabled: enabled,
decoration: decoration,
keyboardType: tvInput.keyboardType,
@@ -1066,9 +1247,9 @@ class FocusableTextFormField extends _FocusableTextInputBase {
this.onSaved,
super.autofocus,
super.enabled,
super.enableTvKeyboard,
super.tvKeyboardAutoOpenBehavior,
super.tvKeyboardController,
super.tvTextInputPresentation,
super.tvTextInputAutoOpenBehavior,
super.tvTextInputController,
super.obscureText,
super.autocorrect,
super.enableSuggestions,
@@ -1087,11 +1268,22 @@ class FocusableTextFormField extends _FocusableTextInputBase {
@override
Widget build(BuildContext context) {
return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
return buildFocusableInput(({
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(
controller: controller,
focusNode: effectiveFocusNode,
focusNode: focusNode,
enabled: enabled,
decoration: decoration,
keyboardType: tvInput.keyboardType,
+1
View File
@@ -72,6 +72,7 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
FocusableTextField(
controller: searchController,
focusNode: searchFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
textInputAction: TextInputAction.search,
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.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>
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
String? _focusResultsForQuery;
final _tvKeyboardController = TvKeyboardController();
final _tvTextInputController = TvTextInputController();
final _clearFocusNode = FocusNode(debugLabel: 'Search.clear');
@override
@@ -130,13 +130,13 @@ class _SearchScreenState extends State<SearchScreen>
// 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
// Search chip sends tabSearch before the query arrives).
_tvKeyboardController.closeKeyboard();
_tvTextInputController.closeTextInput();
if (trimmed.isEmpty) return;
// 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
// 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
// results, or cancels the debounce and runs now; the screen override arms
@@ -225,7 +225,8 @@ class _SearchScreenState extends State<SearchScreen>
FocusableTextField(
controller: searchController,
focusNode: searchFocusNode,
tvKeyboardController: _tvKeyboardController,
tvTextInputController: _tvTextInputController,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
textInputAction: TextInputAction.search,
onNavigateLeft: _navigateToSidebar,
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
@@ -487,8 +487,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
FocusableTextFormField(
controller: _urlController,
focusNode: _urlFocus,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
autofocus: true,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
keyboardType: TextInputType.url,
minLines: 1,
maxLines: 4,
@@ -99,6 +99,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
FocusableTextFormField(
controller: _urlsController,
focusNode: _urlsFocus,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
autofocus: true,
keyboardType: TextInputType.url,
minLines: 1,
@@ -312,6 +312,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
controller: _textController,
focusNode: _textFieldFocusNode,
keyboardType: TextInputType.multiline,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
maxLines: null,
minLines: 12,
decoration: InputDecoration(
@@ -184,7 +184,7 @@ class _SeerrConnectScreenState extends State<SeerrConnectScreen> with AsyncFormS
controller: _urlController,
focusNode: _urlFocus,
autofocus: true,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
keyboardType: TextInputType.url,
autocorrect: false,
enableSuggestions: false,
+3
View File
@@ -257,6 +257,9 @@ class _TextInputDialogState extends State<_TextInputDialog> with ControllerDispo
controller: _controller,
focusNode: _fieldFocusNode,
autofocus: true,
tvTextInputPresentation: widget.multiline
? TvTextInputPresentation.flutterOverlay
: TvTextInputPresentation.automatic,
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText, errorText: _errorText),
keyboardType: widget.keyboardType ?? (widget.multiline ? TextInputType.multiline : null),
inputFormatters: widget.inputFormatters,
+2 -1
View File
@@ -1905,7 +1905,8 @@ class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
FocusableTextField(
controller: _filterController,
focusNode: _filterFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
onNavigateDown: _firstItemFocusNode.requestFocus,
decoration: pillInputDecoration(
context,
@@ -284,6 +284,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
child: FocusableTextField(
controller: _titleController,
focusNode: _titleFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
decoration: pillInputDecoration(
context,
hintText: widget.mediaTitle ?? t.metadataEdit.title,
@@ -451,6 +452,7 @@ class _LanguagePickerViewState extends State<_LanguagePickerView> with Controlle
child: FocusableTextField(
controller: _filterController,
focusNode: _filterFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
autofocus: true,
decoration: pillInputDecoration(
context,
+5 -1
View File
@@ -74,7 +74,11 @@ void main() {
body: StatefulBuilder(
builder: (context, 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(
body: Column(
children: [
FocusableTextField(controller: c1, focusNode: first, enableTvKeyboard: false),
FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false),
FocusableTextField(
controller: c1,
focusNode: first,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
FocusableTextField(
controller: c2,
focusNode: second,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
],
),
),
@@ -56,8 +64,17 @@ void main() {
home: Scaffold(
body: Column(
children: [
FocusableTextField(controller: c1, focusNode: first, maxLines: 4, enableTvKeyboard: false),
FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false),
FocusableTextField(
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(
body: TickerMode(
enabled: visible,
child: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
child: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
),
),
),
),
@@ -181,7 +185,11 @@ void main() {
MaterialApp(
home: Scaffold(
body: present
? FocusableTextField(controller: controller, focusNode: fieldFocusNode)
? FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
)
: const SizedBox.shrink(),
),
),
@@ -211,7 +219,11 @@ void main() {
await tester.pumpWidget(
MaterialApp(
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);
});
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 {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
@@ -246,7 +448,7 @@ void main() {
FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
enableTvKeyboard: false,
tvTextInputPresentation: TvTextInputPresentation.platform,
textInputAction: TextInputAction.done,
onNavigateDown: nextFocusNode.requestFocus,
),
@@ -311,7 +513,7 @@ void main() {
FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
),
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
],
@@ -353,7 +555,7 @@ void main() {
body: FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
),
),
),
@@ -392,7 +594,7 @@ void main() {
FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
enableTvKeyboard: false,
tvTextInputPresentation: TvTextInputPresentation.platform,
onNavigateDown: nextFocusNode.requestFocus,
onSelect: () => selects++,
onBack: () => backs++,
@@ -467,7 +669,11 @@ void main() {
home: Scaffold(
body: Column(
children: [
FocusableTextFormField(controller: controller, focusNode: fieldFocusNode, enableTvKeyboard: false),
FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
],
),
@@ -557,11 +763,11 @@ void main() {
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
// as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the
// legacy `flutter/keyevent` Android DPAD_CENTER path). On Apple TV this
// must open the on-screen keyboard, not submit the form. Previously
// legacy `flutter/keyevent` path). Fields that explicitly retain Plezy's
// Flutter overlay must open it rather than submit the form.
// `isPhysicalKeyboardEnter` matched select+keyboard and routed through
// `_submitTextInput`, which silently triggered form submit on every
// dpad center press (e.g. immediate validation error on empty fields).
@@ -579,6 +785,7 @@ void main() {
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
onSubmitted: (value) => submitted = value,
),
),
@@ -609,6 +816,7 @@ void main() {
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
maxLength: 2,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[ab]'))],
onChanged: changes.add,
@@ -660,7 +868,11 @@ void main() {
await tester.pumpWidget(
MaterialApp(
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(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
),
),
),
@@ -772,7 +984,7 @@ void main() {
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
maxLength: 8,
inputFormatters: [
TextInputFormatter.withFunction((_, nextValue) {
+4
View File
@@ -14,6 +14,7 @@
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
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 */; };
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.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>"; };
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>"; };
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>"; };
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>"; };
@@ -337,6 +339,7 @@
F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = {
isa = PBXGroup;
children = (
4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */,
2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */,
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */,
@@ -590,6 +593,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
4F1000024F1000024F100002 /* FlutterNativeTextInputTests.mm in Sources */,
2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */,
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.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