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:
@@ -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((_) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user