Compare commits

..
7 changed files with 168 additions and 110 deletions

View file

@ -17,3 +17,14 @@ Publicly export EmojiController for use as needed.
## 1.0.3 ## 1.0.3
Add scrollbar to category list, for more intuitive scrolling on desktop. Add scrollbar to category list, for more intuitive scrolling on desktop.
## 1.1.0
- Minify emoji json for a smaller bundle
- Use a Sliver-based approach for rendering the emoji view, resulting in much better performance
- Add more padding to search bar
- Filter out empty categories, helpful for search
## 1.1.1
Wait for end of frame before highlighting categories, fixing an issue where category highlights could be one scroll outdated.

View file

@ -5,9 +5,10 @@ A generic but configurable emoji picker for Flutter. Defaults to including only
- Up-to-date emojis, sourced from gemoji - Up-to-date emojis, sourced from gemoji
- The ability to prepend or append custom categories with custom emoji - The ability to prepend or append custom categories with custom emoji
- Support for free text selection - Support for free text selection
- High performance due to using slivers plus a custom listener
- Sleek M3 design: - Sleek M3 design:
![A screenshot of the emoji picker, showing a searchbar, category selection, and two categories of emojis](https://git.federated.nexus/Henry-Hiles/material_emoji_picker/raw/branch/main/assets/screenshot.png) ![A screenshot of the emoji picker, showing a searchbar, category selection, and two categories of emojis](https://git.federated.nexus/Henry-Hiles/material_emoji_picker/raw/branch/main/assets/screenshots/main.png)
## Getting started ## Getting started

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

BIN
assets/screenshots/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View file

@ -7,49 +7,30 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:material_emoji_picker/src/controllers/emoji.dart"; import "package:material_emoji_picker/src/controllers/emoji.dart";
import "package:material_emoji_picker/src/models/emoji_category.dart"; import "package:material_emoji_picker/src/models/emoji_category.dart";
import "package:super_sliver_list/super_sliver_list.dart";
final class const EmojiPicker({ class EmojiPicker extends HookConsumerWidget {
final IList<EmojiCategory> prependCategories = const IList.empty(), const EmojiPicker({
final IList<EmojiCategory> appendCategories = const IList.empty(), this.prependCategories = const IList.empty(),
required final FutureOr<void> Function(String value) onSelection, this.appendCategories = const IList.empty(),
final bool allowFreeText = false, required this.onSelection,
this.allowFreeText = false,
super.key, super.key,
}) extends HookConsumerWidget { });
final IList<EmojiCategory> prependCategories;
final IList<EmojiCategory> appendCategories;
final FutureOr<void> Function(String value) onSelection;
final bool allowFreeText;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectedCategory = useState<int?>(null); final selectedCategory = useState<int>(0);
final search = useState(""); final search = useState("");
final scrollController = useScrollController(); final scrollController = useScrollController();
final listController = useMemoized(ListController.new); final scrollViewKey = useMemoized(() => GlobalKey());
final categoryScrollController = useScrollController(); final categoryScrollController = useScrollController();
final categoryListController = useMemoized(ListController.new);
useEffect(() {
void onChange() {
final range = listController.visibleRange;
if (range != null) {
final firstVisible = range.$1;
if (selectedCategory.value != firstVisible) {
WidgetsBinding.instance.addPostFrameCallback((_) {
selectedCategory.value = firstVisible;
categoryListController.animateToItem(
index: firstVisible,
scrollController: categoryScrollController,
alignment: 0,
duration: (_) => Duration(milliseconds: 300),
curve: (_) => Curves.easeInOut,
);
});
}
}
}
listController.addListener(onChange);
return () => listController.removeListener(onChange);
}, [listController]);
return Container( return Container(
padding: .all(16), padding: .all(16),
@ -64,10 +45,67 @@ final class const EmojiPicker({
prependCategories.addAll(categories).addAll(appendCategories), prependCategories.addAll(categories).addAll(appendCategories),
); );
final headerKeys = useMemoized(
() =>
List.generate(combined.length, (_) => GlobalKey()).toIList(),
[combined],
);
final chipKeys = useMemoized(
() =>
List.generate(combined.length, (_) => GlobalKey()).toIList(),
[combined],
);
useEffect(() {
Future<void> syncSelectedCategoryWithScroll() async {
await WidgetsBinding.instance.endOfFrame;
final viewport = scrollViewKey.currentContext
?.findRenderObject();
if (viewport is! RenderBox) return;
final selectedIndex = headerKeys.lastIndexWhere((headerKey) {
final renderObject = headerKey.currentContext
?.findRenderObject();
if (renderObject is! RenderBox) return false;
final top = renderObject
.localToGlobal(Offset.zero, ancestor: viewport)
.dy;
return top <= 20;
});
if (selectedIndex != selectedCategory.value) {
selectedCategory.value = selectedIndex;
final chipContext = chipKeys[selectedIndex].currentContext;
if (chipContext != null && chipContext.mounted) {
await Scrollable.ensureVisible(
chipContext,
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
alignment: 0,
);
}
}
}
scrollController.addListener(syncSelectedCategoryWithScroll);
return () => scrollController.removeListener(
syncSelectedCategoryWithScroll,
);
}, [scrollController, headerKeys]);
return Column( return Column(
children: [ children: [
SearchBar( SearchBar(
hintText: "Search emoji", hintText: "Search emoji",
padding: const WidgetStatePropertyAll<EdgeInsets>(
.symmetric(horizontal: 16.0),
),
leading: Icon(Icons.search), leading: Icon(Icons.search),
onChanged: (value) => search.value = value, onChanged: (value) => search.value = value,
trailing: [ trailing: [
@ -85,14 +123,14 @@ final class const EmojiPicker({
height: 48, height: 48,
child: Scrollbar( child: Scrollbar(
controller: categoryScrollController, controller: categoryScrollController,
child: SuperListView( child: ListView(
padding: .only(bottom: 12), padding: .only(bottom: 12),
scrollDirection: .horizontal, scrollDirection: .horizontal,
controller: categoryScrollController, controller: categoryScrollController,
listController: categoryListController,
children: combined children: combined
.mapIndexed( .mapIndexed(
(index, category) => Padding( (index, category) => Padding(
key: chipKeys[index],
padding: .symmetric(horizontal: 4), padding: .symmetric(horizontal: 4),
child: FilterChip( child: FilterChip(
label: Text(category.name), label: Text(category.name),
@ -101,15 +139,16 @@ final class const EmojiPicker({
showCheckmark: false, showCheckmark: false,
onSelected: (_) { onSelected: (_) {
selectedCategory.value = index; selectedCategory.value = index;
final headerContext =
listController.animateToItem( headerKeys[index].currentContext;
index: index, if (headerContext != null) {
scrollController: scrollController, Scrollable.ensureVisible(
curve: (_) => Curves.easeInOut, headerContext,
duration: (_) => duration: Duration(milliseconds: 300),
Duration(milliseconds: 300), curve: Curves.easeInOut,
alignment: 0, alignment: 0,
); );
}
}, },
), ),
), ),
@ -119,12 +158,14 @@ final class const EmojiPicker({
), ),
), ),
Expanded( Expanded(
child: SuperListView.builder( child: CustomScrollView(
listController: listController, key: scrollViewKey,
controller: scrollController, controller: scrollController,
itemCount: combined.length, slivers: combined
itemBuilder: (context, index) { .mapIndexed(
final category = combined[index]; (index, category) => Builder(
builder: (context) {
final headerKey = headerKeys[index];
final emojis = search.value.isEmpty final emojis = search.value.isEmpty
? category.emojis ? category.emojis
: category.emojis : category.emojis
@ -136,34 +177,43 @@ final class const EmojiPicker({
emoji.description.contains( emoji.description.contains(
search.value, search.value,
) || ) ||
emoji.tags.join().contains(search.value), emoji.tags.join().contains(
search.value,
),
) )
.toIList(); .toIList();
if (emojis.isEmpty) return SizedBox.shrink(); if (emojis.isEmpty) {
return SliverToBoxAdapter(
child: SizedBox.shrink(key: headerKey),
);
}
return Column( return SliverMainAxisGroup(
crossAxisAlignment: CrossAxisAlignment.start, slivers: [
children: [ SliverToBoxAdapter(
Text( child: Text(
key: headerKey,
category.name, category.name,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context)
.textTheme
.titleMedium,
), ),
GridView.builder( ),
SliverGrid.builder(
gridDelegate: gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent( const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 56, maxCrossAxisExtent: 56,
mainAxisExtent: 56, mainAxisExtent: 56,
), ),
itemCount: emojis.length, itemCount: emojis.length,
shrinkWrap: true,
controller: scrollController,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final emoji = emojis[index]; final emoji = emojis[index];
return IconButton( return IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: () => onSelection(emoji.value), onPressed: () =>
onSelection(emoji.value),
icon: SizedBox.square( icon: SizedBox.square(
dimension: 36, dimension: 36,
child: FittedBox(child: emoji.widget), child: FittedBox(child: emoji.widget),
@ -171,11 +221,16 @@ final class const EmojiPicker({
); );
}, },
), ),
SizedBox(height: 4), SliverToBoxAdapter(
child: SizedBox(height: 4),
),
], ],
); );
}, },
), ),
)
.toList(),
),
), ),
], ],
); );

View file

@ -581,14 +581,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
super_sliver_list:
dependency: "direct main"
description:
name: super_sliver_list
sha256: b1e1e64d08ce40e459b9bb5d9f8e361617c26b8c9f3bb967760b0f436b6e3f56
url: "https://pub.dev"
source: hosted
version: "0.4.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:

View file

@ -1,6 +1,6 @@
name: material_emoji_picker name: material_emoji_picker
description: A generic but configurable emoji picker for Flutter description: A generic but configurable emoji picker for Flutter
version: 1.0.3 version: 1.1.1+2
repository: https://git.federated.nexus/Henry-Hiles/material_emoji_picker repository: https://git.federated.nexus/Henry-Hiles/material_emoji_picker
issue_tracker: https://git.federated.nexus/Henry-Hiles/material_emoji_picker/issues issue_tracker: https://git.federated.nexus/Henry-Hiles/material_emoji_picker/issues
@ -25,7 +25,6 @@ dependencies:
json_annotation: ^4.12.0 json_annotation: ^4.12.0
flutter_hooks: ^0.21.3+1 flutter_hooks: ^0.21.3+1
http: ^1.6.0 http: ^1.6.0
super_sliver_list: ^0.4.1
material_ui: ^1.2.0 material_ui: ^1.2.0
dev_dependencies: dev_dependencies: