Skip to content

Instantly share code, notes, and snippets.

@battlecook
Last active April 5, 2023 20:22
Show Gist options
  • Save battlecook/2afbc23e17d4d77069681e21c862b692 to your computer and use it in GitHub Desktop.
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());
}
@Shajeel-Afzal
Copy link

Hi, Thank you so much for the gist. Is there any way to change the color of the remove chip icon?

@battlecook
Copy link
Author

battlecook commented May 2, 2021

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 "

@Shajeel-Afzal
Copy link

Loved it. Thank you!

@battlecook
Copy link
Author

hope it helps

@encosto
Copy link

encosto commented Jan 20, 2022

Is it possible to add a chip by pressing the enter key?

@battlecook
Copy link
Author

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.

@battlecook
Copy link
Author

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',

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment