diff --git a/examples/api/lib/material/input_chip/input_chip.1.dart b/examples/api/lib/material/input_chip/input_chip.1.dart new file mode 100644 index 0000000000..c503e6ce7e --- /dev/null +++ b/examples/api/lib/material/input_chip/input_chip.1.dart @@ -0,0 +1,369 @@ +// 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 _pizzaToppings = [ + '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 { + final FocusNode _chipFocusNode = FocusNode(); + List _toppings = [_pizzaToppings.first]; + List _suggestions = []; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Editable Chip Field Sample'), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ChipsInput( + 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 _onSearchChanged(String value) async { + final List 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 = []; + }); + } + + void _onChipTapped(String topping) {} + + void _onChipDeleted(String topping) { + setState(() { + _toppings.remove(topping); + _suggestions = []; + }); + } + + void _onSubmitted(String text) { + if (text.trim().isNotEmpty) { + setState(() { + _toppings = [..._toppings, text.trim()]; + }); + } else { + _chipFocusNode.unfocus(); + setState(() { + _toppings = []; + }); + } + } + + void _onChanged(List data) { + setState(() { + _toppings = data; + }); + } + + FutureOr> _suggestionCallback(String text) { + if (text.isNotEmpty) { + return _pizzaToppings.where((String topping) { + return topping.toLowerCase().contains(text.toLowerCase()); + }).toList(); + } + return const []; + } +} + +class ChipsInput 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 values; + final InputDecoration decoration; + final TextStyle? style; + final StrutStyle? strutStyle; + + final ValueChanged> onChanged; + final ValueChanged? onChipTapped; + final ValueChanged? onSubmitted; + final ValueChanged? onTextChanged; + + final Widget Function(BuildContext context, T data) chipBuilder; + + @override + ChipsInputState createState() => ChipsInputState(); +} + +class ChipsInputState extends State> { + @visibleForTesting + late final ChipsInputEditingController controller; + + String _previousText = ''; + TextSelection? _previousSelection; + + @override + void initState() { + super.initState(); + + controller = ChipsInputEditingController( + [...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 values = [...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([...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 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 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 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 chipWidgets = + values.map((T v) => WidgetSpan(child: chipBuilder(context, v))); + + return TextSpan( + style: style, + children: [ + ...chipWidgets, + if (textWithoutReplacements.isNotEmpty) + TextSpan(text: textWithoutReplacements) + ], + ); + } +} + +class ToppingSuggestion extends StatelessWidget { + const ToppingSuggestion(this.topping, {super.key, this.onTap}); + + final String topping; + final ValueChanged? 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 onDeleted; + final ValueChanged 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), + ), + ); + } +} diff --git a/examples/api/test/material/input_chip/input_chip.1_test.dart b/examples/api/test/material/input_chip/input_chip.1_test.dart new file mode 100644 index 0000000000..e55a483efd --- /dev/null +++ b/examples/api/test/material/input_chip/input_chip.1_test.dart @@ -0,0 +1,62 @@ +// 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/material.dart'; +import 'package:flutter_api_samples/material/input_chip/input_chip.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final String replacementChar = String.fromCharCode( + example.ChipsInputEditingController.kObjectReplacementChar); + + testWidgets('User input generates InputChips', (WidgetTester tester) async { + await tester.pumpWidget( + const example.EditableChipFieldApp(), + ); + await tester.pumpAndSettle(); + + expect(find.byType(example.EditableChipFieldApp), findsOneWidget); + expect(find.byType(example.ChipsInput), findsOneWidget); + expect(find.byType(InputChip), findsOneWidget); + + example.ChipsInputState state = + tester.state(find.byType(example.ChipsInput)); + expect(state.controller.textWithoutReplacements.isEmpty, true); + + await tester.tap(find.byType(example.ChipsInput)); + await tester.pumpAndSettle(); + expect(tester.testTextInput.isVisible, true); + // Simulating text typing on the input field. + tester.testTextInput.enterText('${replacementChar}ham'); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsOneWidget); + + state = tester.state(find.byType(example.ChipsInput)); + await tester.pumpAndSettle(); + expect(state.controller.textWithoutReplacements, 'ham'); + + // Add new InputChip by sending the "done" action. + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(state.controller.textWithoutReplacements.isEmpty, true); + + expect(find.byType(InputChip), findsNWidgets(2)); + + // Simulate item deletion. + await tester.tap(find.descendant( + of: find.byType(InputChip), + matching: find.byType(InkWell).last, + )); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsOneWidget); + + await tester.tap(find.descendant( + of: find.byType(InputChip), + matching: find.byType(InkWell).last, + )); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsNothing); + }); +} diff --git a/packages/flutter/lib/src/material/input_chip.dart b/packages/flutter/lib/src/material/input_chip.dart index 56ef93fe6c..e2146d14d4 100644 --- a/packages/flutter/lib/src/material/input_chip.dart +++ b/packages/flutter/lib/src/material/input_chip.dart @@ -42,6 +42,16 @@ import 'theme_data.dart'; /// ** See code in examples/api/lib/material/input_chip/input_chip.0.dart ** /// {@end-tool} /// +/// +/// {@tool dartpad} +/// The following example shows how to generate [InputChip]s from +/// user text input. When the user enters a pizza topping in the text field, +/// the user is presented with a list of suggestions. When selecting one of the +/// suggestions, an [InputChip] is generated in the text field. +/// +/// ** See code in examples/api/lib/material/input_chip/input_chip.1.dart ** +/// {@end-tool} +/// /// ## Material Design 3 /// /// [InputChip] can be used for Input chips from Material Design 3.