| // 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 'dart:async'; | |
| import 'package:flutter/material.dart'; | |
| const List<String> _pizzaToppings = <String>[ | |
| 'Olives', | |
| 'Tomato', | |
| 'Cheese', | |
| 'Pepperoni', | |
| 'Bacon', | |
| 'Onion', | |
| 'Jalapeno', | |
| 'Mushrooms', | |
| 'Pineapple', | |
| ]; | |
| void main() => runApp(const EditableChipFieldApp()); | |
| class EditableChipFieldApp extends StatelessWidget { | |
| const EditableChipFieldApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return const MaterialApp(home: EditableChipFieldExample()); | |
| } | |
| } | |
| class EditableChipFieldExample extends StatefulWidget { | |
| const EditableChipFieldExample({super.key}); | |
| @override | |
| EditableChipFieldExampleState createState() { | |
| return EditableChipFieldExampleState(); | |
| } | |
| } | |
| class EditableChipFieldExampleState extends State<EditableChipFieldExample> { | |
| final FocusNode _chipFocusNode = FocusNode(); | |
| List<String> _toppings = <String>[_pizzaToppings.first]; | |
| List<String> _suggestions = <String>[]; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar(title: const Text('Editable Chip Field Sample')), | |
| body: Column( | |
| children: <Widget>[ | |
| Padding( | |
| padding: const .symmetric(horizontal: 16), | |
| child: ChipsInput<String>( | |
| values: _toppings, | |
| decoration: const InputDecoration( | |
| prefixIcon: Icon(Icons.local_pizza_rounded), | |
| hintText: 'Search for toppings', | |
| ), | |
| strutStyle: const StrutStyle(fontSize: 15), | |
| onChanged: _onChanged, | |
| onSubmitted: _onSubmitted, | |
| chipBuilder: _chipBuilder, | |
| onTextChanged: _onSearchChanged, | |
| ), | |
| ), | |
| if (_suggestions.isNotEmpty) | |
| Expanded( | |
| child: ListView.builder( | |
| itemCount: _suggestions.length, | |
| itemBuilder: (BuildContext context, int index) { | |
| return ToppingSuggestion( | |
| _suggestions[index], | |
| onTap: _selectSuggestion, | |
| ); | |
| }, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| Future<void> _onSearchChanged(String value) async { | |
| final List<String> results = await _suggestionCallback(value); | |
| setState(() { | |
| _suggestions = results | |
| .where((String topping) => !_toppings.contains(topping)) | |
| .toList(); | |
| }); | |
| } | |
| Widget _chipBuilder(BuildContext context, String topping) { | |
| return ToppingInputChip( | |
| topping: topping, | |
| onDeleted: _onChipDeleted, | |
| onSelected: _onChipTapped, | |
| ); | |
| } | |
| void _selectSuggestion(String topping) { | |
| setState(() { | |
| _toppings.add(topping); | |
| _suggestions = <String>[]; | |
| }); | |
| } | |
| void _onChipTapped(String topping) {} | |
| void _onChipDeleted(String topping) { | |
| setState(() { | |
| _toppings.remove(topping); | |
| _suggestions = <String>[]; | |
| }); | |
| } | |
| void _onSubmitted(String text) { | |
| if (text.trim().isNotEmpty) { | |
| setState(() { | |
| _toppings = <String>[..._toppings, text.trim()]; | |
| }); | |
| } else { | |
| _chipFocusNode.unfocus(); | |
| setState(() { | |
| _toppings = <String>[]; | |
| }); | |
| } | |
| } | |
| void _onChanged(List<String> data) { | |
| setState(() { | |
| _toppings = data; | |
| }); | |
| } | |
| FutureOr<List<String>> _suggestionCallback(String text) { | |
| if (text.isNotEmpty) { | |
| return _pizzaToppings.where((String topping) { | |
| return topping.toLowerCase().contains(text.toLowerCase()); | |
| }).toList(); | |
| } | |
| return const <String>[]; | |
| } | |
| } | |
| class ChipsInput<T> extends StatefulWidget { | |
| const ChipsInput({ | |
| super.key, | |
| required this.values, | |
| this.decoration = const InputDecoration(), | |
| this.style, | |
| this.strutStyle, | |
| required this.chipBuilder, | |
| required this.onChanged, | |
| this.onChipTapped, | |
| this.onSubmitted, | |
| this.onTextChanged, | |
| }); | |
| final List<T> values; | |
| final InputDecoration decoration; | |
| final TextStyle? style; | |
| final StrutStyle? strutStyle; | |
| final ValueChanged<List<T>> onChanged; | |
| final ValueChanged<T>? onChipTapped; | |
| final ValueChanged<String>? onSubmitted; | |
| final ValueChanged<String>? onTextChanged; | |
| final Widget Function(BuildContext context, T data) chipBuilder; | |
| @override | |
| ChipsInputState<T> createState() => ChipsInputState<T>(); | |
| } | |
| class ChipsInputState<T> extends State<ChipsInput<T>> { | |
| @visibleForTesting | |
| late final ChipsInputEditingController<T> controller; | |
| String _previousText = ''; | |
| TextSelection? _previousSelection; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| controller = ChipsInputEditingController<T>(<T>[ | |
| ...widget.values, | |
| ], widget.chipBuilder); | |
| controller.addListener(_textListener); | |
| } | |
| @override | |
| void dispose() { | |
| controller.removeListener(_textListener); | |
| controller.dispose(); | |
| super.dispose(); | |
| } | |
| void _textListener() { | |
| final String currentText = controller.text; | |
| if (_previousSelection != null) { | |
| final int currentNumber = countReplacements(currentText); | |
| final int previousNumber = countReplacements(_previousText); | |
| final int cursorEnd = _previousSelection!.extentOffset; | |
| final int cursorStart = _previousSelection!.baseOffset; | |
| final List<T> values = <T>[...widget.values]; | |
| // If the current number and the previous number of replacements are different, then | |
| // the user has deleted the InputChip using the keyboard. In this case, we trigger | |
| // the onChanged callback. We need to be sure also that the current number of | |
| // replacements is different from the input chip to avoid double-deletion. | |
| if (currentNumber < previousNumber && currentNumber != values.length) { | |
| if (cursorStart == cursorEnd) { | |
| values.removeRange(cursorStart - 1, cursorEnd); | |
| } else { | |
| if (cursorStart > cursorEnd) { | |
| values.removeRange(cursorEnd, cursorStart); | |
| } else { | |
| values.removeRange(cursorStart, cursorEnd); | |
| } | |
| } | |
| widget.onChanged(values); | |
| } | |
| } | |
| _previousText = currentText; | |
| _previousSelection = controller.selection; | |
| } | |
| static int countReplacements(String text) { | |
| return text.codeUnits | |
| .where( | |
| (int u) => u == ChipsInputEditingController.kObjectReplacementChar, | |
| ) | |
| .length; | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| controller.updateValues(<T>[...widget.values]); | |
| return TextField( | |
| minLines: 1, | |
| maxLines: 3, | |
| textInputAction: .done, | |
| style: widget.style, | |
| strutStyle: widget.strutStyle, | |
| controller: controller, | |
| onChanged: (String value) => | |
| widget.onTextChanged?.call(controller.textWithoutReplacements), | |
| onSubmitted: (String value) => | |
| widget.onSubmitted?.call(controller.textWithoutReplacements), | |
| ); | |
| } | |
| } | |
| class ChipsInputEditingController<T> extends TextEditingController { | |
| ChipsInputEditingController(this.values, this.chipBuilder) | |
| : super(text: String.fromCharCode(kObjectReplacementChar) * values.length); | |
| // This constant character acts as a placeholder in the TextField text value. | |
| // There will be one character for each of the InputChip displayed. | |
| static const int kObjectReplacementChar = 0xFFFE; | |
| List<T> values; | |
| final Widget Function(BuildContext context, T data) chipBuilder; | |
| /// Called whenever chip is either added or removed | |
| /// from the outside the context of the text field. | |
| void updateValues(List<T> values) { | |
| if (values.length != this.values.length) { | |
| final String char = String.fromCharCode(kObjectReplacementChar); | |
| final int length = values.length; | |
| value = TextEditingValue( | |
| text: char * length, | |
| selection: TextSelection.collapsed(offset: length), | |
| ); | |
| this.values = values; | |
| } | |
| } | |
| String get textWithoutReplacements { | |
| final String char = String.fromCharCode(kObjectReplacementChar); | |
| return text.replaceAll(RegExp(char), ''); | |
| } | |
| String get textWithReplacements => text; | |
| @override | |
| TextSpan buildTextSpan({ | |
| required BuildContext context, | |
| TextStyle? style, | |
| required bool withComposing, | |
| }) { | |
| final Iterable<WidgetSpan> chipWidgets = values.map( | |
| (T v) => WidgetSpan(child: chipBuilder(context, v)), | |
| ); | |
| return TextSpan( | |
| style: style, | |
| children: <InlineSpan>[ | |
| ...chipWidgets, | |
| if (textWithoutReplacements.isNotEmpty) | |
| TextSpan(text: textWithoutReplacements), | |
| ], | |
| ); | |
| } | |
| } | |
| class ToppingSuggestion extends StatelessWidget { | |
| const ToppingSuggestion(this.topping, {super.key, this.onTap}); | |
| final String topping; | |
| final ValueChanged<String>? onTap; | |
| @override | |
| Widget build(BuildContext context) { | |
| return ListTile( | |
| key: ObjectKey(topping), | |
| leading: CircleAvatar(child: Text(topping[0].toUpperCase())), | |
| title: Text(topping), | |
| onTap: () => onTap?.call(topping), | |
| ); | |
| } | |
| } | |
| class ToppingInputChip extends StatelessWidget { | |
| const ToppingInputChip({ | |
| super.key, | |
| required this.topping, | |
| required this.onDeleted, | |
| required this.onSelected, | |
| }); | |
| final String topping; | |
| final ValueChanged<String> onDeleted; | |
| final ValueChanged<String> onSelected; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Container( | |
| margin: const .only(right: 3), | |
| child: InputChip( | |
| key: ObjectKey(topping), | |
| label: Text(topping), | |
| avatar: CircleAvatar(child: Text(topping[0].toUpperCase())), | |
| onDeleted: () => onDeleted(topping), | |
| onSelected: (bool value) => onSelected(topping), | |
| materialTapTargetSize: .shrinkWrap, | |
| padding: const .all(2), | |
| ), | |
| ); | |
| } | |
| } |