From 8aa701bf0c34cedee84aedaeb8ca89403e950752 Mon Sep 17 00:00:00 2001 From: Lucas SAUDON Date: Fri, 27 Jun 2025 22:38:07 +0200 Subject: [PATCH] fix: Add focusNode and textEditingController in `Autocomplete` (#170936) *Allows you to keep the selected value when the autocomplete is in a listview and you scroll through it.* ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Victor Sanni --- .../lib/src/material/autocomplete.dart | 19 +++++ .../flutter/lib/src/widgets/autocomplete.dart | 4 +- .../test/material/autocomplete_test.dart | 83 +++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/material/autocomplete.dart b/packages/flutter/lib/src/material/autocomplete.dart index 32d8bae65a8..8fcdedab0c9 100644 --- a/packages/flutter/lib/src/material/autocomplete.dart +++ b/packages/flutter/lib/src/material/autocomplete.dart @@ -63,10 +63,12 @@ class Autocomplete extends StatelessWidget { required this.optionsBuilder, this.displayStringForOption = RawAutocomplete.defaultStringForOption, this.fieldViewBuilder = _defaultFieldViewBuilder, + this.focusNode, this.onSelected, this.optionsMaxHeight = 200.0, this.optionsViewBuilder, this.optionsViewOpenDirection = OptionsViewOpenDirection.down, + this.textEditingController, this.initialValue, }); @@ -79,6 +81,14 @@ class Autocomplete extends StatelessWidget { /// default. final AutocompleteFieldViewBuilder fieldViewBuilder; + /// The [FocusNode] that is used for the text field. + /// + /// {@macro flutter.widgets.RawAutocomplete.split} + /// + /// If this parameter is not null, then [textEditingController] must also be + /// non-null. + final FocusNode? focusNode; + /// {@macro flutter.widgets.RawAutocomplete.onSelected} final AutocompleteOnSelected? onSelected; @@ -102,6 +112,13 @@ class Autocomplete extends StatelessWidget { /// The default value is set to 200. final double optionsMaxHeight; + /// The [TextEditingController] that is used for the text field. + /// + /// {@macro flutter.widgets.RawAutocomplete.split} + /// + /// If this parameter is not null, then [focusNode] must also be non-null. + final TextEditingController? textEditingController; + /// {@macro flutter.widgets.RawAutocomplete.initialValue} final TextEditingValue? initialValue; @@ -123,6 +140,8 @@ class Autocomplete extends StatelessWidget { return RawAutocomplete( displayStringForOption: displayStringForOption, fieldViewBuilder: fieldViewBuilder, + focusNode: focusNode, + textEditingController: textEditingController, initialValue: initialValue, optionsBuilder: optionsBuilder, optionsViewOpenDirection: optionsViewOpenDirection, diff --git a/packages/flutter/lib/src/widgets/autocomplete.dart b/packages/flutter/lib/src/widgets/autocomplete.dart index 7bdf9b38d40..01da7c4d2ab 100644 --- a/packages/flutter/lib/src/widgets/autocomplete.dart +++ b/packages/flutter/lib/src/widgets/autocomplete.dart @@ -211,7 +211,7 @@ class RawAutocomplete extends StatefulWidget { /// {@endtemplate} /// /// If this parameter is not null, then [textEditingController] must also be - /// not null. + /// non-null. final FocusNode? focusNode; /// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder} @@ -265,7 +265,7 @@ class RawAutocomplete extends StatefulWidget { /// /// {@macro flutter.widgets.RawAutocomplete.split} /// - /// If this parameter is not null, then [focusNode] must also be not null. + /// If this parameter is not null, then [focusNode] must also be non-null. final TextEditingController? textEditingController; /// {@template flutter.widgets.RawAutocomplete.initialValue} diff --git a/packages/flutter/test/material/autocomplete_test.dart b/packages/flutter/test/material/autocomplete_test.dart index 71aa6d28d2f..e93d0f860f0 100644 --- a/packages/flutter/test/material/autocomplete_test.dart +++ b/packages/flutter/test/material/autocomplete_test.dart @@ -648,4 +648,87 @@ void main() { expect(optionFinder(kOptions.length - 1), findsNothing); checkOptionHighlight(tester, kOptions.first, highlightColor); }); + + testWidgets( + 'passes textEditingController, focusNode to textEditingController, focusNode RawAutocomplete', + (WidgetTester tester) async { + final TextEditingController textEditingController = TextEditingController(); + final FocusNode focusNode = FocusNode(); + addTearDown(textEditingController.dispose); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Autocomplete( + focusNode: focusNode, + textEditingController: textEditingController, + optionsBuilder: (TextEditingValue textEditingValue) => ['a'], + ), + ), + ), + ), + ); + + final RawAutocomplete rawAutocomplete = tester.widget( + find.byType(RawAutocomplete), + ); + expect(rawAutocomplete.textEditingController, textEditingController); + expect(rawAutocomplete.focusNode, focusNode); + }, + ); + + testWidgets('when field scrolled offscreen, reshown selected value when scrolled back', ( + WidgetTester tester, + ) async { + final ScrollController scrollController = ScrollController(); + final TextEditingController textEditingController = TextEditingController(); + final FocusNode focusNode = FocusNode(); + addTearDown(textEditingController.dispose); + addTearDown(focusNode.dispose); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + controller: scrollController, + children: [ + Autocomplete( + focusNode: focusNode, + textEditingController: textEditingController, + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + const SizedBox(height: 1000.0), + ], + ), + ), + ), + ); + + /// Select an option. + await tester.tap(find.byType(TextField)); + await tester.pump(); + const String textSelection = 'chameleon'; + await tester.tap(find.text(textSelection)); + + // Unfocus and scroll to deconstruct the widge + final TextField field = find.byType(TextField).evaluate().first.widget as TextField; + field.focusNode?.unfocus(); + scrollController.jumpTo(2000.0); + await tester.pumpAndSettle(); + + /// Scroll to go back to the widget. + scrollController.jumpTo(0.0); + await tester.pumpAndSettle(); + + /// Checks that the option selected is still present. + final TextField field2 = find.byType(TextField).evaluate().first.widget as TextField; + expect(field2.controller!.text, textSelection); + }); }