How can I build a chip input field in Flutter?
Asked Answered
X

6

16

A chip input field using Material

enter image description here

Xuthus answered 3/9, 2018 at 19:19 Comment(1)
@GünterZöchbauer can you please elaborate with an answer? Linking to the Chip class is neither helpful nor insightful.Sihon
I
16

You can find an implementation of a Chip Input Field type widget here:

Latest: https://gist.github.com/slightfoot/c6c0f1f1baca326a389a9aec47886ad6

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// See: https://twitter.com/shakil807/status/1042127387515858949
// https://github.com/pchmn/MaterialChipsInput/tree/master/library/src/main/java/com/pchmn/materialchips
// https://github.com/BelooS/ChipsLayoutManager

void main() => runApp(ChipsDemoApp());

class ChipsDemoApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primaryColor: Colors.indigo,
        accentColor: Colors.pink,
      ),
      home: DemoScreen(),
    );
  }
}

class DemoScreen extends StatefulWidget {
  @override
  _DemoScreenState createState() => _DemoScreenState();
}

class _DemoScreenState extends State<DemoScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Material Chips Input'),
      ),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: TextField(
              decoration: const InputDecoration(hintText: 'normal'),
            ),
          ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: ChipsInput<AppProfile>(
                decoration: InputDecoration(prefixIcon: Icon(Icons.search), hintText: 'Profile search'),
                findSuggestions: _findSuggestions,
                onChanged: _onChanged,
                chipBuilder: (BuildContext context, ChipsInputState<AppProfile> state, AppProfile profile) {
                  return InputChip(
                    key: ObjectKey(profile),
                    label: Text(profile.name),
                    avatar: CircleAvatar(
                      backgroundImage: NetworkImage(profile.imageUrl),
                    ),
                    onDeleted: () => state.deleteChip(profile),
                    onSelected: (_) => _onChipTapped(profile),
                    materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
                  );
                },
                suggestionBuilder: (BuildContext context, ChipsInputState<AppProfile> state, AppProfile profile) {
                  return ListTile(
                    key: ObjectKey(profile),
                    leading: CircleAvatar(
                      backgroundImage: NetworkImage(profile.imageUrl),
                    ),
                    title: Text(profile.name),
                    subtitle: Text(profile.email),
                    onTap: () => state.selectSuggestion(profile),
                  );
                },
              ),
            ),
          ),
        ],
      ),
    );
  }

  void _onChipTapped(AppProfile profile) {
    print('$profile');
  }

  void _onChanged(List<AppProfile> data) {
    print('onChanged $data');
  }

  Future<List<AppProfile>> _findSuggestions(String query) async {
    if (query.length != 0) {
      return mockResults.where((profile) {
        return profile.name.contains(query) || profile.email.contains(query);
      }).toList(growable: false);
    } else {
      return const <AppProfile>[];
    }
  }
}

// -------------------------------------------------

