| // 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 MaterialApp( |
| theme: ThemeData(useMaterial3: true), |
| home: const 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 EdgeInsets.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: 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 EdgeInsets.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: MaterialTapTargetSize.shrinkWrap, |
| padding: const EdgeInsets.all(2), |
| ), |
| ); |
| } |
| } |