fix(tv): close virtual keyboard when its text field unmounts

The keyboard is a navigator route, so a screen swapping out its form
left it floating over the new content. showTvVirtualKeyboard now
returns a route handle and the field host closes it on dispose.
This commit is contained in:
edde746
2026-06-12 13:36:01 +02:00
parent 892dfee381
commit 05251edaf3
4 changed files with 117 additions and 37 deletions
+41 -24
View File
@@ -648,6 +648,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
late final VoidCallback _focusListener = _handleFocusChanged;
final Object _nativeFocusToken = Object();
bool _reportedNativeTextInputFocused = false;
TvVirtualKeyboardHandle? _tvKeyboardHandle;
bool _tvKeyboardOpen = false;
bool _tvKeyboardOpenScheduled = false;
bool _suppressTvKeyboardAutoOpen = false;
@@ -661,6 +662,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.input.focusNode != widget.input.focusNode) {
// An open keyboard dialog intentionally survives rebuilds and focusNode
// swaps; it is closed only when this host unmounts — see dispose.
_restoreInstalledHandler();
_suppressTvKeyboardAutoOpen = false;
_tvKeyboardOpenScheduled = false;
@@ -673,6 +676,13 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
@override
void dispose() {
_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).
// Navigator mutation is unsafe during tree finalization; defer a frame.
final keyboard = _tvKeyboardHandle;
if (keyboard != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => keyboard.close());
}
_ownedFocusNode?.dispose();
super.dispose();
}
@@ -763,31 +773,38 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
// so only static configuration may be snapshotted here — the callbacks
// must resolve against widget.input at invoke time.
final input = widget.input;
final keyboard = showTvVirtualKeyboard(
context: context,
controller: input.controller,
hintText: _keyboardHint(input.decoration),
keyboardType: input.keyboardType,
textInputAction: input.textInputAction,
inputFormatters: input.inputFormatters,
obscureText: input.obscureText,
maxLength: input.maxLength,
maxLines: input.maxLines,
onChanged: (text) {
if (!mounted) return;
widget.input.onChanged?.call(text);
},
onSubmitted: (text) {
if (!mounted) return;
final current = widget.input;
if (current.onSubmitted != null) {
current.onSubmitted!(text);
} else {
current._handleTvKeyboardAction();
}
},
);
if (keyboard == null) {
_tvKeyboardOpen = false;
return;
}
_tvKeyboardHandle = keyboard;
unawaited(
showTvVirtualKeyboard(
context: context,
controller: input.controller,
hintText: _keyboardHint(input.decoration),
keyboardType: input.keyboardType,
textInputAction: input.textInputAction,
inputFormatters: input.inputFormatters,
obscureText: input.obscureText,
maxLength: input.maxLength,
maxLines: input.maxLines,
onChanged: (text) {
if (!mounted) return;
widget.input.onChanged?.call(text);
},
onSubmitted: (text) {
if (!mounted) return;
final current = widget.input;
if (current.onSubmitted != null) {
current.onSubmitted!(text);
} else {
current._handleTvKeyboardAction();
}
},
).whenComplete(() {
keyboard.closed.whenComplete(() {
_tvKeyboardHandle = null;
if (!mounted) return;
_tvKeyboardOpen = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
+37 -3
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -41,7 +43,31 @@ void warmUpTvVirtualKeyboardText(BuildContext context) {
}
}
Future<void> showTvVirtualKeyboard({
/// Handle to a TV virtual keyboard pushed by [showTvVirtualKeyboard].
class TvVirtualKeyboardHandle {
TvVirtualKeyboardHandle._(this._navigator, this._route);
final NavigatorState _navigator;
final DialogRoute<void> _route;
/// Completes when the keyboard leaves the navigator — submit, cancel,
/// barrier/back dismiss, or [close].
Future<void> get closed => _route.popped;
/// Dismiss the keyboard if it is still up. No-ops once the route is gone
/// or the navigator is being torn down.
void close() {
if (!_navigator.mounted) return;
if (_route.isCurrent) {
_navigator.pop();
} else if (_route.isActive) {
// removeRoute also completes route.popped, which backs [closed].
_navigator.removeRoute(_route);
}
}
}
TvVirtualKeyboardHandle? showTvVirtualKeyboard({
required BuildContext context,
required TextEditingController controller,
String? hintText,
@@ -55,13 +81,19 @@ Future<void> showTvVirtualKeyboard({
ValueChanged<String>? onSubmitted,
VoidCallback? onAction,
}) {
if (!PlatformDetector.isTV()) return Future.value();
if (!PlatformDetector.isTV()) return null;
return showDialog<void>(
// A hand-built DialogRoute instead of showDialog so the caller gets a
// handle it can close when the owning field unmounts. Mirrors showDialog's
// root-navigator, captured-themes and closed-loop traversal defaults.
final navigator = Navigator.of(context, rootNavigator: true);
final route = DialogRoute<void>(
context: context,
barrierDismissible: true,
barrierColor: Colors.black.withValues(alpha: 0.1),
useSafeArea: false,
themes: InheritedTheme.capture(from: context, to: navigator.context),
traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop,
builder: (context) => _TvVirtualKeyboardDialog(
controller: controller,
hintText: hintText,
@@ -76,6 +108,8 @@ Future<void> showTvVirtualKeyboard({
onAction: onAction,
),
);
unawaited(navigator.push(route));
return TvVirtualKeyboardHandle._(navigator, route);
}
enum _TvKeyType { spacer, character, shift, symbols, space, newline, backspace, clear, cancel, done }
@@ -234,6 +234,38 @@ void main() {
expect(find.byType(Dialog), findsOneWidget);
});
testWidgets('TV virtual keyboard closes when its owning field unmounts', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
await _setTvSurfaceSize(tester);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'search_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
Future<void> pumpField({required bool present}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: present
? FocusableTextField(controller: controller, focusNode: fieldFocusNode)
: const SizedBox.shrink(),
),
),
);
}
await pumpField(present: true);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsOneWidget);
// Swap the field out while the keyboard is up — the keyboard must follow.
await pumpField(present: false);
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsNothing);
});
testWidgets('TV virtual keyboard does not immediately reopen after dismissal', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
await _setTvSurfaceSize(tester);
+7 -10
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
@@ -167,7 +166,7 @@ void main() {
),
);
unawaited(showTvVirtualKeyboard(context: context, controller: controller));
showTvVirtualKeyboard(context: context, controller: controller);
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsOneWidget);
@@ -208,14 +207,12 @@ Future<void> _pumpKeyboard(
),
);
unawaited(
showTvVirtualKeyboard(
context: context,
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
onSubmitted: onSubmitted,
),
showTvVirtualKeyboard(
context: context,
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
onSubmitted: onSubmitted,
);
await tester.pumpAndSettle();
}