const mockResults = <AppProfile>[
  AppProfile('Stock Man', '[email protected]', 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'),
  AppProfile('Paul', '[email protected]', 'https://mbtskoudsalg.com/images/person-stock-image-png.png'),
  AppProfile('Fred', '[email protected]',
      'https://media.istockphoto.com/photos/feeling-great-about-my-corporate-choices-picture-id507296326'),
  AppProfile('Bera', '[email protected]',
      'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('John', '[email protected]',
      'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('Thomas', '[email protected]',
      'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('Norbert', '[email protected]',
      'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('Marina', '[email protected]',
      'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
];

class AppProfile {
  final String name;
  final String email;
  final String imageUrl;

  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;

  @override
  int get hashCode => name.hashCode;

  @override
  String toString() {
    return 'Profile{$name}';
  }
}

// -------------------------------------------------

typedef ChipsInputSuggestions<T> = Future<List<T>> Function(String query);
typedef ChipSelected<T> = void Function(T data, bool selected);
typedef ChipsBuilder<T> = Widget Function(BuildContext context, ChipsInputState<T> state, T data);

class ChipsInput<T> extends StatefulWidget {
  const ChipsInput({
    Key key,
    this.decoration = const InputDecoration(),
    @required this.chipBuilder,
    @required this.suggestionBuilder,
    @required this.findSuggestions,
    @required this.onChanged,
    this.onChipTapped,
  }) : super(key: key);

  final InputDecoration decoration;
  final ChipsInputSuggestions findSuggestions;
  final ValueChanged<List<T>> onChanged;
  final ValueChanged<T> onChipTapped;
  final ChipsBuilder<T> chipBuilder;
  final ChipsBuilder<T> suggestionBuilder;

  @override
  ChipsInputState<T> createState() => ChipsInputState<T>();
}

class ChipsInputState<T> extends State<ChipsInput<T>> implements TextInputClient {
  static const kObjectReplacementChar = 0xFFFC;

  Set<T> _chips = Set<T>();
  List<T> _suggestions;
  int _searchId = 0;

  FocusNode _focusNode;
  TextEditingValue _value = TextEditingValue();
  TextInputConnection _connection;

  String get text => String.fromCharCodes(
        _value.text.codeUnits.where((ch) => ch != kObjectReplacementChar),
      );

  bool get _hasInputConnection => _connection != null && _connection.attached;

  void requestKeyboard() {
    if (_focusNode.hasFocus) {
      _openInputConnection();
    } else {
      FocusScope.of(context).requestFocus(_focusNode);
    }
  }

  void selectSuggestion(T data) {
    setState(() {
      _chips.add(data);
      _updateTextInputState();
      _suggestions = null;
    });
    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 = TextInput.attach(this, TextInputConfiguration());
      _connection.setEditingState(_value);
    }
    _connection.show();
  }

  void _closeInputConnectionIfNeeded() {
    if (_hasInputConnection) {
      _connection.close();
      _connection = null;
    }
  }

  @override
  Widget build(BuildContext context) {
    var chipsChildren = _chips
        .map<Widget>(
          (data) => widget.chipBuilder(context, this, data),
        )
        .toList();

    final theme = Theme.of(context);

    chipsChildren.add(
      Container(
        height: 32.0,
        child: Row(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Text(
              text,
              style: theme.textTheme.subhead.copyWith(
                height: 1.5,
              ),
            ),
            _TextCaret(
              resumed: _focusNode.hasFocus,
            ),
          ],
        ),
      ),
    );

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      //mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: requestKeyboard,
          child: InputDecorator(
            decoration: widget.decoration,
            isFocused: _focusNode.hasFocus,
            isEmpty: _value.text.length == 0,
            child: Wrap(
              children: chipsChildren,
              spacing: 4.0,
              runSpacing: 4.0,
            ),
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: _suggestions?.length ?? 0,
            itemBuilder: (BuildContext context, int index) {
              return widget.suggestionBuilder(context, this, _suggestions[index]);
            },
          ),
        ),
      ],
    );
  }

  @override
  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;
  }

  @override
  void performAction(TextInputAction action) {
    _focusNode.unfocus();
  }

  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({
    Key key,
    this.duration = const Duration(milliseconds: 500),
    this.resumed = false,
  }) : super(key: key);

  final Duration duration;
  final bool resumed;

  @override
  _TextCursorState createState() => _TextCursorState();
}

class _TextCursorState extends State<_TextCaret> 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() {
    _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,
        ),
      ),
    );
  }
}
Indoiranian answered 19/9, 2018 at 23:7 Comment(6)
This is really great. However, have you noticed that it crashes in iOS when you try to delete a chip with the backspace key? It's driving me crazy, I don't even know why. It only happens in iOS, in Android works as expected.Traction
It would be nice if this was an actual package and was enhanced a bit to include debouncing.Josey
But what if I have only List array and doing the same. Currently your chipBuilder accepts only class object like AppProfile('Stock Man', '[email protected]', 'd2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'),Bounden
@Bounden it doesn't make a difference. It's generic. You can return whatever type you want. If you're saying you have a list to select from then that's what you return from findSuggestions.Indoiranian
Ok I have an array of objects so can we apply to chipbuilder to array as it is? I don't want to pass AppProfile profile but I only wants to apply List at 3rd parameter in suggestionbuilder and chipbuilder. I am thinking at dynamic way. Currently your mockresult is static. I am new in flutter.Bounden
For Ex: I have List arr=[{name: "abc"}, {name: "xyz"},{name: "pqr"}]; so how can I show name in chip as well as chipbuilder and suggestionbuilder?Bounden
O
20

You can use package flutter_chips_input
https://pub.dartlang.org/packages/flutter_chips_input
Just want to provide another option.
You can check example below:
enter image description here

ChipsInput(
initialValue: [
    AppProfile('John Doe', '[email protected]', 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg')
],
decoration: InputDecoration(
    labelText: "Select People",
),
maxChips: 3,
findSuggestions: (String query) {
    if (query.length != 0) {
        var lowercaseQuery = query.toLowerCase();
        return mockResults.where((profile) {
            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)));
    } else {
        return const <AppProfile>[];
    }
},
onChanged: (data) {
    print(data);
},
chipBuilder: (context, state, profile) {
    return InputChip(
        key: ObjectKey(profile),
        label: Text(profile.name),
        avatar: CircleAvatar(
            backgroundImage: NetworkImage(profile.imageUrl),
        ),
        onDeleted: () => state.deleteChip(profile),
        materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
    );
},
suggestionBuilder: (context, state, profile) {
    return ListTile(
        key: ObjectKey(profile),
        leading: CircleAvatar(
            backgroundImage: NetworkImage(profile.imageUrl),
        ),
        title: Text(profile.name),
        subtitle: Text(profile.email),
        onTap: () => state.selectSuggestion(profile),
    );
},

)

Observation answered 2/5, 2019 at 0:55 Comment(5)
How can i search from server and show suggestions below?Veiled
@SachinTanpure, could you post your reproduce code to a new question? include json string of server response. thanks.Observation
I have simple requirement, i have to show textfield. When i type text inside textfield, api will hit which respond json, based on json i have show dropdown list below textfield. When user select item in dropdown, selected item is show as chip inside textfield.Veiled
Hey @Observation I was looking for this only but I don't want suggestions or I don't need any list, I just want that when user gives a input and after that a space then a chip is created just like it is in gmail. Please tell how to do thatInfinitude
in my case cursor is not shown, any idea?Barringer
I
16

You can find an implementation of a Chip Input Field type widget here:

Latest: https://gist.github.com/slightfoot/c6c0f1f1baca326a389a9aec47886ad6

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// See: https://twitter.com/shakil807/status/1042127387515858949
// https://github.com/pchmn/MaterialChipsInput/tree/master/library/src/main/java/com/pchmn/materialchips
// https://github.com/BelooS/ChipsLayoutManager

void main() => runApp(ChipsDemoApp());

class ChipsDemoApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primaryColor: Colors.indigo,
        accentColor: Colors.pink,
      ),
      home: DemoScreen(),
    );
  }
}

class DemoScreen extends StatefulWidget {
  @override
  _DemoScreenState createState() => _DemoScreenState();
}

class _DemoScreenState extends State<DemoScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Material Chips Input'),
      ),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: TextField(
              decoration: const InputDecoration(hintText: 'normal'),
            ),
          ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: ChipsInput<AppProfile>(
                decoration: InputDecoration(prefixIcon: Icon(Icons.search), hintText: 'Profile search'),
                findSuggestions: _findSuggestions,
                onChanged: _onChanged,
                chipBuilder: (BuildContext context, ChipsInputState<AppProfile> state, AppProfile profile) {
                  return InputChip(
                    key: ObjectKey(profile),
                    label: Text(profile.name),
                    avatar: CircleAvatar(
                      backgroundImage: NetworkImage(profile.imageUrl),
                    ),
                    onDeleted: () => state.deleteChip(profile),
                    onSelected: (_) => _onChipTapped(profile),
                    materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
                  );
                },
                suggestionBuilder: (BuildContext context, ChipsInputState<AppProfile> state, AppProfile profile) {
                  return ListTile(
                    key: ObjectKey(profile),
                    leading: CircleAvatar(
                      backgroundImage: NetworkImage(profile.imageUrl),
                    ),
                    title: Text(profile.name),
                    subtitle: Text(profile.email),
                    onTap: () => state.selectSuggestion(profile),
                  );
                },
              ),
            ),
          ),
        ],
      ),
    );
  }

  void _onChipTapped(AppProfile profile) {
    print('$profile');
  }

  void _onChanged(List<AppProfile> data) {
    print('onChanged $data');
  }

  Future<List<AppProfile>> _findSuggestions(String query) async {
    if (query.length != 0) {
      return mockResults.where((profile) {
        return profile.name.contains(query) || profile.email.contains(query);
      }).toList(growable: false);
    } else {
      return const <AppProfile>[];
    }
  }
}

// -------------------------------------------------

const mockResults = <AppProfile>[
  AppProfile('Stock Man', '[email protected]', 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'),
  AppProfile('Paul', '[email protected]', 'https://mbtskoudsalg.com/images/person-stock-image-png.png'),
  AppProfile('Fred', '[email protected]',
      'https://media.istockphoto.com/photos/feeling-great-about-my-corporate-choices-picture-id507296326'),
  AppProfile('Bera', '[email protected]',
      'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('John', '[email protected]',
      'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('Thomas', '[email protected]',
      'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('Norbert', '[email protected]',
      'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('Marina', '[email protected]',
      'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
];

class AppProfile {
  final String name;
  final String email;
  final String imageUrl;

  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;

  @override
  int get hashCode => name.hashCode;

  @override
  String toString() {
    return 'Profile{$name}';
  }
}

// -------------------------------------------------

typedef ChipsInputSuggestions<T> = Future<List<T>> Function(String query);
typedef ChipSelected<T> = void Function(T data, bool selected);
typedef ChipsBuilder<T> = Widget Function(BuildContext context, ChipsInputState<T> state, T data);

class ChipsInput<T> extends StatefulWidget {
  const ChipsInput({
    Key key,
    this.decoration = const InputDecoration(),
    @required this.chipBuilder,
    @required this.suggestionBuilder,
    @required this.findSuggestions,
    @required this.onChanged,
    this.onChipTapped,
  }) : super(key: key);

  final InputDecoration decoration;
  final ChipsInputSuggestions findSuggestions;
  final ValueChanged<List<T>> onChanged;
  final ValueChanged<T> onChipTapped;
  final ChipsBuilder<T> chipBuilder;
  final ChipsBuilder<T> suggestionBuilder;

  @override
  ChipsInputState<T> createState() => ChipsInputState<T>();
}

class ChipsInputState<T> extends State<ChipsInput<T>> implements TextInputClient {
  static const kObjectReplacementChar = 0xFFFC;

  Set<T> _chips = Set<T>();
  List<T> _suggestions;
  int _searchId = 0;

  FocusNode _focusNode;
  TextEditingValue _value = TextEditingValue();
  TextInputConnection _connection;

  String get text => String.fromCharCodes(
        _value.text.codeUnits.where((ch) => ch != kObjectReplacementChar),
      );

  bool get _hasInputConnection => _connection != null && _connection.attached;

  void requestKeyboard() {
    if (_focusNode.hasFocus) {
      _openInputConnection();
    } else {
      FocusScope.of(context).requestFocus(_focusNode);
    }
  }

  void selectSuggestion(T data) {
    setState(() {
      _chips.add(data);
      _updateTextInputState();
      _suggestions = null;
    });
    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 = TextInput.attach(this, TextInputConfiguration());
      _connection.setEditingState(_value);
    }
    _connection.show();
  }

  void _closeInputConnectionIfNeeded() {
    if (_hasInputConnection) {
      _connection.close();
      _connection = null;
    }
  }

  @override
  Widget build(BuildContext context) {
    var chipsChildren = _chips
        .map<Widget>(
          (data) => widget.chipBuilder(context, this, data),
        )
        .toList();

    final theme = Theme.of(context);

    chipsChildren.add(
      Container(
        height: 32.0,
        child: Row(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Text(
              text,
              style: theme.textTheme.subhead.copyWith(
                height: 1.5,
              ),
            ),
            _TextCaret(
              resumed: _focusNode.hasFocus,
            ),
          ],
        ),
      ),
    );

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      //mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: requestKeyboard,
          child: InputDecorator(
            decoration: widget.decoration,
            isFocused: _focusNode.hasFocus,
            isEmpty: _value.text.length == 0,
            child: Wrap(
              children: chipsChildren,
              spacing: 4.0,
              runSpacing: 4.0,
            ),
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: _suggestions?.length ?? 0,
            itemBuilder: (BuildContext context, int index) {
              return widget.suggestionBuilder(context, this, _suggestions[index]);
            },
          ),
        ),
      ],
    );
  }

  @override
  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;
  }

  @override
  void performAction(TextInputAction action) {
    _focusNode.unfocus();
  }

  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({
    Key key,
    this.duration = const Duration(milliseconds: 500),
    this.resumed = false,
  }) : super(key: key);

  final Duration duration;
  final bool resumed;

  @override
  _TextCursorState createState() => _TextCursorState();
}

class _TextCursorState extends State<_TextCaret> 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() {
    _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,
        ),
      ),
    );
  }
}
Indoiranian answered 19/9, 2018 at 23:7 Comment(6)
This is really great. However, have you noticed that it crashes in iOS when you try to delete a chip with the backspace key? It's driving me crazy, I don't even know why. It only happens in iOS, in Android works as expected.Traction
It would be nice if this was an actual package and was enhanced a bit to include debouncing.Josey
But what if I have only List array and doing the same. Currently your chipBuilder accepts only class object like AppProfile('Stock Man', '[email protected]', 'd2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'),Bounden
@Bounden it doesn't make a difference. It's generic. You can return whatever type you want. If you're saying you have a list to select from then that's what you return from findSuggestions.Indoiranian
Ok I have an array of objects so can we apply to chipbuilder to array as it is? I don't want to pass AppProfile profile but I only wants to apply List at 3rd parameter in suggestionbuilder and chipbuilder. I am thinking at dynamic way. Currently your mockresult is static. I am new in flutter.Bounden
For Ex: I have List arr=[{name: "abc"}, {name: "xyz"},{name: "pqr"}]; so how can I show name in chip as well as chipbuilder and suggestionbuilder?Bounden
L
5

Null safe version of Simon's.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class PlaygroundPage extends StatefulWidget {
  @override
  _PlaygroundPageState createState() => _PlaygroundPageState();
}

class _PlaygroundPageState extends State<PlaygroundPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Material Chips Input'),
      ),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: TextField(
              decoration: const InputDecoration(hintText: 'normal'),
            ),
          ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: ChipsInput<AppProfile>(
                decoration: InputDecoration(prefixIcon: Icon(Icons.search), hintText: 'Profile search'),
                findSuggestions: _findSuggestions,
                onChanged: _onChanged,
                chipBuilder: (BuildContext context, ChipsInputState<AppProfile> state, AppProfile profile) {
                  return InputChip(
                    key: ObjectKey(profile),
                    label: Text(profile.name),
                    avatar: CircleAvatar(
                      backgroundImage: NetworkImage(profile.imageUrl),
                    ),
                    onDeleted: () => state.deleteChip(profile),
                    onSelected: (_) => _onChipTapped(profile),
                    materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
                  );
                },
                suggestionBuilder: (BuildContext context, ChipsInputState<AppProfile> state, AppProfile profile) {
                  return ListTile(
                    key: ObjectKey(profile),
                    leading: CircleAvatar(
                      backgroundImage: NetworkImage(profile.imageUrl),
                    ),
                    title: Text(profile.name),
                    subtitle: Text(profile.email),
                    onTap: () => state.selectSuggestion(profile),
                  );
                },
              ),
            ),
          ),
        ],
      ),
    );
  }

  void _onChipTapped(AppProfile profile) {
    print('$profile');
  }

  void _onChanged(List<AppProfile> data) {
    print('onChanged $data');
  }

  Future<List<AppProfile>> _findSuggestions(String query) async {
    if (query.length != 0) {
      return mockResults.where((profile) {
        return profile.name.contains(query) || profile.email.contains(query);
      }).toList(growable: false);
    } else {
      return const <AppProfile>[];
    }
  }
}

// -------------------------------------------------

const mockResults = <AppProfile>[
  AppProfile('Stock Man', '[email protected]', 'https://d2gg9evh47fn9z.cloudfront.net/800px_COLOURBOX4057996.jpg'),
  AppProfile('Paul', '[email protected]', 'https://mbtskoudsalg.com/images/person-stock-image-png.png'),
  AppProfile('Fred', '[email protected]', 'https://media.istockphoto.com/photos/feeling-great-about-my-corporate-choices-picture-id507296326'),
  AppProfile('Bera', '[email protected]', 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('John', '[email protected]', 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('Thomas', '[email protected]', 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('Norbert', '[email protected]', 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
  AppProfile('Marina', '[email protected]', 'https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png'),
];

class AppProfile {
  final String name;
  final String email;
  final String imageUrl;

  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;

  @override
  int get hashCode => name.hashCode;

  @override
  String toString() {
    return 'Profile{$name}';
  }
}

// -------------------------------------------------

typedef ChipsInputSuggestions<T> = Future<List<T>> Function(String query);
typedef ChipSelected<T> = void Function(T data, bool selected);
typedef ChipsBuilder<T> = Widget Function(BuildContext context, ChipsInputState<T> state, T data);

class ChipsInput<T> extends StatefulWidget {
  const ChipsInput({
    Key? key,
    this.decoration = const InputDecoration(),
    required this.chipBuilder,
    required this.suggestionBuilder,
    required this.findSuggestions,
    required this.onChanged,
    this.onChipTapped,
  }) : super(key: key);

  final InputDecoration decoration;
  final ChipsInputSuggestions findSuggestions;
  final ValueChanged<List<T>> onChanged;
  final ValueChanged<T>? onChipTapped;
  final ChipsBuilder<T> chipBuilder;
  final ChipsBuilder<T> suggestionBuilder;

  @override
  ChipsInputState<T> createState() => ChipsInputState<T>();
}

class ChipsInputState<T> extends State<ChipsInput<T>> implements TextInputClient {
  static const kObjectReplacementChar = 0xFFFC;

  Set<T> _chips = Set<T>();
  List<T>? _suggestions;
  int _searchId = 0;

  FocusNode? _focusNode;
  TextEditingValue _value = TextEditingValue();
  TextInputConnection? _connection;

  String get text => String.fromCharCodes(
        _value.text.codeUnits.where((ch) => ch != kObjectReplacementChar),
      );

  bool get _hasInputConnection => _connection != null && (_connection?.attached ?? false);

  void requestKeyboard() {
    if (_focusNode?.hasFocus ?? false) {
      _openInputConnection();
    } else {
      FocusScope.of(context).requestFocus(_focusNode);
    }
  }

  void selectSuggestion(T data) {
    setState(() {
      _chips.add(data);
      _updateTextInputState();
      _suggestions = null;
    });
    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 ?? false) {
      _openInputConnection();
    } else {
      _closeInputConnectionIfNeeded();
    }
    setState(() {
      // rebuild so that _TextCursor is hidden.
    });
  }

  @override
  void dispose() {
    _focusNode?.dispose();
    _closeInputConnectionIfNeeded();
    super.dispose();
  }

  void _openInputConnection() {
    if (!_hasInputConnection) {
      _connection = TextInput.attach(this, TextInputConfiguration());
      _connection?.setEditingState(_value);
    }
    _connection?.show();
  }

  void _closeInputConnectionIfNeeded() {
    if (_hasInputConnection) {
      _connection?.close();
      _connection = null;
    }
  }

  @override
  Widget build(BuildContext context) {
    var chipsChildren = _chips
        .map<Widget>(
          (data) => widget.chipBuilder(context, this, data),
        )
        .toList();

    final theme = Theme.of(context);

    chipsChildren.add(
      Container(
        height: 32.0,
        child: Row(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Text(
              text,
              style: theme.textTheme.subtitle1?.copyWith(
                height: 1.5,
              ),
            ),
            _TextCaret(
              resumed: _focusNode?.hasFocus ?? false,
            ),
          ],
        ),
      ),
    );

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      //mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: requestKeyboard,
          child: InputDecorator(
            decoration: widget.decoration,
            isFocused: _focusNode?.hasFocus ?? false,
            isEmpty: _value.text.length == 0,
            child: Wrap(
              children: chipsChildren,
              spacing: 4.0,
              runSpacing: 4.0,
            ),
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: _suggestions?.length ?? 0,
            itemBuilder: (BuildContext context, int index) {
              return widget.suggestionBuilder(context, this, _suggestions![index]);
            },
          ),
        ),
      ],
    );
  }

  @override
  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;
  }

  @override
  void performAction(TextInputAction action) {
    _focusNode?.unfocus();
  }

  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) as List<T>?);
    }
  }

  @override
  void connectionClosed() {
    // TODO: implement connectionClosed
  }

  @override
  // TODO: implement currentAutofillScope
  AutofillScope? get currentAutofillScope => throw UnimplementedError();

  @override
  // TODO: implement currentTextEditingValue
  TextEditingValue? get currentTextEditingValue => throw UnimplementedError();

  @override
  void performPrivateCommand(String action, Map<String, dynamic> data) {
    // TODO: implement performPrivateCommand
  }

  @override
  void showAutocorrectionPromptRect(int start, int end) {
    // TODO: implement showAutocorrectionPromptRect
  }

  @override
  void updateFloatingCursor(RawFloatingCursorPoint point) {
    // TODO: implement updateFloatingCursor
  }
}

