diff --git a/example/lib/main.dart b/example/lib/main.dart index 21d35277..66b1e55d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_chips_input/flutter_chips_input.dart'; +const String avatarPlaceholder = "https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png"; + void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { @@ -24,39 +27,27 @@ class MyHomePage extends StatefulWidget { const MyHomePage({Key? key}) : super(key: key); @override - _MyHomePageState createState() => _MyHomePageState(); + MyHomePageState createState() => MyHomePageState(); } -class _MyHomePageState extends State { +class MyHomePageState extends State { final _chipKey = GlobalKey(); @override Widget build(BuildContext context) { const mockResults = [ - AppProfile('John Doe', 'jdoe@flutter.io', - 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'), - AppProfile('Paul', 'paul@google.com', - 'https://mbtskoudsalg.com/images/person-stock-image-png.png'), - AppProfile('Fred', 'fred@google.com', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'), - AppProfile('Brian', 'brian@flutter.io', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'), - AppProfile('John', 'john@flutter.io', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'), - AppProfile('Thomas', 'thomas@flutter.io', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'), - AppProfile('Nelly', 'nelly@flutter.io', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'), - AppProfile('Marie', 'marie@flutter.io', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'), - AppProfile('Charlie', 'charlie@flutter.io', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'), - AppProfile('Diana', 'diana@flutter.io', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'), - AppProfile('Ernie', 'ernie@flutter.io', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'), - AppProfile('Gina', 'fred@flutter.io', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'), + AppProfile('John Doe', 'jdoe@flutter.io', 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'), + AppProfile('Paul', 'paul@google.com', avatarPlaceholder), + AppProfile('Fred', 'fred@google.com', avatarPlaceholder), + AppProfile('Brian', 'brian@flutter.io', avatarPlaceholder), + AppProfile('John', 'john@flutter.io', avatarPlaceholder), + AppProfile('Thomas', 'thomas@flutter.io', avatarPlaceholder), + AppProfile('Nelly', 'nelly@flutter.io', avatarPlaceholder), + AppProfile('Marie', 'marie@flutter.io', avatarPlaceholder), + AppProfile('Charlie', 'charlie@flutter.io', avatarPlaceholder), + AppProfile('Diana', 'diana@flutter.io', avatarPlaceholder), + AppProfile('Ernie', 'ernie@flutter.io', avatarPlaceholder), + AppProfile('Gina', 'fred@flutter.io', avatarPlaceholder), ]; return Scaffold( @@ -67,7 +58,7 @@ class _MyHomePageState extends State { child: SingleChildScrollView( child: Column( children: [ - ChipsInput( + ChipsInput( key: _chipKey, /*initialValue: [ AppProfile('John Doe', 'jdoe@flutter.io', @@ -77,34 +68,26 @@ class _MyHomePageState extends State { // allowChipEditing: true, keyboardAppearance: Brightness.dark, textCapitalization: TextCapitalization.words, + submitKeys: const [LogicalKeyboardKey.tab], // enabled: false, // maxChips: 5, - textStyle: const TextStyle( - height: 1.5, fontFamily: 'Roboto', fontSize: 16), + textStyle: const TextStyle(height: 1.5, fontFamily: 'Roboto', fontSize: 16), decoration: const InputDecoration( - // prefixIcon: Icon(Icons.search), + prefixIcon: Icon(Icons.search), // hintText: formControl.hint, labelText: 'Select People', - // enabled: false, + enabled: false, // errorText: field.errorText, + border: OutlineInputBorder(), ), - findSuggestions: (String query) { + findSuggestions: (query) { // print("Query: '$query'"); if (query.isNotEmpty) { var lowercaseQuery = query.toLowerCase(); return mockResults.where((profile) { - return profile.name - .toLowerCase() - .contains(query.toLowerCase()) || - profile.email - .toLowerCase() - .contains(query.toLowerCase()); + 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))); + ..sort((a, b) => a.name.toLowerCase().indexOf(lowercaseQuery).compareTo(b.name.toLowerCase().indexOf(lowercaseQuery))); } // return []; return mockResults; @@ -112,39 +95,41 @@ class _MyHomePageState extends State { onChanged: (data) { // print(data); }, - chipBuilder: (context, state, dynamic profile) { + chipBuilder: (context, state, index, profile) { return InputChip( key: ObjectKey(profile), label: Text(profile.name), - avatar: CircleAvatar( - backgroundImage: NetworkImage(profile.imageUrl), - ), - onDeleted: () => state.deleteChip(profile), + avatar: CircleAvatar(backgroundImage: NetworkImage(profile.imageUrl)), + onDeleted: () => state.deleteChip(index), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); }, - suggestionBuilder: (context, state, dynamic profile) { - return ListTile( + suggestionBuilder: (context, state, index, profile) { + return InputChip( key: ObjectKey(profile), - leading: CircleAvatar( - backgroundImage: NetworkImage(profile.imageUrl), - ), - title: Text(profile.name), - subtitle: Text(profile.email), - onTap: () => state.selectSuggestion(profile), + label: Text(profile.name), + avatar: CircleAvatar(backgroundImage: NetworkImage(profile.imageUrl)), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () => state.selectSuggestion(profile), + ); + }, + onSubmit: (txt) { + if (txt.isEmpty) return null; + return mockResults.firstWhere( + (profile) => profile.name == txt, + orElse: () => AppProfile(txt, "", "https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png"), ); }, ), const TextField(), - /*ChipsInput( - initialValue: [ - AppProfile('John Doe', 'jdoe@flutter.io', - 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'), + ChipsInput( + initialValue: const [ + AppProfile('John Doe', 'jdoe@flutter.io', 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'), ], enabled: true, maxChips: 10, - textStyle: TextStyle(height: 1.5, fontFamily: "Roboto", fontSize: 16), - decoration: InputDecoration( + textStyle: const TextStyle(height: 1.5, fontFamily: "Roboto", fontSize: 16), + decoration: const InputDecoration( // prefixIcon: Icon(Icons.search), // hintText: formControl.hint, labelText: "Select People", @@ -152,21 +137,12 @@ class _MyHomePageState extends State { // errorText: field.errorText, ), findSuggestions: (String query) { - if (query.length != 0) { + if (query.isNotEmpty) { var lowercaseQuery = query.toLowerCase(); return mockResults.where((profile) { - return profile.name - .toLowerCase() - .contains(query.toLowerCase()) || - profile.email - .toLowerCase() - .contains(query.toLowerCase()); + 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))); + ..sort((a, b) => a.name.toLowerCase().indexOf(lowercaseQuery).compareTo(b.name.toLowerCase().indexOf(lowercaseQuery))); } else { return mockResults; } @@ -174,35 +150,35 @@ class _MyHomePageState extends State { onChanged: (data) { print(data); }, - chipBuilder: (context, state, profile) { + chipBuilder: (context, state, index, profile) { return InputChip( key: ObjectKey(profile), label: Text(profile.name), - avatar: CircleAvatar( - backgroundImage: NetworkImage(profile.imageUrl), - ), - onDeleted: () => state.deleteChip(profile), + avatar: CircleAvatar(backgroundImage: NetworkImage(profile.imageUrl)), + onDeleted: () => state.deleteChip(index), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); }, - suggestionBuilder: (context, state, profile) { + suggestionBuilder: (context, state, index, profile) { return ListTile( key: ObjectKey(profile), - leading: CircleAvatar( - backgroundImage: NetworkImage(profile.imageUrl), - ), + leading: CircleAvatar(backgroundImage: NetworkImage(profile.imageUrl)), title: Text(profile.name), subtitle: Text(profile.email), onTap: () => state.selectSuggestion(profile), ); }, - ),*/ + onSubmit: (txt) { + if (txt.isEmpty) return null; + return mockResults.firstWhere( + (profile) => profile.name == txt, + orElse: () => AppProfile(txt, "", avatarPlaceholder), + ); + }, + ), ElevatedButton( onPressed: () { - _chipKey.currentState!.selectSuggestion(const AppProfile( - 'Gina', - 'fred@flutter.io', - 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png')); + _chipKey.currentState!.selectSuggestion(const AppProfile('Gina', 'fred@flutter.io', avatarPlaceholder)); }, child: const Text('Add Chip'), ), @@ -222,11 +198,7 @@ class AppProfile { 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; + bool operator ==(Object other) => identical(this, other) || other is AppProfile && runtimeType == other.runtimeType && name == other.name; @override int get hashCode => name.hashCode; diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 45a222ba..351c4003 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=3.0.0' flutter: ">=1.22.0" dependencies: diff --git a/lib/src/chips_input.dart b/lib/src/chips_input.dart index 3c10a20d..a10468ae 100644 --- a/lib/src/chips_input.dart +++ b/lib/src/chips_input.dart @@ -10,32 +10,46 @@ import 'text_cursor.dart'; typedef ChipsInputSuggestions = FutureOr> Function(String query); typedef ChipSelected = void Function(T data, bool selected); typedef ChipsBuilder = Widget Function( - BuildContext context, ChipsInputState state, T data); + BuildContext context, + ChipsInputState state, + int index, + T data, + ); +typedef SuggestionBuilder = Widget Function( + BuildContext context, + ChipsInputState state, + List data, + ); +typedef OnSubmitCallback = T? Function(String txt); +typedef OnChipDeleted = void Function(List chips, int index); const kObjectReplacementChar = 0xFFFD; extension on TextEditingValue { String get normalCharactersText => String.fromCharCodes( - text.codeUnits.where((ch) => ch != kObjectReplacementChar), - ); + text.codeUnits.where((ch) => ch != kObjectReplacementChar), + ); - List get replacementCharacters => text.codeUnits - .where((ch) => ch == kObjectReplacementChar) - .toList(growable: false); + List get replacementCharacters => text.codeUnits.where((ch) => ch == kObjectReplacementChar).toList(growable: false); int get replacementCharactersCount => replacementCharacters.length; } class ChipsInput extends StatefulWidget { const ChipsInput({ - Key? key, + super.key, this.initialValue = const [], this.decoration = const InputDecoration(), this.enabled = true, required this.chipBuilder, - required this.suggestionBuilder, - required this.findSuggestions, + this.findSuggestions, + this.onSubmit, + this.submitKeys = const [LogicalKeyboardKey.tab], + this.existingValues = const [], required this.onChanged, + this.suggestionBuilder, + this.suggestionsBuilder, + this.onChipDeleted, this.maxChips, this.textStyle, this.suggestionsBoxMaxHeight, @@ -51,16 +65,20 @@ class ChipsInput extends StatefulWidget { this.allowChipEditing = false, this.focusNode, this.initialSuggestions, - }) : assert(maxChips == null || initialValue.length <= maxChips), - super(key: key); + }) : assert(maxChips == null || initialValue.length <= maxChips); final InputDecoration decoration; final TextStyle? textStyle; final bool enabled; - final ChipsInputSuggestions findSuggestions; + final ChipsInputSuggestions? findSuggestions; + final List existingValues; + final OnSubmitCallback? onSubmit; + final List submitKeys; final ValueChanged> onChanged; final ChipsBuilder chipBuilder; - final ChipsBuilder suggestionBuilder; + final ChipsBuilder? suggestionBuilder; + final SuggestionBuilder? suggestionsBuilder; + final OnChipDeleted? onChipDeleted; final List initialValue; final int? maxChips; final double? suggestionsBoxMaxHeight; @@ -84,12 +102,10 @@ class ChipsInput extends StatefulWidget { ChipsInputState createState() => ChipsInputState(); } -class ChipsInputState extends State> - implements TextInputClient { - Set _chips = {}; +class ChipsInputState extends State> implements TextInputClient { + List _chips = []; List? _suggestions; - final StreamController?> _suggestionsStreamController = - StreamController?>.broadcast(); + final StreamController?> _suggestionsStreamController = StreamController?>.broadcast(); int _searchId = 0; TextEditingValue _value = const TextEditingValue(); TextInputConnection? _textInputConnection; @@ -97,25 +113,31 @@ class ChipsInputState extends State> final _layerLink = LayerLink(); final Map _enteredTexts = {}; - 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, - ); + int? _viewId; + + TextInputConfiguration get textInputConfiguration { + final viewId = View.of(context).viewId; + _viewId = viewId; + + return TextInputConfiguration( + viewId: viewId, + 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; + bool get _hasInputConnection => _textInputConnection != null && _textInputConnection!.attached; - bool get _hasReachedMaxChips => - widget.maxChips != null && _chips.length >= widget.maxChips!; + bool get _hasReachedMaxChips => widget.maxChips != null && _chips.length >= widget.maxChips!; FocusNode? _focusNode; - FocusNode get _effectiveFocusNode => - widget.focusNode ?? (_focusNode ??= FocusNode()); + + FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); late FocusAttachment _nodeAttachment; RenderBox? get renderBox => context.findRenderObject() as RenderBox?; @@ -126,9 +148,7 @@ class ChipsInputState extends State> void initState() { super.initState(); _chips.addAll(widget.initialValue); - _suggestions = widget.initialSuggestions - ?.where((r) => !_chips.contains(r)) - .toList(growable: false); + _suggestions = widget.initialSuggestions?.where((r) => !_chips.contains(r)).toList(growable: false); _suggestionsBoxController = SuggestionsBoxController(context); _effectiveFocusNode.addListener(_handleFocusChanged); @@ -141,6 +161,10 @@ class ChipsInputState extends State> FocusScope.of(context).autofocus(_effectiveFocusNode); } }); + + _effectiveFocusNode.addListener(() { + if (_effectiveFocusNode.hasFocus) _onSearchChanged(_value.text); + }); } @override @@ -155,6 +179,7 @@ class ChipsInputState extends State> void _handleFocusChanged() { if (_effectiveFocusNode.hasFocus) { + _updateViewIdIfNeeded(); _openInputConnection(); _suggestionsBoxController.open(); } else { @@ -176,96 +201,138 @@ class ChipsInputState extends State> } } + Widget _defaultSuggestionsBuilder(BuildContext ctx, List suggestions) { + return Material( + color: Theme.of(ctx).colorScheme.surfaceContainerHighest, + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + controller: ScrollController(), + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Wrap( + spacing: 2, + runSpacing: 2, + children: [ + if (widget.suggestionBuilder == null) ...suggestions.map((suggestion) => _defaultSuggestionBuilder(suggestion)), + if (widget.suggestionBuilder != null) + ...suggestions.map( + (suggestion) { + final index = suggestions.indexOf(suggestion); + return widget.suggestionBuilder!.call(ctx, this, index, suggestion); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _defaultSuggestionBuilder(T suggestion) { + return InputChip( + label: Text('$suggestion'), + onPressed: () => selectSuggestion(suggestion), + ); + } + void _initOverlayEntry() { + // Skip if suggestions are null + if (widget.findSuggestions == null) return; + _suggestionsBoxController.overlayEntry = OverlayEntry( builder: (context) { final size = renderBox!.size; final renderBoxOffset = renderBox!.localToGlobal(Offset.zero); - final topAvailableSpace = renderBoxOffset.dy; final mq = MediaQuery.of(context); - final bottomAvailableSpace = mq.size.height - - mq.viewInsets.bottom - - renderBoxOffset.dy - - size.height; - var suggestionBoxHeight = max(topAvailableSpace, bottomAvailableSpace); - if (null != widget.suggestionsBoxMaxHeight) { - suggestionBoxHeight = - min(suggestionBoxHeight, widget.suggestionsBoxMaxHeight!); + final bottomAvailableSpace = mq.size.height - mq.viewInsets.bottom - renderBoxOffset.dy - size.height; + var suggestionBoxHeight = max(renderBoxOffset.dy, bottomAvailableSpace); + + if (widget.suggestionsBoxMaxHeight != null) { + suggestionBoxHeight = min(suggestionBoxHeight, widget.suggestionsBoxMaxHeight!); } - final showTop = topAvailableSpace > bottomAvailableSpace; - // print("showTop: $showTop" ); - final compositedTransformFollowerOffset = - showTop ? Offset(0, -size.height) : Offset.zero; + + final showTop = renderBoxOffset.dy > bottomAvailableSpace; return StreamBuilder?>( stream: _suggestionsStreamController.stream, initialData: _suggestions, builder: (context, snapshot) { if (snapshot.hasData && snapshot.data!.isNotEmpty) { - final suggestionsListView = Material( - elevation: 0, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: suggestionBoxHeight, - ), - child: ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: snapshot.data!.length, - itemBuilder: (BuildContext context, int index) { - return _suggestions != null - ? widget.suggestionBuilder( - context, - this, - _suggestions![index] as T, - ) - : Container(); - }, - ), - ), + final suggestionsListView = ConstrainedBox( + constraints: BoxConstraints(maxHeight: suggestionBoxHeight), + child: widget.suggestionsBuilder != null ? widget.suggestionsBuilder!(context, this, _suggestions!.cast()) : _defaultSuggestionsBuilder(context, _suggestions!.cast()), ); + return Positioned( width: size.width, child: CompositedTransformFollower( link: _layerLink, showWhenUnlinked: false, - offset: compositedTransformFollowerOffset, - child: !showTop - ? suggestionsListView - : FractionalTranslation( - translation: const Offset(0, -1), - child: suggestionsListView, - ), + offset: showTop ? Offset(0, -size.height) : Offset.zero, + child: Container( + color: Colors.white, + child: !showTop + ? suggestionsListView + : FractionalTranslation( + translation: const Offset(0, -1), + child: suggestionsListView, + ), + ), ), ); } - return Container(); + return Container(); // Return empty container if no suggestions }, ); }, ); } + void _onSearchChanged(String value) async { + // Skip if no suggestions function + if (widget.findSuggestions == null) return; + + final localId = ++_searchId; + final results = await widget.findSuggestions!(value); + if (_searchId == localId && mounted) { + setState(() { + _suggestions = results.where((r) => !_chips.contains(r)).toList(growable: false); + }); + } + + _suggestionsStreamController.add(_suggestions ?? []); + + if (!_suggestionsBoxController.isOpened && !_hasReachedMaxChips) { + _suggestionsBoxController.open(); + } + } + void selectSuggestion(T data) { if (!_hasReachedMaxChips) { - setState(() => _chips = _chips..add(data)); - if (widget.allowChipEditing) { - final enteredText = _value.normalCharactersText; - if (enteredText.isNotEmpty) _enteredTexts[data] = enteredText; - } - _updateTextInputState(replaceText: true); - setState(() => _suggestions = null); + setState(() { + _chips.add(data); + _updateTextInputState(replaceText: true); + _suggestions = null; + }); + _suggestionsStreamController.add(_suggestions); - if (_hasReachedMaxChips) _suggestionsBoxController.close(); + _suggestionsBoxController.close(); // Close suggestions box + widget.onChanged(_chips.toList(growable: false)); - } else { - _suggestionsBoxController.close(); } } - void deleteChip(T data) { + void deleteChip(int index) { if (widget.enabled) { - setState(() => _chips.remove(data)); + final data = _chips[index]; + setState(() => _chips.removeAt(index)); if (_enteredTexts.containsKey(data)) _enteredTexts.remove(data); _updateTextInputState(); widget.onChanged(_chips.toList(growable: false)); @@ -281,29 +348,7 @@ class ChipsInputState extends State> _textInputConnection?.show(); } - _scrollToVisible(); - } - - void _scrollToVisible() { - Future.delayed(const Duration(milliseconds: 300), () { - WidgetsBinding.instance.addPostFrameCallback((_) async { - final renderBox = context.findRenderObject() as RenderBox; - await Scrollable.of(context)?.position.ensureVisible(renderBox); - }); - }); - } - - void _onSearchChanged(String value) async { - final localId = ++_searchId; - final results = await widget.findSuggestions(value); - if (_searchId == localId && mounted) { - setState(() => _suggestions = - results.where((r) => !_chips.contains(r)).toList(growable: false)); - } - _suggestionsStreamController.add(_suggestions ?? []); - if (!_suggestionsBoxController.isOpened && !_hasReachedMaxChips) { - _suggestionsBoxController.open(); - } + // _scrollToVisible(); } void _closeInputConnectionIfNeeded() { @@ -320,11 +365,9 @@ class ChipsInputState extends State> final oldTextEditingValue = _value; if (value.text != oldTextEditingValue.text) { setState(() => _value = value); - if (value.replacementCharactersCount < - oldTextEditingValue.replacementCharactersCount) { + if (value.replacementCharactersCount < oldTextEditingValue.replacementCharactersCount) { final removedChip = _chips.last; - setState(() => - _chips = Set.of(_chips.take(value.replacementCharactersCount))); + setState(() => _chips = List.of(_chips.take(value.replacementCharactersCount))); widget.onChanged(_chips.toList(growable: false)); String? putText = ''; if (widget.allowChipEditing && _enteredTexts.containsKey(removedChip)) { @@ -339,47 +382,50 @@ class ChipsInputState extends State> } } - void _updateTextInputState({replaceText = false, putText = ''}) { - if (replaceText || putText != '') { - final updatedText = - String.fromCharCodes(_chips.map((_) => kObjectReplacementChar)) + - (replaceText ? '' : _value.normalCharactersText) + - putText; - setState(() => _value = _value.copyWith( - text: updatedText, - selection: TextSelection.collapsed(offset: updatedText.length), - //composing: TextRange(start: 0, end: text.length), - composing: TextRange.empty, - )); + void _updateTextInputState({bool replaceText = false, String putText = ''}) { + if (replaceText || putText.isNotEmpty) { + final updatedText = "${replaceText ? '' : _value.normalCharactersText}$putText"; + setState( + () => _value = _value.copyWith( + text: updatedText, + selection: TextSelection.collapsed(offset: updatedText.length), + // composing: TextRange(start: 0, end: updatedText.length), + composing: TextRange.empty, + ), + ); + } + if (_hasInputConnection) { + _textInputConnection!.setEditingState(_value); } - _closeInputConnectionIfNeeded(); //Hack for #34 (https://github.com/danvick/flutter_chips_input/issues/34#issuecomment-684505282). TODO: Find permanent fix - _textInputConnection ??= TextInput.attach(this, textInputConfiguration); - _textInputConnection?.setEditingState(_value); } @override void performAction(TextInputAction action) { switch (action) { - case TextInputAction.done: case TextInputAction.go: + case TextInputAction.done: case TextInputAction.send: + case TextInputAction.next: case TextInputAction.search: - if (_suggestions?.isNotEmpty ?? false) { - selectSuggestion(_suggestions!.first as T); - } else { - _effectiveFocusNode.unfocus(); - } + case TextInputAction.newline: + case TextInputAction.continueAction: + _performAction(); break; default: - _effectiveFocusNode.unfocus(); break; } } + void _performAction() { + final value = widget.onSubmit?.call(_value.text); + if (value != null) selectSuggestion(value); + } + @override void didChangeDependencies() { super.didChangeDependencies(); _effectiveFocusNode.canRequestFocus = _canRequestFocus; + _updateViewIdIfNeeded(); } @override @@ -391,6 +437,20 @@ class ChipsInputState extends State> void didUpdateWidget(covariant ChipsInput oldWidget) { super.didUpdateWidget(oldWidget); _effectiveFocusNode.canRequestFocus = _canRequestFocus; + _updateViewIdIfNeeded(); + } + + void _updateViewIdIfNeeded() { + if (!_hasInputConnection) { + _viewId = View.of(context).viewId; + return; + } + + final newViewId = View.of(context).viewId; + if (newViewId != _viewId) { + _viewId = newViewId; + _textInputConnection?.updateConfig(textInputConfiguration); + } } @override @@ -415,9 +475,12 @@ class ChipsInputState extends State> @override Widget build(BuildContext context) { _nodeAttachment.reparent(); - final chipsChildren = _chips - .map((data) => widget.chipBuilder(context, this, data)) - .toList(); + final chipsChildren = _chips.map( + (chip) { + final index = _chips.indexOf(chip); + return widget.chipBuilder(context, this, index, chip); + }, + ).toList(); final theme = Theme.of(context); @@ -434,8 +497,7 @@ class ChipsInputState extends State> _value.normalCharactersText, maxLines: 1, overflow: widget.textOverflow, - style: widget.textStyle ?? - theme.textTheme.subtitle1!.copyWith(height: 1.5), + style: widget.textStyle?.copyWith(fontSize: 13.0) ?? theme.textTheme.titleMedium?.copyWith(height: 1.2), ), ), Flexible( @@ -447,49 +509,106 @@ class ChipsInputState extends State> ), ); - return NotificationListener( - onNotification: (SizeChangedLayoutNotification val) { - WidgetsBinding.instance.addPostFrameCallback((_) async { - _suggestionsBoxController.overlayEntry?.markNeedsBuild(); - }); - return true; + return KeyboardListener( + focusNode: _effectiveFocusNode, + onKeyEvent: (event) { + final str = _value.text; + + // Handle KeyDownEvent to avoid duplicate events + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.backspace) { + if (str.isNotEmpty) { + final sd = str.substring(0, str.length - 1); + + /// Make sure to also update cursor position using + /// the TextSelection.collapsed. + updateEditingValue( + TextEditingValue( + text: sd, + selection: TextSelection.collapsed( + offset: sd.length, + ), + ), + ); + } else if (_chips.isNotEmpty) { + // Remove the last chip when backspace is pressed with an empty input + deleteChip(_chips.length - 1); + } + } + + for(final key in widget.submitKeys) { + if(event.physicalKey == key || event.logicalKey == key) { + _performAction(); + break; + } + } + } }, - child: SizeChangedLayoutNotifier( - child: Column( - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - requestKeyboard(); - }, - child: InputDecorator( - decoration: widget.decoration, - isFocused: _effectiveFocusNode.hasFocus, - isEmpty: _value.text.isEmpty && _chips.isEmpty, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 4.0, - runSpacing: 4.0, - children: chipsChildren, + child: NotificationListener( + onNotification: (SizeChangedLayoutNotification val) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + _suggestionsBoxController.overlayEntry?.markNeedsBuild(); + }); + return true; + }, + child: SizeChangedLayoutNotifier( + child: Column( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => requestKeyboard(), + child: InputDecorator( + decoration: widget.decoration.copyWith( + contentPadding: const EdgeInsets.symmetric(vertical: 14.0, horizontal: 10), // Reduce vertical padding + ), + isFocused: _effectiveFocusNode.hasFocus, + isEmpty: _value.text.isEmpty && _chips.isEmpty, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4.0, + runSpacing: 4.0, + children: chipsChildren, + ), ), ), - ), - CompositedTransformTarget( - link: _layerLink, - child: Container(), - ), - ], + CompositedTransformTarget( + link: _layerLink, + child: Container(), + ), + ], + ), ), ), ); } @override - void showToolbar() {} + void insertTextPlaceholder(Size size) { + // TODO: implement insertTextPlaceholder + } @override - void insertTextPlaceholder(Size size) {} + void removeTextPlaceholder() { + // TODO: implement removeTextPlaceholder + } @override - void removeTextPlaceholder() {} + void showToolbar() { + // TODO: implement showToolbar + } + + @override + void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) { + // TODO: implement didChangeInputControl + } + + @override + void performSelector(String selectorName) { + // TODO: implement performSelector + } + + @override + void insertContent(KeyboardInsertedContent content) { + // TODO: implement insertContent + } } diff --git a/lib/src/suggestions_box_controller.dart b/lib/src/suggestions_box_controller.dart index 127c7452..157d55eb 100644 --- a/lib/src/suggestions_box_controller.dart +++ b/lib/src/suggestions_box_controller.dart @@ -14,7 +14,7 @@ class SuggestionsBoxController { void open() { if (_isOpened) return; assert(overlayEntry != null); - Overlay.of(context)!.insert(overlayEntry!); + Overlay.of(context).insert(overlayEntry!); _isOpened = true; } diff --git a/lib/src/text_cursor.dart b/lib/src/text_cursor.dart index e00da661..78c06c65 100644 --- a/lib/src/text_cursor.dart +++ b/lib/src/text_cursor.dart @@ -1,22 +1,21 @@ import 'dart:async'; - import 'package:flutter/material.dart'; class TextCursor extends StatefulWidget { const TextCursor({ - Key? key, + super.key, this.duration = const Duration(milliseconds: 500), this.resumed = false, - }) : super(key: key); + }); final Duration duration; final bool resumed; @override - _TextCursorState createState() => _TextCursorState(); + TextCursorState createState() => TextCursorState(); } -class _TextCursorState extends State +class TextCursorState extends State with SingleTickerProviderStateMixin { bool _displayed = false; late Timer _timer; diff --git a/pubspec.yaml b/pubspec.yaml index b33d3aa6..1e599abd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ version: 2.0.0 homepage: https://github.com/danvick/flutter_chips_input environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.17.0 <3.0.0" dependencies: flutter: @@ -20,32 +20,32 @@ dev_dependencies: # The following section is specific to Flutter. flutter: - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.io/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.io/assets-and-images/#resolution-aware. - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.io/custom-fonts/#from-packages +# To add assets to your package, add an assets section, like this: +# assets: +# - images/a_dot_burr.jpeg +# - images/a_dot_ham.jpeg +# +# For details regarding assets in packages, see +# https://flutter.io/assets-and-images/#from-packages +# +# An image asset can refer to one or more resolution-specific "variants", see +# https://flutter.io/assets-and-images/#resolution-aware. +# To add custom fonts to your package, add a fonts section here, +# in this "flutter" section. Each entry in this list should have a +# "family" key with the font family name, and a "fonts" key with a +# list giving the asset and other descriptors for the font. For +# example: +# fonts: +# - family: Schyler +# fonts: +# - asset: fonts/Schyler-Regular.ttf +# - asset: fonts/Schyler-Italic.ttf +# style: italic +# - family: Trajan Pro +# fonts: +# - asset: fonts/TrajanPro.ttf +# - asset: fonts/TrajanPro_Bold.ttf +# weight: 700 +# +# For details regarding fonts in packages, see +# https://flutter.io/custom-fonts/#from-packages diff --git a/test/flutter_chips_input_test.dart b/test/flutter_chips_input_test.dart index 5b38fe09..07507b68 100644 --- a/test/flutter_chips_input_test.dart +++ b/test/flutter_chips_input_test.dart @@ -19,21 +19,21 @@ void main() { maxChips: 3, findSuggestions: (String query) => query.isNotEmpty ? allContacts - .where((_) => _.toLowerCase().contains(query.toLowerCase())) - .toList() + .where((_) => _.toLowerCase().contains(query.toLowerCase())) + .toList() : const [], onChanged: (contacts) { debugPrint(contacts.toString()); }, - chipBuilder: (context, state, contact) { + chipBuilder: (context, state, index, contact) { return InputChip( key: ValueKey(contact), label: Text(contact), - onDeleted: () => state.deleteChip(contact), + onDeleted: () => state.deleteChip(index), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); }, - suggestionBuilder: (context, state, contact) { + suggestionBuilder: (context, state, index, contact) { return ListTile( key: ValueKey(contact), title: Text(contact),