-
-
Save battlecook/2afbc23e17d4d77069681e21c862b692 to your computer and use it in GitHub Desktop.
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) { | |
addChip(tag as T); | |
} | |
_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: | |
if(widget.separator == '\n') { | |
String newTag = _value.normalCharactersText; | |
if(newTag.isEmpty) { | |
_updateTextInput(); | |
return; | |
} | |
_chips.add(newTag as T); | |
if (widget.onChangedTag != null) { | |
widget.onChangedTag!(); | |
} | |
_enteredTags.add(newTag); | |
_updateTextInput(); | |
} | |
break; | |
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 | |
} | |
@override | |
void insertTextPlaceholder(Size size) { | |
// TODO: implement insertTextPlaceholder | |
} | |
@override | |
void removeTextPlaceholder() { | |
// TODO: implement removeTextPlaceholder | |
} | |
@override | |
void showToolbar() { | |
// TODO: implement showToolbar | |
} | |
} | |
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()); | |
} |
I wrote the code so that the user can inject the chip's options.
Just modify the chipBuilder value. If you want to change only the delete chip in the code above, you can change the deleteIconColor value.
replace deleteIconColor: Color.fromRGBO(138, 145, 151, 1), to "The color you want "
Loved it. Thank you!
hope it helps
Is it possible to add a chip by pressing the enter key?
The enter key cannot be used at this time. It's an old code, so it needs to be modified to work with the latest flutter. I'm happy that there are still people helped at this code. I'm going to check at this code soon and try to fix it. Then I will check the enter key as well.
I worked to recognize the enter key using the action value of the performAction function.
If you want a chip to be added when you press the enter key, you can add a separator as shown below.
separator: '\n',
Hi, Thank you so much for the gist. Is there any way to change the color of the remove chip icon?