Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
21de3590c1 |
|||
|
6f7cd8c378 |
|||
|
606dc644fe |
|||
|
dada24712e |
|||
|
072a366f43 |
|||
|
e4602b65f8 |
|||
|
5480889848 |
|||
|
647f84f31f |
|||
|
9793635bed |
|||
|
8de1939dbe |
|||
|
08cf09ecc2 |
|||
|
67a31eb1fd |
7 changed files with 168 additions and 110 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB |
BIN
assets/screenshots/main.png
Normal file
BIN
assets/screenshots/main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
|
|
@ -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(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue