-
-
Save peerwaya/82c9fd49e899000e2486e8cfbfa71266 to your computer and use it in GitHub Desktop.
Material Chips Input #HumpdayQandA - https://medium.com/flutter-community/hump-day-q-and-a/home
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
), | |
), | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment