// MIT License // // Copyright (c) 2019 Simon Lightfoot // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. // import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; typedef ChipsInputSuggestions = Future> Function(String query); typedef ChipSelected = void Function(T data, bool selected); typedef ChipsBuilder = Widget Function(BuildContext context, ChipsInputState state, T data); class ChipsInput extends StatefulWidget { const ChipsInput({ super.key, this.decoration = const InputDecoration(), required this.chipBuilder, required this.suggestionBuilder, required this.findSuggestions, required this.onChanged, this.onChipTapped, }); final InputDecoration decoration; final ChipsInputSuggestions findSuggestions; final ValueChanged> onChanged; final ValueChanged? onChipTapped; final ChipsBuilder chipBuilder; final ChipsBuilder suggestionBuilder; @override ChipsInputState createState() => ChipsInputState(); } class ChipsInputState extends State> { static const kObjectReplacementChar = 0xFFFC; Set _chips = {}; List _suggestions = []; int _searchId = 0; FocusNode _focusNode = FocusNode(); TextEditingValue _value = const TextEditingValue(); TextInputConnection? _connection; String get text { return String.fromCharCodes( _value.text.codeUnits.where((ch) => ch != kObjectReplacementChar), ); } TextEditingValue get currentTextEditingValue => _value; bool get _hasInputConnection => _connection != null && (_connection?.attached ?? false); void requestKeyboard() { if (_focusNode.hasFocus) { _openInputConnection(); } else { FocusScope.of(context).requestFocus(_focusNode); } } void selectSuggestion(T data) { setState(() { _chips.add(data); _updateTextInputState(); _suggestions = []; }); 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?.setEditingState(_value); } _connection?.show(); } void _closeInputConnectionIfNeeded() { if (_hasInputConnection) { _connection?.close(); _connection = null; } } @override Widget build(BuildContext context) { var chipsChildren = _chips .map( (data) => widget.chipBuilder(context, this, data), ) .toList(); final theme = Theme.of(context); chipsChildren.add( SizedBox( height: 32.0, child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( text, style: theme.textTheme.bodyLarge?.copyWith( height: 1.5, ), ), _TextCaret( resumed: _focusNode.hasFocus, ), ], ), ), ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, //mainAxisSize: MainAxisSize.min, children: [ GestureDetector( behavior: HitTestBehavior.opaque, onTap: requestKeyboard, child: InputDecorator( decoration: widget.decoration, isFocused: _focusNode.hasFocus, isEmpty: _value.text.isEmpty, child: Wrap( children: chipsChildren, spacing: 4.0, runSpacing: 4.0, ), ), ), Expanded( child: ListView.builder( itemCount: _suggestions.length, itemBuilder: (BuildContext context, int index) { return widget.suggestionBuilder(context, this, _suggestions[index]); }, ), ), ], ); } 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; } 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({ this.resumed = false, }); final bool resumed; @override _TextCursorState createState() => _TextCursorState(); } class _TextCursorState extends State<_TextCaret> with SingleTickerProviderStateMixin { bool _displayed = false; late Timer _timer; @override void initState() { super.initState(); } 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, ), ), ); } }