1
+ import 'dart:async' ;
2
+ import 'package:flutter/material.dart' ;
3
+ import 'package:flutter/services.dart' ;
4
+
5
+ typedef ChipsInputSuggestions <T > = Future <List <T >> Function (String query);
6
+ typedef ChipSelected <T > = void Function (T data, bool selected);
7
+ typedef ChipsBuilder <T > = Widget Function (BuildContext context, ChipsInputState <T > state, T data);
8
+
9
+ class ChipsInput <T > extends StatefulWidget {
10
+ const ChipsInput ({
11
+ Key key,
12
+ this .decoration = const InputDecoration (),
13
+ @required this .chipBuilder,
14
+ @required this .suggestionBuilder,
15
+ @required this .findSuggestions,
16
+ @required this .onChanged,
17
+ this .onChipTapped,
18
+ }) : super (key: key);
19
+
20
+ final InputDecoration decoration;
21
+ final ChipsInputSuggestions findSuggestions;
22
+ final ValueChanged <List <T >> onChanged;
23
+ final ValueChanged <T > onChipTapped;
24
+ final ChipsBuilder <T > chipBuilder;
25
+ final ChipsBuilder <T > suggestionBuilder;
26
+
27
+ @override
28
+ ChipsInputState <T > createState () => ChipsInputState <T >();
29
+ }
30
+
31
+ class ChipsInputState <T > extends State <ChipsInput <T >> implements TextInputClient {
32
+ static const kObjectReplacementChar = 0xFFFC ;
33
+
34
+ Set <T > _chips = Set <T >();
35
+ List <T > _suggestions;
36
+ int _searchId = 0 ;
37
+
38
+ FocusNode _focusNode;
39
+ TextEditingValue _value = TextEditingValue ();
40
+ TextInputConnection _connection;
41
+
42
+ String get text => String .fromCharCodes (
43
+ _value.text.codeUnits.where ((ch) => ch != kObjectReplacementChar),
44
+ );
45
+
46
+ bool get _hasInputConnection => _connection != null && _connection.attached;
47
+
48
+ void requestKeyboard () {
49
+ if (_focusNode.hasFocus) {
50
+ _openInputConnection ();
51
+ } else {
52
+ FocusScope .of (context).requestFocus (_focusNode);
53
+ }
54
+ }
55
+
56
+ void selectSuggestion (T data) {
57
+ setState (() {
58
+ _chips.add (data);
59
+ _updateTextInputState ();
60
+ _suggestions = null ;
61
+ });
62
+ widget.onChanged (_chips.toList (growable: false ));
63
+ }
64
+
65
+ void deleteChip (T data) {
66
+ setState (() {
67
+ _chips.remove (data);
68
+ _updateTextInputState ();
69
+ });
70
+ widget.onChanged (_chips.toList (growable: false ));
71
+ }
72
+
73
+ @override
74
+ void initState () {
75
+ super .initState ();
76
+ _focusNode = FocusNode ();
77
+ _focusNode.addListener (_onFocusChanged);
78
+ }
79
+
80
+ void _onFocusChanged () {
81
+ if (_focusNode.hasFocus) {
82
+ _openInputConnection ();
83
+ } else {
84
+ _closeInputConnectionIfNeeded ();
85
+ }
86
+ setState (() {
87
+ // rebuild so that _TextCursor is hidden.
88
+ });
89
+ }
90
+
91
+ @override
92
+ void dispose () {
93
+ _focusNode? .dispose ();
94
+ _closeInputConnectionIfNeeded ();
95
+ super .dispose ();
96
+ }
97
+
98
+ void _openInputConnection () {
99
+ if (! _hasInputConnection) {
100
+ _connection = TextInput .attach (this , TextInputConfiguration ());
101
+ _connection.setEditingState (_value);
102
+ }
103
+ _connection.show ();
104
+ }
105
+
106
+ void _closeInputConnectionIfNeeded () {
107
+ if (_hasInputConnection) {
108
+ _connection.close ();
109
+ _connection = null ;
110
+ }
111
+ }
112
+
113
+ @override
114
+ Widget build (BuildContext context) {
115
+ var chipsChildren = _chips
116
+ .map <Widget >(
117
+ (data) => widget.chipBuilder (context, this , data),
118
+ )
119
+ .toList ();
120
+
121
+ final theme = Theme .of (context);
122
+
123
+ chipsChildren.add (
124
+ Container (
125
+ height: 32.0 ,
126
+ child: Row (
127
+ mainAxisSize: MainAxisSize .min,
128
+ crossAxisAlignment: CrossAxisAlignment .stretch,
129
+ children: < Widget > [
130
+ Text (
131
+ text,
132
+ style: theme.textTheme.subhead.copyWith (
133
+ height: 1.5 ,
134
+ ),
135
+ ),
136
+ _TextCaret (
137
+ resumed: _focusNode.hasFocus,
138
+ ),
139
+ ],
140
+ ),
141
+ ),
142
+ );
143
+
144
+ return Column (
145
+ crossAxisAlignment: CrossAxisAlignment .stretch,
146
+ //mainAxisSize: MainAxisSize.min,
147
+ children: < Widget > [
148
+ GestureDetector (
149
+ behavior: HitTestBehavior .opaque,
150
+ onTap: requestKeyboard,
151
+ child: InputDecorator (
152
+ decoration: widget.decoration,
153
+ isFocused: _focusNode.hasFocus,
154
+ isEmpty: _value.text.length == 0 ,
155
+ child: Wrap (
156
+ children: chipsChildren,
157
+ spacing: 4.0 ,
158
+ runSpacing: 4.0 ,
159
+ ),
160
+ ),
161
+ ),
162
+ Expanded (
163
+ child: ListView .builder (
164
+ itemCount: _suggestions? .length ?? 0 ,
165
+ itemBuilder: (BuildContext context, int index) {
166
+ return widget.suggestionBuilder (context, this , _suggestions[index]);
167
+ },
168
+ ),
169
+ ),
170
+ ],
171
+ );
172
+ }
173
+
174
+ @override
175
+ void updateEditingValue (TextEditingValue value) {
176
+ final oldCount = _countReplacements (_value);
177
+ final newCount = _countReplacements (value);
178
+ setState (() {
179
+ if (newCount < oldCount) {
180
+ _chips = Set .from (_chips.take (newCount));
181
+ }
182
+ _value = value;
183
+ });
184
+ _onSearchChanged (text);
185
+ }
186
+
187
+ int _countReplacements (TextEditingValue value) {
188
+ return value.text.codeUnits.where ((ch) => ch == kObjectReplacementChar).length;
189
+ }
190
+
191
+ @override
192
+ void performAction (TextInputAction action) {
193
+ _focusNode.unfocus ();
194
+ }
195
+
196
+ void _updateTextInputState () {
197
+ final text = String .fromCharCodes (_chips.map ((_) => kObjectReplacementChar));
198
+ _value = TextEditingValue (
199
+ text: text,
200
+ selection: TextSelection .collapsed (offset: text.length),
201
+ composing: TextRange (start: 0 , end: text.length),
202
+ );
203
+ _connection.setEditingState (_value);
204
+ }
205
+
206
+ void _onSearchChanged (String value) async {
207
+ final localId = ++ _searchId;
208
+ final results = await widget.findSuggestions (value);
209
+ if (_searchId == localId && mounted) {
210
+ setState (() => _suggestions = results.where ((profile) => ! _chips.contains (profile)).toList (growable: false ));
211
+ }
212
+ }
213
+ }
214
+
215
+ class _TextCaret extends StatefulWidget {
216
+ const _TextCaret ({
217
+ Key key,
218
+ this .duration = const Duration (milliseconds: 500 ),
219
+ this .resumed = false ,
220
+ }) : super (key: key);
221
+
222
+ final Duration duration;
223
+ final bool resumed;
224
+
225
+ @override
226
+ _TextCursorState createState () => _TextCursorState ();
227
+ }
228
+
229
+ class _TextCursorState extends State <_TextCaret > with SingleTickerProviderStateMixin {
230
+ bool _displayed = false ;
231
+ Timer _timer;
232
+
233
+ @override
234
+ void initState () {
235
+ super .initState ();
236
+ _timer = Timer .periodic (widget.duration, _onTimer);
237
+ }
238
+
239
+ void _onTimer (Timer timer) {
240
+ setState (() => _displayed = ! _displayed);
241
+ }
242
+
243
+ @override
244
+ void dispose () {
245
+ _timer.cancel ();
246
+ super .dispose ();
247
+ }
248
+
249
+ @override
250
+ Widget build (BuildContext context) {
251
+ final theme = Theme .of (context);
252
+ return FractionallySizedBox (
253
+ heightFactor: 0.7 ,
254
+ child: Opacity (
255
+ opacity: _displayed && widget.resumed ? 1.0 : 0.0 ,
256
+ child: Container (
257
+ width: 2.0 ,
258
+ color: theme.primaryColor,
259
+ ),
260
+ ),
261
+ );
262
+ }
263
+ }
0 commit comments