A chip input field using Material
6 Answers
You can use package flutter_chips_input
https://pub.dartlang.org/packages/flutter_chips_input
Just want to provide another option.
You can check example below:

ChipsInput(
initialValue: [
AppProfile('John Doe', '[email protected]', 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg')
],
decoration: InputDecoration(
labelText: "Select People",
),
maxChips: 3,
findSuggestions: (String query) {
if (query.length != 0) {
var lowercaseQuery = query.toLowerCase();
return mockResults.where((profile) {
return profile.name.toLowerCase().contains(query.toLowerCase()) || profile.email.toLowerCase().contains(query.toLowerCase());
}).toList(growable: false)
..sort((a, b) => a.name
.toLowerCase()
.indexOf(lowercaseQuery)
.compareTo(b.name.toLowerCase().indexOf(lowercaseQuery)));
} else {
return const <AppProfile>[];
}
},
onChanged: (data) {
print(data);
},
chipBuilder: (context, state, profile) {
return InputChip(
key: ObjectKey(profile),
label: Text(profile.name),
avatar: CircleAvatar(
backgroundImage: NetworkImage(profile.imageUrl),
),
onDeleted: () => state.deleteChip(profile),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
},
suggestionBuilder: (context, state, profile) {
return ListTile(
key: ObjectKey(profile),
leading: CircleAvatar(
backgroundImage: NetworkImage(profile.imageUrl),
),
title: Text(profile.name),
subtitle: Text(profile.email),
onTap: () => state.selectSuggestion(profile),
);
},
)
5 Comments
You can find an implementation of a Chip Input Field type widget here:
Latest: https://gist.github.com/slightfoot/c6c0f1f1baca326a389a9aec47886ad6
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// See: https://twitter.com/shakil807/status/1042127387515858949
// https://github.com/pchmn/MaterialChipsInput/tree/master/library/src/main/java/com/pchmn/materialchips
// https://github.com/BelooS/ChipsLayoutManager
void main() => runApp(ChipsDemoApp());
class ChipsDemoApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
primaryColor: Colors.indigo,
accentColor: Colors.pink,
),
home: DemoScreen(),
);
}
}
class DemoScreen extends StatefulWidget {
@override
_DemoScreenState createState() => _DemoScreenState();
}
class _DemoScreenState extends State<DemoScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Material Chips Input'),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: const InputDecoration(hintText: 'normal'),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ChipsInput<AppProfile>(
decoration: InputDecoration(prefixIcon: Icon(Icons.search), hintText: 'Profile search'),
findSuggestions: _findSuggestions,
onChanged: _onChanged,
chipBuilder: (BuildContext context, ChipsInputState<AppProfile> state, AppProfile profile) {
return InputChip(
key: ObjectKey(profile),
label: Text(profile.name),
avatar: CircleAvatar(
backgroundImage: NetworkImage(profile.imageUrl),
),
onDeleted: () => state.deleteChip(profile),
onSelected: (_) => _onChipTapped(profile),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
},
suggestionBuilder: (BuildContext context, ChipsInputState<AppProfile> state, AppProfile profile) {
return ListTile(
key: ObjectKey(profile),
leading: CircleAvatar(
backgroundImage: NetworkImage(profile.imageUrl),
),
title: Text(profile.name),
subtitle: Text(profile.email),
onTap: () => state.selectSuggestion(profile),
);
},
),
),
),
],
),
);
}
void _onChipTapped(AppProfile profile) {
print('$profile');
}
void _onChanged(List<AppProfile> data) {
print('onChanged $data');
}
Future<List<AppProfile>> _findSuggestions(String query) async {
if (query.length != 0) {
return mockResults.where((profile) {
return profile.name.contains(query) || profile.email.contains(query);
}).toList(growable: false);
} else {
return const <AppProfile>[];
}
}
}
// -------------------------------------------------
const mockResults = <AppProfile>[
AppProfile('Stock Man', '[email protected]', 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'),
AppProfile('Paul', '[email protected]', 'https://mbtskoudsalg.com/images/person-stock-image-png.png'),
AppProfile('Fred', '[email protected]',
'https://media.istockphoto.com/photos/feeling-great-about-my-corporate-choices-picture-id507296326'),
AppProfile('Bera', '[email protected]',
'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
AppProfile('John', '[email protected]',
'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
AppProfile('Thomas', '[email protected]',
'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
AppProfile('Norbert', '[email protected]',
'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
AppProfile('Marina', '[email protected]',
'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
];
class AppProfile {
final String name;
final String email;
final String imageUrl;
const AppProfile(this.name, this.email, this.imageUrl);
@override
bool operator ==(Object other) =>
identical(this, other) || other is AppProfile && runtimeType == other.runtimeType && name == other.name;
@override
int get hashCode => name.hashCode;
@override
String toString() {
return 'Profile{$name}';
}
}
// -------------------------------------------------
typedef ChipsInputSuggestions<T> = Future<List<T>> Function(String query);
typedef ChipSelected<T> = void Function(T data, bool selected);
typedef ChipsBuilder<T> = Widget Function(BuildContext context, ChipsInputState<T> state, T data);
class ChipsInput<T> extends StatefulWidget {
const ChipsInput({
Key key,
this.decoration = const InputDecoration(),
@required this.chipBuilder,
@required this.suggestionBuilder,
@required this.findSuggestions,
@required this.onChanged,
this.onChipTapped,
}) : super(key: key);
final InputDecoration decoration;
final ChipsInputSuggestions findSuggestions;
final ValueChanged<List<T>> onChanged;
final ValueChanged<T> onChipTapped;
final ChipsBuilder<T> chipBuilder;
final ChipsBuilder<T> suggestionBuilder;
@override
ChipsInputState<T> createState() => ChipsInputState<T>();
}
class ChipsInputState<T> extends State<ChipsInput<T>> implements TextInputClient {
static const kObjectReplacementChar = 0xFFFC;
Set<T> _chips = Set<T>();
List<T> _suggestions;
int _searchId = 0;
FocusNode _focusNode;
TextEditingValue _value = TextEditingValue();
TextInputConnection _connection;
String get text => String.fromCharCodes(
_value.text.codeUnits.where((ch) => ch != kObjectReplacementChar),
);
bool get _hasInputConnection => _connection != null && _connection.attached;
void requestKeyboard() {
if (_focusNode.hasFocus) {
_openInputConnection();
} else {
FocusScope.of(context).requestFocus(_focusNode);
}
}
void selectSuggestion(T data) {
setState(() {
_chips.add(data);
_updateTextInputState();
_suggestions = null;
});
widget.onChanged(_chips.toList(growable: false));
}
void deleteChip(T data) {
setState(() {
_chips.remove(data);
_updateTextInputState();
});
widget.onChanged(_chips.toList(growable: false));
}
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChanged);
}
void _onFocusChanged() {
if (_focusNode.hasFocus) {
_openInputConnection();
} else {
_closeInputConnectionIfNeeded();
}
setState(() {
// rebuild so that _TextCursor is hidden.
});
}
@override
void dispose() {
_focusNode?.dispose();
_closeInputConnectionIfNeeded();
super.dispose();
}
void _openInputConnection() {
if (!_hasInputConnection) {
_connection = TextInput.attach(this, TextInputConfiguration());
_connection.setEditingState(_value);
}
_connection.show();
}
void _closeInputConnectionIfNeeded() {
if (_hasInputConnection) {
_connection.close();
_connection = null;
}
}
@override
Widget build(BuildContext context) {
var chipsChildren = _chips
.map<Widget>(
(data) => widget.chipBuilder(context, this, data),
)
.toList();
final theme = Theme.of(context);
chipsChildren.add(
Container(
height: 32.0,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
text,
style: theme.textTheme.subhead.copyWith(
height: 1.5,
),
),
_TextCaret(
resumed: _focusNode.hasFocus,
),
],
),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
//mainAxisSize: MainAxisSize.min,
children: <Widget>[
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: requestKeyboard,
child: InputDecorator(
decoration: widget.decoration,
isFocused: _focusNode.hasFocus,
isEmpty: _value.text.length == 0,
child: Wrap(
children: chipsChildren,
spacing: 4.0,
runSpacing: 4.0,
),
),
),
Expanded(
child: ListView.builder(
itemCount: _suggestions?.length ?? 0,
itemBuilder: (BuildContext context, int index) {
return widget.suggestionBuilder(context, this, _suggestions[index]);
},
),
),
],
);
}
@override
void updateEditingValue(TextEditingValue value) {
final oldCount = _countReplacements(_value);
final newCount = _countReplacements(value);
setState(() {
if (newCount < oldCount) {
_chips = Set.from(_chips.take(newCount));
}
_value = value;
});
_onSearchChanged(text);
}
int _countReplacements(TextEditingValue value) {
return value.text.codeUnits.where((ch) => ch == kObjectReplacementChar).length;
}
@override
void performAction(TextInputAction action) {
_focusNode.unfocus();
}
void _updateTextInputState() {
final text = String.fromCharCodes(_chips.map((_) => kObjectReplacementChar));
_value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
composing: TextRange(start: 0, end: text.length),
);
_connection.setEditingState(_value);
}
void _onSearchChanged(String value) async {
final localId = ++_searchId;
final results = await widget.findSuggestions(value);
if (_searchId == localId && mounted) {
setState(() => _suggestions = results.where((profile) => !_chips.contains(profile)).toList(growable: false));
}
}
}
class _TextCaret extends StatefulWidget {
const _TextCaret({
Key key,
this.duration = const Duration(milliseconds: 500),
this.resumed = false,
}) : super(key: key);
final Duration duration;
final bool resumed;
@override
_TextCursorState createState() => _TextCursorState();
}
class _TextCursorState extends State<_TextCaret> with SingleTickerProviderStateMixin {
bool _displayed = false;
Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(widget.duration, _onTimer);
}
void _onTimer(Timer timer) {
setState(() => _displayed = !_displayed);
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FractionallySizedBox(
heightFactor: 0.7,
child: Opacity(
opacity: _displayed && widget.resumed ? 1.0 : 0.0,
child: Container(
width: 2.0,
color: theme.primaryColor,
),
),
);
}
}
6 Comments
2023 update
There is a full official example from the Flutter team now. So you can use it. It is based on extending TextEditingController to replace text with InputChips.
Comments
Null safe version of Simon's.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class PlaygroundPage extends StatefulWidget {
@override
_PlaygroundPageState createState() => _PlaygroundPageState();
}
class _PlaygroundPageState extends State<PlaygroundPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Material Chips Input'),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: const InputDecoration(hintText: 'normal'),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ChipsInput<AppProfile>(
decoration: InputDecoration(prefixIcon: Icon(Icons.search), hintText: 'Profile search'),
findSuggestions: _findSuggestions,
onChanged: _onChanged,
chipBuilder: (BuildContext context, ChipsInputState<AppProfile> state, AppProfile profile) {
return InputChip(
key: ObjectKey(profile),
label: Text(profile.name),
avatar: CircleAvatar(
backgroundImage: NetworkImage(profile.imageUrl),
),
onDeleted: () => state.deleteChip(profile),
onSelected: (_) => _onChipTapped(profile),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
},
suggestionBuilder: (BuildContext context, ChipsInputState<AppProfile> state, AppProfile profile) {
return ListTile(
key: ObjectKey(profile),
leading: CircleAvatar(
backgroundImage: NetworkImage(profile.imageUrl),
),
title: Text(profile.name),
subtitle: Text(profile.email),
onTap: () => state.selectSuggestion(profile),
);
},
),
),
),
],
),
);
}
void _onChipTapped(AppProfile profile) {
print('$profile');
}
void _onChanged(List<AppProfile> data) {
print('onChanged $data');
}
Future<List<AppProfile>> _findSuggestions(String query) async {
if (query.length != 0) {
return mockResults.where((profile) {
return profile.name.contains(query) || profile.email.contains(query);
}).toList(growable: false);
} else {
return const <AppProfile>[];
}
}
}
// -------------------------------------------------
const mockResults = <AppProfile>[
AppProfile('Stock Man', '[email protected]', 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'),
AppProfile('Paul', '[email protected]', 'https://mbtskoudsalg.com/images/person-stock-image-png.png'),
AppProfile('Fred', '[email protected]', 'https://media.istockphoto.com/photos/feeling-great-about-my-corporate-choices-picture-id507296326'),
AppProfile('Bera', '[email protected]', 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
AppProfile('John', '[email protected]', 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
AppProfile('Thomas', '[email protected]', 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
AppProfile('Norbert', '[email protected]', 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
AppProfile('Marina', '[email protected]', 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
];
class AppProfile {
final String name;
final String email;
final String imageUrl;
const AppProfile(this.name, this.email, this.imageUrl);
@override
bool operator ==(Object other) => identical(this, other) || other is AppProfile && runtimeType == other.runtimeType && name == other.name;
@override
int get hashCode => name.hashCode;
@override
String toString() {
return 'Profile{$name}';
}
}
// -------------------------------------------------
typedef ChipsInputSuggestions<T> = Future<List<T>> Function(String query);
typedef ChipSelected<T> = void Function(T data, bool selected);
typedef ChipsBuilder<T> = Widget Function(BuildContext context, ChipsInputState<T> state, T data);
class ChipsInput<T> extends StatefulWidget {
const ChipsInput({
Key? key,
this.decoration = const InputDecoration(),
required this.chipBuilder,
required this.suggestionBuilder,
required this.findSuggestions,
required this.onChanged,
this.onChipTapped,
}) : super(key: key);
final InputDecoration decoration;
final ChipsInputSuggestions findSuggestions;
final ValueChanged<List<T>> onChanged;
final ValueChanged<T>? onChipTapped;
final ChipsBuilder<T> chipBuilder;
final ChipsBuilder<T> suggestionBuilder;
@override
ChipsInputState<T> createState() => ChipsInputState<T>();
}
class ChipsInputState<T> extends State<ChipsInput<T>> implements TextInputClient {
static const kObjectReplacementChar = 0xFFFC;
Set<T> _chips = Set<T>();
List<T>? _suggestions;
int _searchId = 0;
FocusNode? _focusNode;
TextEditingValue _value = TextEditingValue();
TextInputConnection? _connection;
String get text => String.fromCharCodes(
_value.text.codeUnits.where((ch) => ch != kObjectReplacementChar),
);
bool get _hasInputConnection => _connection != null && (_connection?.attached ?? false);
void requestKeyboard() {
if (_focusNode?.hasFocus ?? false) {
_openInputConnection();
} else {
FocusScope.of(context).requestFocus(_focusNode);
}
}
void selectSuggestion(T data) {
setState(() {
_chips.add(data);
_updateTextInputState();
_suggestions = null;
});
widget.onChanged(_chips.toList(growable: false));
}
void deleteChip(T data) {
setState(() {
_chips.remove(data);
_updateTextInputState();
});
widget.onChanged(_chips.toList(growable: false));
}
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode?.addListener(_onFocusChanged);
}
void _onFocusChanged() {
if (_focusNode?.hasFocus ?? false) {
_openInputConnection();
} else {
_closeInputConnectionIfNeeded();
}
setState(() {
// rebuild so that _TextCursor is hidden.
});
}
@override
void dispose() {
_focusNode?.dispose();
_closeInputConnectionIfNeeded();
super.dispose();
}
void _openInputConnection() {
if (!_hasInputConnection) {
_connection = TextInput.attach(this, TextInputConfiguration());
_connection?.setEditingState(_value);
}
_connection?.show();
}
void _closeInputConnectionIfNeeded() {
if (_hasInputConnection) {
_connection?.close();
_connection = null;
}
}
@override
Widget build(BuildContext context) {
var chipsChildren = _chips
.map<Widget>(
(data) => widget.chipBuilder(context, this, data),
)
.toList();
final theme = Theme.of(context);
chipsChildren.add(
Container(
height: 32.0,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
text,
style: theme.textTheme.subtitle1?.copyWith(
height: 1.5,
),
),
_TextCaret(
resumed: _focusNode?.hasFocus ?? false,
),
],
),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
//mainAxisSize: MainAxisSize.min,
children: <Widget>[
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: requestKeyboard,
child: InputDecorator(
decoration: widget.decoration,
isFocused: _focusNode?.hasFocus ?? false,
isEmpty: _value.text.length == 0,
child: Wrap(
children: chipsChildren,
spacing: 4.0,
runSpacing: 4.0,
),
),
),
Expanded(
child: ListView.builder(
itemCount: _suggestions?.length ?? 0,
itemBuilder: (BuildContext context, int index) {
return widget.suggestionBuilder(context, this, _suggestions![index]);
},
),
),
],
);
}
@override
void updateEditingValue(TextEditingValue value) {
final oldCount = _countReplacements(_value);
final newCount = _countReplacements(value);
setState(() {
if (newCount < oldCount) {
_chips = Set.from(_chips.take(newCount));
}
_value = value;
});
_onSearchChanged(text);
}
int _countReplacements(TextEditingValue value) {
return value.text.codeUnits.where((ch) => ch == kObjectReplacementChar).length;
}
@override
void performAction(TextInputAction action) {
_focusNode?.unfocus();
}
void _updateTextInputState() {
final text = String.fromCharCodes(_chips.map((_) => kObjectReplacementChar));
_value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
composing: TextRange(start: 0, end: text.length),
);
_connection?.setEditingState(_value);
}
void _onSearchChanged(String value) async {
final localId = ++_searchId;
final results = await widget.findSuggestions(value);
if (_searchId == localId && mounted) {
setState(() => _suggestions = results.where((profile) => !_chips.contains(profile)).toList(growable: false) as List<T>?);
}
}
@override
void connectionClosed() {
// TODO: implement connectionClosed
}
@override
// TODO: implement currentAutofillScope
AutofillScope? get currentAutofillScope => throw UnimplementedError();
@override
// TODO: implement currentTextEditingValue
TextEditingValue? get currentTextEditingValue => throw UnimplementedError();
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {
// TODO: implement performPrivateCommand
}
@override
void showAutocorrectionPromptRect(int start, int end) {
// TODO: implement showAutocorrectionPromptRect
}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
// TODO: implement updateFloatingCursor
}
}
class _TextCaret extends StatefulWidget {
const _TextCaret({
Key? key,
this.duration = const Duration(milliseconds: 500),
this.resumed = false,
}) : super(key: key);
final Duration duration;
final bool resumed;
@override
_TextCursorState createState() => _TextCursorState();
}
class _TextCursorState extends State<_TextCaret> with SingleTickerProviderStateMixin {
bool _displayed = false;
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(widget.duration, _onTimer);
}
void _onTimer(Timer timer) {
setState(() => _displayed = !_displayed);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FractionallySizedBox(
heightFactor: 0.7,
child: Opacity(
opacity: _displayed && widget.resumed ? 1.0 : 0.0,
child: Container(
width: 2.0,
color: theme.primaryColor,
),
),
);
}
}
Comments
I implemented a tag to be created when a user input is received in a TextField and a separator is input.
I tried implementing it by referring to the package flutter_chips_input
Latest: https://gist.github.com/battlecook/2afbc23e17d4d77069681e21c862b692 .
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class TextCursor extends StatefulWidget {
const TextCursor({Key? key,
this.duration = const Duration(milliseconds: 500),
this.resumed = false,
this.cursorColor = Colors.blue,
}) : super(key: key);
final Duration duration;
final bool resumed;
final Color cursorColor;
@override
_TextCursorState createState() => _TextCursorState();
}
class _TextCursorState extends State<TextCursor> with SingleTickerProviderStateMixin {
bool _displayed = false;
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(widget.duration, _onTimer);
}
void _onTimer(Timer timer) {
setState(() => _displayed = !_displayed);
}
@override
void dispose() {
const TextField();
_timer!.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FractionallySizedBox(
heightFactor: 0.7,
child: Opacity(
opacity: _displayed && widget.resumed ? 1.0 : 0.0,
child: Container(
width: 2.0,
color: widget.cursorColor,
),
),
);
}
}
typedef ChipsBuilder<T> = Widget Function(BuildContext context, ChipsInputState<T> state, T data);
typedef ChipTextValidator = int Function(String value);
const kObjectReplacementChar = 0xFFFD;
extension on TextEditingValue {
String get normalCharactersText => String.fromCharCodes(
text.codeUnits.where((ch) => ch != kObjectReplacementChar),
);
List<int> get replacementCharacters => text.codeUnits.where((ch) => ch == kObjectReplacementChar).toList(growable: false);
int get replacementCharactersCount => replacementCharacters.length;
}
class ChipsInput<T> extends StatefulWidget {
const ChipsInput({
required Key key,
this.decoration = const InputDecoration(),
this.enabled = true,
required this.width,
this.chipBuilder,
this.addChip,
this.deleteChip,
this.onChangedTag,
this.initialTags = const <String>[],
this.separator = ' ',
required this.chipTextValidator,
this.chipSpacing = 6,
this.maxChips = 5,
this.maxTagSize = 10,
this.maxTagColor = Colors.red,
this.cursorColor = Colors.blue,
this.textStyle,
this.countTextStyle = const TextStyle(color: Colors.black),
this.countMaxTextStyle = const TextStyle(color: Colors.red),
this.inputType = TextInputType.text,
this.textOverflow = TextOverflow.clip,
this.obscureText = false,
this.autocorrect = true,
this.actionLabel,
this.inputAction = TextInputAction.done,
this.keyboardAppearance = Brightness.light,
this.textCapitalization = TextCapitalization.none,
this.autofocus = false,
this.focusNode,
}) : assert(initialTags.length <= maxChips),
assert(separator.length == 1),
assert(chipSpacing > 0),
super(key: key);
final InputDecoration decoration;
final TextStyle? textStyle;
final double width;
final bool enabled;
final ChipsBuilder<T>? chipBuilder;
final ValueChanged<String>? addChip;
final Function()? deleteChip;
final Function()? onChangedTag;
final String separator;
final ChipTextValidator chipTextValidator;
final double chipSpacing;
final int maxTagSize;
final Color maxTagColor;
final Color cursorColor;
final List<String> initialTags;
final int maxChips;
final TextStyle countTextStyle;
final TextStyle countMaxTextStyle;
final TextInputType inputType;
final TextOverflow textOverflow;
final bool obscureText;
final bool autocorrect;
final String? actionLabel;
final TextInputAction inputAction;
final Brightness keyboardAppearance;
final bool autofocus;
final FocusNode? focusNode;
final TextCapitalization textCapitalization;
@override
ChipsInputState<T> createState() => ChipsInputState<T>();
}
class ChipsInputState<T> extends State<ChipsInput<T>> implements TextInputClient {
Set<T> _chips = <T>{};
TextEditingValue _value = const TextEditingValue();
TextInputConnection? _textInputConnection;
Size? size;
final Map<T, String> _enteredTexts = {};
final List<String> _enteredTags = [];
FocusNode? _focusNode;
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode());
TextInputConfiguration get textInputConfiguration => TextInputConfiguration(
inputType: widget.inputType,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
actionLabel: widget.actionLabel,
inputAction: widget.inputAction,
keyboardAppearance: widget.keyboardAppearance,
textCapitalization: widget.textCapitalization,
);
bool get _hasInputConnection => _textInputConnection != null && _textInputConnection!.attached;
final ScrollController _chipScrollController = ScrollController();
final ScrollController _inputTextScrollController = ScrollController();
double? _inputTextSize;
double? _countSizeBox;
double? _chipBoxSize;
@override
void initState() {
super.initState();
for (var tag in widget.initialTags) {
//widget.addChip(tag);
}
_enteredTags.addAll(widget.initialTags);
_effectiveFocusNode.addListener(_handleFocusChanged);
final String initText = String.fromCharCodes(_chips.map((_) => kObjectReplacementChar));
TextEditingValue initValue = TextEditingValue(text: initText);
initValue = initValue.copyWith(
text: initText,
selection: TextSelection.collapsed(offset: initText.length),
);
_textInputConnection ??= TextInput.attach(this, textInputConfiguration)..setEditingState(initValue);
_updateTextInput(putText: _value.normalCharactersText);
_scrollToEnd(_inputTextScrollController);
_chipBoxSize = widget.width * 0.7;
_inputTextSize = widget.width * 0.1;
_countSizeBox = widget.width * 0.1;
_chipScrollController.addListener(() {
if (_chipScrollController.position.viewportDimension + _inputTextScrollController.position.viewportDimension > widget.width * 0.8) {
_inputTextSize = _inputTextScrollController.position.viewportDimension;
_chipBoxSize = widget.width * 0.8 - _inputTextSize!;
setState(() {});
}
});
WidgetsBinding.instance?.addPostFrameCallback((_) async {
if (mounted && widget.autofocus) {
FocusScope.of(context).autofocus(_effectiveFocusNode);
}
});
}
void _handleFocusChanged() {
if (_effectiveFocusNode.hasFocus) {
_openInputConnection();
} else {
_closeInputConnectionIfNeeded();
}
if (mounted) {
setState(() {});
}
}
void _openInputConnection() {
if (!_hasInputConnection) {
_textInputConnection = TextInput.attach(this, textInputConfiguration)..setEditingState(_value);
}
_textInputConnection!.show();
Future.delayed(const Duration(milliseconds: 100), () {
WidgetsBinding.instance?.addPostFrameCallback((_) async {
RenderObject? renderBox = context.findRenderObject();
Scrollable.of(context)?.position.ensureVisible(renderBox!);
});
});
}
void _closeInputConnectionIfNeeded() {
if (_hasInputConnection) {
_textInputConnection!.close();
}
}
List<String> getTags() {
List<String> tags = [];
for (var element in _chips) {
tags.add(element.toString());
}
return tags;
}
void deleteChip(T data) {
if (widget.enabled) {
_chips.remove(data);
if (_enteredTexts.containsKey(data)) {
_enteredTexts.remove(data);
}
_updateTextInput(putText: _value.normalCharactersText);
}
if (widget.deleteChip != null) {
widget.deleteChip!();
}
}
@override
void connectionClosed() {}
@override
TextEditingValue get currentTextEditingValue => _value;
@override
void performAction(TextInputAction action) {
switch (action) {
case TextInputAction.done:
case TextInputAction.go:
case TextInputAction.send:
case TextInputAction.search:
default:
break;
}
}
@override
void updateEditingValue(TextEditingValue value) {
if (_chipScrollController.hasClients) {
_inputTextSize = _inputTextScrollController.position.viewportDimension + 20;
_chipBoxSize = widget.width * 0.8 - _inputTextScrollController.position.viewportDimension;
}
int index = widget.chipTextValidator(value.text);
if (index == -1) {
}
var _newTextEditingValue = value;
var _oldTextEditingValue = _value;
if (_newTextEditingValue.replacementCharactersCount >= _oldTextEditingValue.replacementCharactersCount && _chips.length >= widget.maxChips) {
_updateTextInput();
_textInputConnection!.setEditingState(_value);
return;
}
if (_newTextEditingValue.text != _oldTextEditingValue.text) {
if(_newTextEditingValue.text == widget.separator) {
_updateTextInput();
return;
}
setState(() {
_value = value;
});
if (_newTextEditingValue.replacementCharactersCount < _oldTextEditingValue.replacementCharactersCount) {
_chips = Set.from(_chips.take(_newTextEditingValue.replacementCharactersCount));
}
_updateTextInput(putText: _value.normalCharactersText);
}
String tagText = _value.normalCharactersText;
if (tagText.isNotEmpty) {
String lastString = tagText.substring(tagText.length - 1);
if (tagText.length >= widget.maxTagSize && lastString != widget.separator) {
_updateTextInput(putText: tagText.substring(0, widget.maxTagSize));
return;
}
if (lastString == widget.separator) {
String newTag = tagText.substring(0, tagText.length - 1);
if(newTag.isEmpty) {
_updateTextInput();
return;
}
_chips.add(newTag as T);
if (widget.onChangedTag != null) {
widget.onChangedTag!();
}
_enteredTags.add(newTag);
_updateTextInput();
}
}
}
void addChip(T data) {
String enteredText = _value.normalCharactersText;
if (enteredText.isNotEmpty) _enteredTexts[data] = enteredText;
_chips.add(data);
}
void _updateTextInput({String putText = ''}) {
final String updatedText = String.fromCharCodes(_chips.map((_) => kObjectReplacementChar)) + putText;
setState(() {
_value = _value.copyWith(
text: updatedText,
selection: TextSelection.collapsed(offset: updatedText.length),
);
});
_textInputConnection ??= TextInput.attach(this, textInputConfiguration);
_textInputConnection!.setEditingState(_value);
}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {}
void _scrollToEnd(ScrollController controller) {
Timer(const Duration(milliseconds: 100), () {
controller.jumpTo(controller.position.maxScrollExtent);
});
}
@override
Widget build(BuildContext context) {
List<Widget> chipsChildren = _chips.map<Widget>((data) => widget.chipBuilder!(context, this, data)).toList();
Widget chipsBox = ConstrainedBox(
constraints: BoxConstraints(
maxWidth: _chipBoxSize!,
),
child: SingleChildScrollView(
controller: _chipScrollController,
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: widget.chipSpacing,
children: chipsChildren,
),
),
);
int maxCount = widget.maxChips;
int currentCount = chipsChildren.length;
List<String> tagAll = [];
for (var element in _chips) {
tagAll.add(element.toString());
}
_scrollToEnd(_chipScrollController);
_scrollToEnd(_inputTextScrollController);
Widget countWidget = const SizedBox.shrink();
TextStyle countWidgetTextStyle = widget.countTextStyle;
if (widget.maxChips <= chipsChildren.length) {
countWidgetTextStyle = widget.countMaxTextStyle;
}
countWidget = Text(currentCount.toString() + "/" + maxCount.toString(), style: countWidgetTextStyle);
double leftPaddingSize = 0;
if (_chips.isNotEmpty) {
leftPaddingSize = widget.chipSpacing;
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
FocusScope.of(context).requestFocus(_effectiveFocusNode);
_textInputConnection!.show();
},
child: InputDecorator(
decoration: widget.decoration,
isFocused: _effectiveFocusNode.hasFocus,
isEmpty: _value.text.isEmpty && _chips.isEmpty,
child: Row(
children: <Widget>[
chipsBox,
Padding(
padding: EdgeInsets.only(left: leftPaddingSize),
),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: _inputTextSize!,
maxHeight: 32.0,
),
child: SingleChildScrollView(
controller: _inputTextScrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Flexible(
flex: 1,
child: Center(
child: Text(
_value.normalCharactersText,
maxLines: 1,
overflow: widget.textOverflow,
style: widget.textStyle,
//style: TextStyle(height: _textStyle.height, color: c, fontFamily: _textStyle.fontFamily, fontSize: _textStyle.fontSize),
),
),
),
Flexible(flex: 0, child: TextCursor(resumed: _effectiveFocusNode.hasFocus, cursorColor: widget.cursorColor,)),
],
),
),
),
const Spacer(),
SizedBox(
width: _countSizeBox,
child: Row(
children: <Widget>[
const Padding(
padding: EdgeInsets.only(left: 8),
),
countWidget,
],
)),
],
),
),
);
}
@override
// TODO: implement currentAutofillScope
AutofillScope get currentAutofillScope => throw UnimplementedError();
@override
void showAutocorrectionPromptRect(int start, int end) {
// TODO: implement showAutocorrectionPromptRect
}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {
// TODO: implement performPrivateCommand
}
}
class SampleWidget extends StatelessWidget {
const SampleWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(home: HomeWidget());
}
}
class HomeWidget extends StatelessWidget {
final GlobalKey<ChipsInputState> _chipKey = GlobalKey();
HomeWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: ChipsInput(
key: _chipKey,
keyboardAppearance: Brightness.dark,
textCapitalization: TextCapitalization.words,
width: MediaQuery.of(context).size.width,
enabled: true,
maxChips: 5,
separator: ' ',
decoration: const InputDecoration(
hintText: 'Enter Tag...',
),
initialTags: const [],
autofocus: true,
chipTextValidator: (String value) {
value.contains('!');
return -1;
},
chipBuilder: (context, state, String tag) {
return InputChip(
labelPadding: const EdgeInsets.only(left: 8.0, right: 3),
backgroundColor: Colors.white,
shape: const StadiumBorder(side: BorderSide(width: 1.8, color: Color.fromRGBO(228, 230, 235, 1))),
shadowColor: Colors.grey,
key: ObjectKey(tag),
label: Text(
"# " + tag.toString(),
textAlign: TextAlign.center,
),
onDeleted: () => state.deleteChip(tag),
deleteIconColor: const Color.fromRGBO(138, 145, 151, 1),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
},
),
),
),
);
}
}
void main() {
runApp(const SampleWidget());
}
You can check the operation by copying the code to dartpad.
3 Comments
The method * was called on null.You can use the dependencies Mentioned above or you could use the Inputchip Class provided by Flutter with a combination of InputChip, List, TextFormField & callbacks. It is easy to achieve.
If you want to know more I have written an article on This, here: https://dev.to/imadnan/flutter-inputchips-inside-textformfield-fo2>InputChipBlog