class _TextCaret extends StatefulWidget {
  const _TextCaret({
    Key? key,
    this.duration = const Duration(milliseconds: 500),
    this.resumed = false,
  }) : super(key: key);

  final Duration duration;
  final bool resumed;

  @override
  _TextCursorState createState() => _TextCursorState();
}

class _TextCursorState extends State<_TextCaret> 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() {
    _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,
        ),
      ),
    );
  }
}
Laney answered 22/9, 2021 at 20:48 Comment(0)
E
5

2023 update

There is a full official example from the Flutter team now. So you can use it. It is based on extending TextEditingController to replace text with InputChips.

Here is the link

Evident answered 22/12, 2023 at 22:38 Comment(0)
E
2

I implemented a tag to be created when a user input is received in a TextField and a separator is input.

I tried implementing it by referring to the package flutter_chips_input

Latest: https://gist.github.com/battlecook/2afbc23e17d4d77069681e21c862b692 .


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) {
      //widget.addChip(tag);
    }

    _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:
      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
  }
}

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());
}

You can check the operation by copying the code to dartpad.

Emmet answered 18/2, 2021 at 2:55 Comment(3)
How to use this? I tried it but it is fiving this error: The method * was called on null.Stretch
I tried and it is working as expected! However, do you know why the back button from the keyboar does not work? Can you fix it?Stretch
@ShajeelAfzal I checked the operation, but the back button on the keyboard seems to work well. It doesn't seem appropriate to talk about this code here, so if you have an issue with that code, go to gist.github.com/battlecook/2afbc23e17d4d77069681e21c862b692. Leave a comment hereEmmet
S
1

You can use the dependencies Mentioned above or you could use the Inputchip Class provided by Flutter with a combination of InputChip, List, TextFormField & callbacks. It is easy to achieve.

If you want to know more I have written an article on This, here: https://dev.to/imadnan/flutter-inputchips-inside-textformfield-fo2>InputChipBlog

Sybaris answered 18/9, 2022 at 18:12 Comment(1)
Your answer could be improved with additional supporting information. Please edit to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers in the help center.Drambuie

© 2022 - 2024 — McMap. All rights reserved.