// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/scheduler.dart'; import 'basic.dart'; import 'container.dart'; import 'editable_text.dart'; import 'focus_manager.dart'; import 'framework.dart'; import 'overlay.dart'; /// The type of the [RawAutocomplete] callback which computes the list of /// optional completions for the widget's field based on the text the user has /// entered so far. /// /// See also: /// /// * [RawAutocomplete.optionsBuilder], which is of this type. typedef AutocompleteOptionsBuilder = Iterable Function(TextEditingValue textEditingValue); /// The type of the callback used by the [RawAutocomplete] widget to indicate /// that the user has selected an option. /// /// See also: /// /// * [RawAutocomplete.onSelected], which is of this type. typedef AutocompleteOnSelected = void Function(T option); /// The type of the [RawAutocomplete] callback which returns a [Widget] that /// displays the specified [options] and calls [onSelected] if the user /// selects an option. /// /// See also: /// /// * [RawAutocomplete.optionsViewBuilder], which is of this type. typedef AutocompleteOptionsViewBuilder = Widget Function( BuildContext context, AutocompleteOnSelected onSelected, Iterable options, ); /// The type of the Autocomplete callback which returns the widget that /// contains the input [TextField] or [TextFormField]. /// /// See also: /// /// * [RawAutocomplete.fieldViewBuilder], which is of this type. typedef AutocompleteFieldViewBuilder = Widget Function( BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted, ); /// The type of the [RawAutocomplete] callback that converts an option value to /// a string which can be displayed in the widget's options menu. /// /// See also: /// /// * [RawAutocomplete.displayStringForOption], which is of this type. typedef AutocompleteOptionToString = String Function(T option); // TODO(justinmc): Mention AutocompleteCupertino when it is implemented. /// {@template flutter.widgets.RawAutocomplete.RawAutocomplete} /// A widget for helping the user make a selection by entering some text and /// choosing from among a list of options. /// /// The user's text input is received in a field built with the /// [fieldViewBuilder] parameter. The options to be displayed are determined /// using [optionsBuilder] and rendered with [optionsViewBuilder]. /// {@endtemplate} /// /// This is a core framework widget with very basic UI. /// /// {@tool dartpad --template=freeform} /// This example shows how to create a very basic autocomplete widget using the /// [fieldViewBuilder] and [optionsViewBuilder] parameters. /// /// ```dart imports /// import 'package:flutter/widgets.dart'; /// import 'package:flutter/material.dart'; /// ``` /// /// ```dart /// class AutocompleteBasicExample extends StatelessWidget { /// AutocompleteBasicExample({Key? key}) : super(key: key); /// /// static final List _options = [ /// 'aardvark', /// 'bobcat', /// 'chameleon', /// ]; /// /// @override /// Widget build(BuildContext context) { /// return RawAutocomplete( /// optionsBuilder: (TextEditingValue textEditingValue) { /// return _options.where((String option) { /// return option.contains(textEditingValue.text.toLowerCase()); /// }); /// }, /// fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) { /// return TextFormField( /// controller: textEditingController, /// focusNode: focusNode, /// onFieldSubmitted: (String value) { /// onFieldSubmitted(); /// }, /// ); /// }, /// optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { /// return Align( /// alignment: Alignment.topLeft, /// child: Material( /// elevation: 4.0, /// child: Container( /// height: 200.0, /// child: ListView.builder( /// padding: EdgeInsets.all(8.0), /// itemCount: options.length, /// itemBuilder: (BuildContext context, int index) { /// final String option = options.elementAt(index); /// return GestureDetector( /// onTap: () { /// onSelected(option); /// }, /// child: ListTile( /// title: Text(option), /// ), /// ); /// }, /// ), /// ), /// ), /// ); /// }, /// ); /// } /// } /// ``` /// {@end-tool} /// /// The type parameter T represents the type of the options. Most commonly this /// is a String, as in the example above. However, it's also possible to use /// another type with a `toString` method, or a custom [displayStringForOption]. /// Options will be compared using `==`, so it may be beneficial to override /// [Object.==] and [Object.hashCode] for custom types. /// /// {@tool dartpad --template=freeform} /// This example is similar to the previous example, but it uses a custom T data /// type instead of directly using String. /// /// ```dart imports /// import 'package:flutter/widgets.dart'; /// import 'package:flutter/material.dart'; /// ``` /// /// ```dart /// // An example of a type that someone might want to autocomplete a list of. /// class User { /// const User({ /// required this.email, /// required this.name, /// }); /// /// final String email; /// final String name; /// /// @override /// String toString() { /// return '$name, $email'; /// } /// /// @override /// bool operator ==(Object other) { /// if (other.runtimeType != runtimeType) /// return false; /// return other is User /// && other.name == name /// && other.email == email; /// } /// /// @override /// int get hashCode => hashValues(email, name); /// } /// /// class AutocompleteCustomTypeExample extends StatelessWidget { /// AutocompleteCustomTypeExample({Key? key}) : super(key: key); /// /// static final List _userOptions = [ /// User(name: 'Alice', email: 'alice@example.com'), /// User(name: 'Bob', email: 'bob@example.com'), /// User(name: 'Charlie', email: 'charlie123@gmail.com'), /// ]; /// /// static String _displayStringForOption(User option) => option.name; /// /// @override /// Widget build(BuildContext context) { /// return RawAutocomplete( /// optionsBuilder: (TextEditingValue textEditingValue) { /// return _userOptions.where((User option) { /// // Search based on User.toString, which includes both name and /// // email, even though the display string is just the name. /// return option.toString().contains(textEditingValue.text.toLowerCase()); /// }); /// }, /// displayStringForOption: _displayStringForOption, /// fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) { /// return TextFormField( /// controller: textEditingController, /// focusNode: focusNode, /// onFieldSubmitted: (String value) { /// onFieldSubmitted(); /// }, /// ); /// }, /// optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { /// return Align( /// alignment: Alignment.topLeft, /// child: Material( /// elevation: 4.0, /// child: Container( /// height: 200.0, /// child: ListView.builder( /// padding: EdgeInsets.all(8.0), /// itemCount: options.length, /// itemBuilder: (BuildContext context, int index) { /// final User option = options.elementAt(index); /// return GestureDetector( /// onTap: () { /// onSelected(option); /// }, /// child: ListTile( /// title: Text(_displayStringForOption(option)), /// ), /// ); /// }, /// ), /// ), /// ), /// ); /// }, /// ); /// } /// } /// ``` /// {@end-tool} /// /// {@tool dartpad --template=freeform} /// This example shows the use of RawAutocomplete in a form. /// /// ```dart imports /// import 'package:flutter/widgets.dart'; /// import 'package:flutter/material.dart'; /// ``` /// /// ```dart /// class AutocompleteFormExamplePage extends StatefulWidget { /// AutocompleteFormExamplePage({Key? key}) : super(key: key); /// /// @override /// AutocompleteFormExample createState() => AutocompleteFormExample(); /// } /// /// class AutocompleteFormExample extends State { /// final _formKey = GlobalKey(); /// final TextEditingController _textEditingController = TextEditingController(); /// String? _dropdownValue; /// String? _autocompleteSelection; /// /// final List _options = [ /// 'aardvark', /// 'bobcat', /// 'chameleon', /// ]; /// /// @override /// Widget build(BuildContext context) { /// return Scaffold( /// appBar: AppBar( /// title: Text('Autocomplete Form Example'), /// ), /// body: Center( /// child: Form( /// key: _formKey, /// child: Column( /// children: [ /// DropdownButtonFormField( /// value: _dropdownValue, /// icon: Icon(Icons.arrow_downward), /// hint: const Text('This is a regular DropdownButtonFormField'), /// iconSize: 24, /// elevation: 16, /// style: TextStyle(color: Colors.deepPurple), /// onChanged: (String? newValue) { /// setState(() { /// _dropdownValue = newValue; /// }); /// }, /// items: ['One', 'Two', 'Free', 'Four'] /// .map>((String value) { /// return DropdownMenuItem( /// value: value, /// child: Text(value), /// ); /// }).toList(), /// validator: (String? value) { /// if (value == null) { /// return 'Must make a selection.'; /// } /// return null; /// }, /// ), /// TextFormField( /// controller: _textEditingController, /// decoration: InputDecoration( /// hintText: 'This is a regular TextFormField', /// ), /// validator: (String? value) { /// if (value == null || value.isEmpty) { /// return 'Can\'t be empty.'; /// } /// return null; /// }, /// ), /// RawAutocomplete( /// optionsBuilder: (TextEditingValue textEditingValue) { /// return _options.where((String option) { /// return option.contains(textEditingValue.text.toLowerCase()); /// }); /// }, /// onSelected: (String selection) { /// setState(() { /// _autocompleteSelection = selection; /// }); /// }, /// fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) { /// return TextFormField( /// controller: textEditingController, /// decoration: InputDecoration( /// hintText: 'This is an RawAutocomplete!', /// ), /// focusNode: focusNode, /// onFieldSubmitted: (String value) { /// onFieldSubmitted(); /// }, /// validator: (String? value) { /// if (!_options.contains(value)) { /// return 'Nothing selected.'; /// } /// return null; /// }, /// ); /// }, /// optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { /// return Align( /// alignment: Alignment.topLeft, /// child: Material( /// elevation: 4.0, /// child: Container( /// height: 200.0, /// child: ListView.builder( /// padding: EdgeInsets.all(8.0), /// itemCount: options.length, /// itemBuilder: (BuildContext context, int index) { /// final String option = options.elementAt(index); /// return GestureDetector( /// onTap: () { /// onSelected(option); /// }, /// child: ListTile( /// title: Text(option), /// ), /// ); /// }, /// ), /// ), /// ), /// ); /// }, /// ), /// ElevatedButton( /// onPressed: () { /// FocusScope.of(context).requestFocus(new FocusNode()); /// if (!_formKey.currentState!.validate()) { /// return; /// } /// showDialog( /// context: context, /// builder: (BuildContext context) { /// return AlertDialog( /// title: Text('Successfully submitted'), /// content: SingleChildScrollView( /// child: ListBody( /// children: [ /// Text('DropdownButtonFormField: "$_dropdownValue"'), /// Text('TextFormField: "${_textEditingController.text}"'), /// Text('RawAutocomplete: "$_autocompleteSelection"'), /// ], /// ), /// ), /// actions: [ /// TextButton( /// child: Text('Ok'), /// onPressed: () { /// Navigator.of(context).pop(); /// }, /// ), /// ], /// ); /// }, /// ); /// }, /// child: Text('Submit'), /// ), /// ], /// ), /// ), /// ), /// ); /// } /// } /// ``` /// {@end-tool} /// /// See also: /// /// * [Autocomplete], which is a Material-styled implementation that is based /// on RawAutocomplete. class RawAutocomplete extends StatefulWidget { /// Create an instance of RawAutocomplete. /// /// [displayStringForOption], [optionsBuilder] and [optionsViewBuilder] must /// not be null. const RawAutocomplete({ Key? key, required this.optionsViewBuilder, required this.optionsBuilder, this.displayStringForOption = defaultStringForOption, this.fieldViewBuilder, this.focusNode, this.onSelected, this.textEditingController, }) : assert(displayStringForOption != null), assert( fieldViewBuilder != null || (key != null && focusNode != null && textEditingController != null), 'Pass in a fieldViewBuilder, or otherwise create a separate field and pass in the FocusNode, TextEditingController, and a key. Use the key with RawAutocomplete.onFieldSubmitted.', ), assert(optionsBuilder != null), assert(optionsViewBuilder != null), assert((focusNode == null) == (textEditingController == null)), super(key: key); /// {@template flutter.widgets.RawAutocomplete.fieldViewBuilder} /// Builds the field whose input is used to get the options. /// /// Pass the provided [TextEditingController] to the field built here so that /// RawAutocomplete can listen for changes. /// {@endtemplate} final AutocompleteFieldViewBuilder? fieldViewBuilder; /// The [FocusNode] that is used for the text field. /// /// {@template flutter.widgets.RawAutocomplete.split} /// The main purpose of this parameter is to allow the use of a separate text /// field located in another part of the widget tree instead of the text /// field built by [fieldViewBuilder]. For example, it may be desirable to /// place the text field in the AppBar and the options below in the main body. /// /// When following this pattern, [fieldViewBuilder] can return /// `SizedBox.shrink()` so that nothing is drawn where the text field would /// normally be. A separate text field can be created elsewhere, and a /// FocusNode and TextEditingController can be passed both to that text field /// and to RawAutocomplete. /// /// {@tool dartpad --template=freeform} /// This examples shows how to create an autocomplete widget with the text /// field in the AppBar and the results in the main body of the app. /// /// ```dart imports /// import 'package:flutter/widgets.dart'; /// import 'package:flutter/material.dart'; /// ``` /// /// ```dart /// final List _options = [ /// 'aardvark', /// 'bobcat', /// 'chameleon', /// ]; /// /// class RawAutocompleteSplitPage extends StatefulWidget { /// RawAutocompleteSplitPage({Key? key}) : super(key: key); /// /// RawAutocompleteSplitPageState createState() => RawAutocompleteSplitPageState(); /// } /// /// class RawAutocompleteSplitPageState extends State { /// final TextEditingController _textEditingController = TextEditingController(); /// final FocusNode _focusNode = FocusNode(); /// final GlobalKey _autocompleteKey = GlobalKey(); /// /// @override /// Widget build(BuildContext context) { /// return MaterialApp( /// theme: ThemeData( /// primarySwatch: Colors.blue, /// ), /// title: 'Split RawAutocomplete App', /// home: Scaffold( /// appBar: AppBar( /// // This is where the real field is being built. /// title: TextFormField( /// controller: _textEditingController, /// focusNode: _focusNode, /// decoration: InputDecoration( /// hintText: 'Split RawAutocomplete App', /// ), /// onFieldSubmitted: (String value) { /// RawAutocomplete.onFieldSubmitted(_autocompleteKey); /// }, /// ), /// ), /// body: Align( /// alignment: Alignment.topLeft, /// child: RawAutocomplete( /// key: _autocompleteKey, /// focusNode: _focusNode, /// textEditingController: _textEditingController, /// optionsBuilder: (TextEditingValue textEditingValue) { /// return _options.where((String option) { /// return option.contains(textEditingValue.text.toLowerCase()); /// }).toList(); /// }, /// optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { /// return Material( /// elevation: 4.0, /// child: ListView( /// children: options.map((String option) => GestureDetector( /// onTap: () { /// onSelected(option); /// }, /// child: ListTile( /// title: Text(option), /// ), /// )).toList(), /// ), /// ); /// }, /// ), /// ), /// ), /// ); /// } /// } /// ``` /// {@end-tool} /// {@endtemplate} /// /// If this parameter is not null, then [textEditingController] must also be /// not null. final FocusNode? focusNode; /// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder} /// Builds the selectable options widgets from a list of options objects. /// /// The options are displayed floating below the field using a /// [CompositedTransformFollower] inside of an [Overlay], not at the same /// place in the widget tree as [RawAutocomplete]. /// {@endtemplate} final AutocompleteOptionsViewBuilder optionsViewBuilder; /// {@template flutter.widgets.RawAutocomplete.displayStringForOption} /// Returns the string to display in the field when the option is selected. /// /// This is useful when using a custom T type and the string to display is /// different than the string to search by. /// /// If not provided, will use `option.toString()`. /// {@endtemplate} final AutocompleteOptionToString displayStringForOption; /// {@template flutter.widgets.RawAutocomplete.onSelected} /// Called when an option is selected by the user. /// /// Any [TextEditingController] listeners will not be called when the user /// selects an option, even though the field will update with the selected /// value, so use this to be informed of selection. /// {@endtemplate} final AutocompleteOnSelected? onSelected; /// {@template flutter.widgets.RawAutocomplete.optionsBuilder} /// A function that returns the current selectable options objects given the /// current TextEditingValue. /// {@endtemplate} final AutocompleteOptionsBuilder optionsBuilder; /// 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 not null. final TextEditingController? textEditingController; /// Calls [AutocompleteFieldViewBuilder]'s onFieldSubmitted callback for the /// RawAutocomplete widget indicated by the given [GlobalKey]. /// /// This is not typically used unless a custom field is implemented instead of /// using [fieldViewBuilder]. In the typical case, the onFieldSubmitted /// callback is passed via the [AutocompleteFieldViewBuilder] signature. When /// not using fieldViewBuilder, the same callback can be called by using this /// static method. /// /// See also: /// /// * [focusNode] and [textEditingController], which contain a code example /// showing how to create a separate field outside of fieldViewBuilder. static void onFieldSubmitted(GlobalKey key) { final _RawAutocompleteState rawAutocomplete = key.currentState! as _RawAutocompleteState; rawAutocomplete._onFieldSubmitted(); } /// The default way to convert an option to a string in /// [displayStringForOption]. /// /// Simply uses the `toString` method on the option. static String defaultStringForOption(dynamic option) { return option.toString(); } @override _RawAutocompleteState createState() => _RawAutocompleteState(); } class _RawAutocompleteState extends State> { final GlobalKey _fieldKey = GlobalKey(); final LayerLink _optionsLayerLink = LayerLink(); late TextEditingController _textEditingController; late FocusNode _focusNode; Iterable _options = Iterable.empty(); T? _selection; // The OverlayEntry containing the options. OverlayEntry? _floatingOptions; // True iff the state indicates that the options should be visible. bool get _shouldShowOptions { return _focusNode.hasFocus && _selection == null && _options.isNotEmpty; } // Called when _textEditingController changes. void _onChangedField() { final Iterable options = widget.optionsBuilder( _textEditingController.value, ); _options = options; if (_selection != null && _textEditingController.text != widget.displayStringForOption(_selection!)) { _selection = null; } _updateOverlay(); } // Called when the field's FocusNode changes. void _onChangedFocus() { _updateOverlay(); } // Called from fieldViewBuilder when the user submits the field. void _onFieldSubmitted() { if (_options.isEmpty) { return; } _select(_options.first); } // Select the given option and update the widget. void _select(T nextSelection) { if (nextSelection == _selection) { return; } _selection = nextSelection; final String selectionString = widget.displayStringForOption(nextSelection); _textEditingController.value = TextEditingValue( selection: TextSelection.collapsed(offset: selectionString.length), text: selectionString, ); widget.onSelected?.call(_selection!); } // Hide or show the options overlay, if needed. void _updateOverlay() { if (_shouldShowOptions) { _floatingOptions?.remove(); _floatingOptions = OverlayEntry( builder: (BuildContext context) { return CompositedTransformFollower( link: _optionsLayerLink, showWhenUnlinked: false, targetAnchor: Alignment.bottomLeft, child: widget.optionsViewBuilder(context, _select, _options), ); }, ); Overlay.of(context, rootOverlay: true)!.insert(_floatingOptions!); } else if (_floatingOptions != null) { _floatingOptions!.remove(); _floatingOptions = null; } } // Handle a potential change in textEditingController by properly disposing of // the old one and setting up the new one, if needed. void _updateTextEditingController(TextEditingController? old, TextEditingController? current) { if ((old == null && current == null) || old == current) { return; } if (old == null) { _textEditingController.removeListener(_onChangedField); _textEditingController.dispose(); _textEditingController = current!; } else if (current == null) { _textEditingController.removeListener(_onChangedField); _textEditingController = TextEditingController(); } else { _textEditingController.removeListener(_onChangedField); _textEditingController = current; } _textEditingController.addListener(_onChangedField); } // Handle a potential change in focusNode by properly disposing of the old one // and setting up the new one, if needed. void _updateFocusNode(FocusNode? old, FocusNode? current) { if ((old == null && current == null) || old == current) { return; } if (old == null) { _focusNode.removeListener(_onChangedFocus); _focusNode.dispose(); _focusNode = current!; } else if (current == null) { _focusNode.removeListener(_onChangedFocus); _focusNode = FocusNode(); } else { _focusNode.removeListener(_onChangedFocus); _focusNode = current; } _focusNode.addListener(_onChangedFocus); } @override void initState() { super.initState(); _textEditingController = widget.textEditingController ?? TextEditingController(); _textEditingController.addListener(_onChangedField); _focusNode = widget.focusNode ?? FocusNode(); _focusNode.addListener(_onChangedFocus); SchedulerBinding.instance!.addPostFrameCallback((Duration _) { _updateOverlay(); }); } @override void didUpdateWidget(RawAutocomplete oldWidget) { super.didUpdateWidget(oldWidget); _updateTextEditingController( oldWidget.textEditingController, widget.textEditingController, ); _updateFocusNode(oldWidget.focusNode, widget.focusNode); SchedulerBinding.instance!.addPostFrameCallback((Duration _) { _updateOverlay(); }); } @override void dispose() { _textEditingController.removeListener(_onChangedField); if (widget.textEditingController == null) { _textEditingController.dispose(); } _focusNode.removeListener(_onChangedFocus); if (widget.focusNode == null) { _focusNode.dispose(); } _floatingOptions?.remove(); _floatingOptions = null; super.dispose(); } @override Widget build(BuildContext context) { return Container( key: _fieldKey, child: CompositedTransformTarget( link: _optionsLayerLink, child: widget.fieldViewBuilder == null ? const SizedBox.shrink() : widget.fieldViewBuilder!( context, _textEditingController, _focusNode, _onFieldSubmitted, ), ), ); } }