Skip to content

Commit

Permalink
Add an example for InputChip generated by user input (#130645)
Browse files Browse the repository at this point in the history
New example for `InputChip` that demonstrate how to create/delete them based on user text inputs.

The sample application shows a custom text area where user can enter text. After the user has typed and hits _Enter_ the text will be replaced with an `InputChip` that contains that text. Is it possible to continue typing and add more chips in this way. All of them will be placed in a scrollable horizontal row. Also is it possible to have suggestion displayed below the text input field in case the typed text match some of the available suggestions.

Issue I'm trying to solve:

- flutter/flutter#128247

**Code structure:**

The example app is composed of 2 main components that find places inside `MainScreen`:

 - `ChipsInput`
 - `ListView`

`ChipsInput` emulates a `TextField` where you can enter text. This text field accepts also a list of values of generic type T (`Topping` in my example), that gets rendered as `InputChip` inside the text field, before the text inserted by the user. This widgets is basically an `InputDecorator` widget that implements `TextInputClient` to get `TextEditingValue` events from the user keyboard. At the end of the input field there is another component, the `TextCursor`, that is displayed just when the user give the focus to the field and emulates the carrets that `TextField` has.

There are also some available callbacks that the user can use to capture events in the `ChipsInput` field like: `onChanged`, `onChipTapped`, `onSubmitted` and `onTextChanged`. This last callback is used to build a list of suggestion that will be placed just below the `ChipsInput` field inside the `ListView`.
  • Loading branch information
andreacioni authored Sep 1, 2023
1 parent 62fb15a commit 400702d
Show file tree
Hide file tree
Showing 3 changed files with 441 additions and 0 deletions.
369 changes: 369 additions & 0 deletions examples/api/lib/material/input_chip/input_chip.1.dart
Original file line number Diff line number Diff line change
@@ -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<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),
),
);
}
}
Loading

0 comments on commit 400702d

Please sign in to comment.