A chip input field using Material
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,
),
),
);
}
}
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),
);
},
)
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,
),
),
);
}
}
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,
),
),
);
}
}
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 InputChip
s.
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.
The method * was called on null.
–
Stretch 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
© 2022 - 2024 — McMap. All rights reserved.