Fix DropdownMenu throwing TextEditingController disposed error on select (#139385)
## Description When a DropdownMenu exists within another menu and a MenuItem is selected, the `TextEditingController` of the DropdownMenu is used after it has been disposed. This PR manages the local `TextController` instance better, making sure that the one used when building is the current one, and handling the case where the `controller` is set on the widget after initial creation. Also, places where we were setting the text and selection separately were converted to use `TextEditingValue` and set the value atomically. ## Related Issues - Fixes https://github.com/flutter/flutter/issues/139266 ## Tests - Added tests (Created by @josh-burton in https://github.com/flutter/flutter/pull/139268 - Thanks Josh!)
This commit is contained in:
@@ -348,18 +348,20 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
||||
final GlobalKey _leadingKey = GlobalKey();
|
||||
late List<GlobalKey> buttonItemKeys;
|
||||
final MenuController _controller = MenuController();
|
||||
late final TextEditingController _textEditingController;
|
||||
late bool _enableFilter;
|
||||
late List<DropdownMenuEntry<T>> filteredEntries;
|
||||
List<Widget>? _initialMenu;
|
||||
int? currentHighlight;
|
||||
double? leadingPadding;
|
||||
bool _menuHasEnabledItem = false;
|
||||
TextEditingController? _localTextEditingController;
|
||||
TextEditingController get _textEditingController {
|
||||
return widget.controller ?? (_localTextEditingController ??= TextEditingController());
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textEditingController = widget.controller ?? TextEditingController();
|
||||
_enableFilter = widget.enableFilter;
|
||||
filteredEntries = widget.dropdownMenuEntries;
|
||||
buttonItemKeys = List<GlobalKey>.generate(filteredEntries.length, (int index) => GlobalKey());
|
||||
@@ -367,16 +369,33 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
||||
|
||||
final int index = filteredEntries.indexWhere((DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection);
|
||||
if (index != -1) {
|
||||
_textEditingController.text = filteredEntries[index].label;
|
||||
_textEditingController.selection =
|
||||
TextSelection.collapsed(offset: _textEditingController.text.length);
|
||||
_textEditingController.value = TextEditingValue(
|
||||
text: filteredEntries[index].label,
|
||||
selection: TextSelection.collapsed(offset: filteredEntries[index].label.length),
|
||||
);
|
||||
}
|
||||
refreshLeadingPadding();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_localTextEditingController != null) {
|
||||
debugPrint('Disposing of $_textEditingController');
|
||||
}
|
||||
_localTextEditingController?.dispose();
|
||||
_localTextEditingController = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DropdownMenu<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
if (widget.controller != null) {
|
||||
_localTextEditingController?.dispose();
|
||||
_localTextEditingController = null;
|
||||
}
|
||||
}
|
||||
if (oldWidget.enableSearch != widget.enableSearch) {
|
||||
if (!widget.enableSearch) {
|
||||
currentHighlight = null;
|
||||
@@ -394,9 +413,10 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
||||
if (oldWidget.initialSelection != widget.initialSelection) {
|
||||
final int index = filteredEntries.indexWhere((DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection);
|
||||
if (index != -1) {
|
||||
_textEditingController.text = filteredEntries[index].label;
|
||||
_textEditingController.selection =
|
||||
TextSelection.collapsed(offset: _textEditingController.text.length);
|
||||
_textEditingController.value = TextEditingValue(
|
||||
text: filteredEntries[index].label,
|
||||
selection: TextSelection.collapsed(offset: filteredEntries[index].label.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,7 +483,6 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
||||
|
||||
List<Widget> _buildButtons(
|
||||
List<DropdownMenuEntry<T>> filteredEntries,
|
||||
TextEditingController textEditingController,
|
||||
TextDirection textDirection,
|
||||
{ int? focusedIndex, bool enableScrollToHighlight = true}
|
||||
) {
|
||||
@@ -519,9 +538,10 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
||||
trailingIcon: entry.trailingIcon,
|
||||
onPressed: entry.enabled
|
||||
? () {
|
||||
textEditingController.text = entry.label;
|
||||
textEditingController.selection =
|
||||
TextSelection.collapsed(offset: textEditingController.text.length);
|
||||
_textEditingController.value = TextEditingValue(
|
||||
text: entry.label,
|
||||
selection: TextSelection.collapsed(offset: entry.label.length),
|
||||
);
|
||||
currentHighlight = widget.enableSearch ? i : null;
|
||||
widget.onSelected?.call(entry.value);
|
||||
}
|
||||
@@ -535,37 +555,43 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
||||
return result;
|
||||
}
|
||||
|
||||
void handleUpKeyInvoke(_) => setState(() {
|
||||
if (!_menuHasEnabledItem || !_controller.isOpen) {
|
||||
return;
|
||||
}
|
||||
_enableFilter = false;
|
||||
currentHighlight ??= 0;
|
||||
currentHighlight = (currentHighlight! - 1) % filteredEntries.length;
|
||||
while (!filteredEntries[currentHighlight!].enabled) {
|
||||
void handleUpKeyInvoke(_) {
|
||||
setState(() {
|
||||
if (!_menuHasEnabledItem || !_controller.isOpen) {
|
||||
return;
|
||||
}
|
||||
_enableFilter = false;
|
||||
currentHighlight ??= 0;
|
||||
currentHighlight = (currentHighlight! - 1) % filteredEntries.length;
|
||||
}
|
||||
final String currentLabel = filteredEntries[currentHighlight!].label;
|
||||
_textEditingController.text = currentLabel;
|
||||
_textEditingController.selection =
|
||||
TextSelection.collapsed(offset: _textEditingController.text.length);
|
||||
});
|
||||
while (!filteredEntries[currentHighlight!].enabled) {
|
||||
currentHighlight = (currentHighlight! - 1) % filteredEntries.length;
|
||||
}
|
||||
final String currentLabel = filteredEntries[currentHighlight!].label;
|
||||
_textEditingController.value = TextEditingValue(
|
||||
text: currentLabel,
|
||||
selection: TextSelection.collapsed(offset: currentLabel.length),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void handleDownKeyInvoke(_) => setState(() {
|
||||
if (!_menuHasEnabledItem || !_controller.isOpen) {
|
||||
return;
|
||||
}
|
||||
_enableFilter = false;
|
||||
currentHighlight ??= -1;
|
||||
currentHighlight = (currentHighlight! + 1) % filteredEntries.length;
|
||||
while (!filteredEntries[currentHighlight!].enabled) {
|
||||
void handleDownKeyInvoke(_) {
|
||||
setState(() {
|
||||
if (!_menuHasEnabledItem || !_controller.isOpen) {
|
||||
return;
|
||||
}
|
||||
_enableFilter = false;
|
||||
currentHighlight ??= -1;
|
||||
currentHighlight = (currentHighlight! + 1) % filteredEntries.length;
|
||||
}
|
||||
final String currentLabel = filteredEntries[currentHighlight!].label;
|
||||
_textEditingController.text = currentLabel;
|
||||
_textEditingController.selection =
|
||||
TextSelection.collapsed(offset: _textEditingController.text.length);
|
||||
});
|
||||
while (!filteredEntries[currentHighlight!].enabled) {
|
||||
currentHighlight = (currentHighlight! + 1) % filteredEntries.length;
|
||||
}
|
||||
final String currentLabel = filteredEntries[currentHighlight!].label;
|
||||
_textEditingController.value = TextEditingValue(
|
||||
text: currentLabel,
|
||||
selection: TextSelection.collapsed(offset: currentLabel.length),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void handlePressed(MenuController controller) {
|
||||
if (controller.isOpen) {
|
||||
@@ -580,18 +606,10 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.controller == null) {
|
||||
_textEditingController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TextDirection textDirection = Directionality.of(context);
|
||||
_initialMenu ??= _buildButtons(widget.dropdownMenuEntries, _textEditingController, textDirection, enableScrollToHighlight: false);
|
||||
_initialMenu ??= _buildButtons(widget.dropdownMenuEntries, textDirection, enableScrollToHighlight: false);
|
||||
final DropdownMenuThemeData theme = DropdownMenuTheme.of(context);
|
||||
final DropdownMenuThemeData defaults = _DropdownMenuDefaultsM3(context);
|
||||
|
||||
@@ -610,7 +628,7 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
||||
}
|
||||
}
|
||||
|
||||
final List<Widget> menu = _buildButtons(filteredEntries, _textEditingController, textDirection, focusedIndex: currentHighlight);
|
||||
final List<Widget> menu = _buildButtons(filteredEntries, textDirection, focusedIndex: currentHighlight);
|
||||
|
||||
final TextStyle? effectiveTextStyle = widget.textStyle ?? theme.textStyle ?? defaults.textStyle;
|
||||
|
||||
@@ -670,9 +688,10 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
||||
if (currentHighlight != null) {
|
||||
final DropdownMenuEntry<T> entry = filteredEntries[currentHighlight!];
|
||||
if (entry.enabled) {
|
||||
_textEditingController.text = entry.label;
|
||||
_textEditingController.selection =
|
||||
TextSelection.collapsed(offset: _textEditingController.text.length);
|
||||
_textEditingController.value = TextEditingValue(
|
||||
text: entry.label,
|
||||
selection: TextSelection.collapsed(offset: entry.label.length),
|
||||
);
|
||||
widget.onSelected?.call(entry.value);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1805,6 +1805,109 @@ void main() {
|
||||
await tester.pump();
|
||||
checkExpectedHighlight(searchResult: 'Read', otherItems: <String>['All', 'Unread']);
|
||||
});
|
||||
|
||||
testWidgetsWithLeakTracking('onSelected gets called when a selection is made in a nested menu',
|
||||
(WidgetTester tester) async {
|
||||
int selectionCount = 0;
|
||||
|
||||
final ThemeData themeData = ThemeData();
|
||||
final List<DropdownMenuEntry<TestMenu>> menuWithDisabledItems = <DropdownMenuEntry<TestMenu>>[
|
||||
const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu0, label: 'Item 0'),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: themeData,
|
||||
home: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
|
||||
return Scaffold(
|
||||
body: MenuAnchor(
|
||||
menuChildren: <Widget>[
|
||||
DropdownMenu<TestMenu>(
|
||||
dropdownMenuEntries: menuWithDisabledItems,
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
selectionCount++;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
builder: (BuildContext context, MenuController controller, Widget? widget) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.smartphone_rounded),
|
||||
onPressed: () {
|
||||
controller.open();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
));
|
||||
|
||||
// Open the first menu
|
||||
await tester.tap(find.byType(IconButton));
|
||||
await tester.pump();
|
||||
// Open the dropdown menu
|
||||
await tester.tap(find.byType(DropdownMenu<TestMenu>));
|
||||
await tester.pump();
|
||||
|
||||
final Finder item1 = find.widgetWithText(MenuItemButton, 'Item 0').last;
|
||||
await tester.tap(item1);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(selectionCount, 1);
|
||||
});
|
||||
|
||||
testWidgetsWithLeakTracking('When onSelected is called and menu is closed, no textEditingController exception is thrown',
|
||||
(WidgetTester tester) async {
|
||||
int selectionCount = 0;
|
||||
|
||||
final ThemeData themeData = ThemeData();
|
||||
final List<DropdownMenuEntry<TestMenu>> menuWithDisabledItems = <DropdownMenuEntry<TestMenu>>[
|
||||
const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu0, label: 'Item 0'),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: themeData,
|
||||
home: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
|
||||
return Scaffold(
|
||||
body: MenuAnchor(
|
||||
menuChildren: <Widget>[
|
||||
DropdownMenu<TestMenu>(
|
||||
dropdownMenuEntries: menuWithDisabledItems,
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
selectionCount++;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
builder: (BuildContext context, MenuController controller, Widget? widget) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.smartphone_rounded),
|
||||
onPressed: () {
|
||||
controller.open();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
));
|
||||
|
||||
// Open the first menu
|
||||
await tester.tap(find.byType(IconButton));
|
||||
await tester.pump();
|
||||
// Open the dropdown menu
|
||||
await tester.tap(find.byType(DropdownMenu<TestMenu>));
|
||||
await tester.pump();
|
||||
|
||||
final Finder item1 = find.widgetWithText(MenuItemButton, 'Item 0').last;
|
||||
await tester.tap(item1);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(selectionCount, 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
enum TestMenu {
|
||||
|
||||
Reference in New Issue
Block a user