use a sliver-based approach

This commit is contained in:
Henry Hiles 2026-09-17 11:13:36 -04:00
commit 67a31eb1fd
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
3 changed files with 163 additions and 123 deletions

View file

@ -1,55 +1,35 @@
import "dart:async";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:material_emoji_picker/src/controllers/emoji.dart";
import "package:material_emoji_picker/src/models/emoji_category.dart";
import "package:super_sliver_list/super_sliver_list.dart";
final class const EmojiPicker({
final IList<EmojiCategory> prependCategories = const IList.empty(),
final IList<EmojiCategory> appendCategories = const IList.empty(),
required final FutureOr<void> Function(String value) onSelection,
final bool allowFreeText = false,
class EmojiPicker extends HookConsumerWidget {
const EmojiPicker({
this.prependCategories = const IList.empty(),
this.appendCategories = const IList.empty(),
required this.onSelection,
this.allowFreeText = false,
super.key,
}) extends HookConsumerWidget {
});
final IList<EmojiCategory> prependCategories;
final IList<EmojiCategory> appendCategories;
final FutureOr<void> Function(String value) onSelection;
final bool allowFreeText;
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedCategory = useState<int?>(null);
final selectedCategory = useState<int>(0);
final search = useState("");
final scrollController = useScrollController();
final listController = useMemoized(ListController.new);
final scrollViewKey = useMemoized(() => GlobalKey());
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(
padding: .all(16),
@ -64,6 +44,63 @@ final class const EmojiPicker({
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(() {
void syncSelectedCategoryWithScroll() async {
final viewport =
scrollViewKey.currentContext?.findRenderObject()
as RenderBox?;
if (viewport == null) return;
var firstVisibleIndex = 0;
for (var index = 0; index < headerKeys.length; index++) {
final header = headerKeys[index].currentContext
?.findRenderObject();
if (header is! RenderBox) continue;
final headerTop = header
.localToGlobal(Offset.zero, ancestor: viewport)
.dy;
if (headerTop > 1) break;
firstVisibleIndex = index;
}
if (firstVisibleIndex != selectedCategory.value) {
selectedCategory.value = firstVisibleIndex;
final chipContext =
chipKeys[firstVisibleIndex].currentContext;
if (chipContext != null) {
await Scrollable.ensureVisible(
chipContext,
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
alignment: 0,
);
}
}
}
scrollController.addListener(syncSelectedCategoryWithScroll);
return () => scrollController.removeListener(
syncSelectedCategoryWithScroll,
);
}, [scrollController, headerKeys]);
return Column(
children: [
SearchBar(
@ -85,14 +122,14 @@ final class const EmojiPicker({
height: 48,
child: Scrollbar(
controller: categoryScrollController,
child: SuperListView(
child: ListView(
padding: .only(bottom: 12),
scrollDirection: .horizontal,
controller: categoryScrollController,
listController: categoryListController,
children: combined
.mapIndexed(
(index, category) => Padding(
children: [
for (final (index, category) in combined.indexed)
Padding(
key: chipKeys[index],
padding: .symmetric(horizontal: 4),
child: FilterChip(
label: Text(category.name),
@ -101,30 +138,32 @@ final class const EmojiPicker({
showCheckmark: false,
onSelected: (_) {
selectedCategory.value = index;
listController.animateToItem(
index: index,
scrollController: scrollController,
curve: (_) => Curves.easeInOut,
duration: (_) =>
Duration(milliseconds: 300),
final headerContext =
headerKeys[index].currentContext;
if (headerContext != null) {
Scrollable.ensureVisible(
headerContext,
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
alignment: 0,
);
}
},
),
),
)
.toList(),
],
),
),
),
Expanded(
child: SuperListView.builder(
listController: listController,
child: CustomScrollView(
key: scrollViewKey,
controller: scrollController,
itemCount: combined.length,
itemBuilder: (context, index) {
final category = combined[index];
slivers: [
for (final (index, category) in combined.indexed)
Builder(
builder: (context) {
final headerKey = headerKeys[index];
final emojis = search.value.isEmpty
? category.emojis
: category.emojis
@ -136,28 +175,36 @@ final class const EmojiPicker({
emoji.description.contains(
search.value,
) ||
emoji.tags.join().contains(search.value),
emoji.tags.join().contains(
search.value,
),
)
.toIList();
if (emojis.isEmpty) return SizedBox.shrink();
if (emojis.isEmpty) {
return SliverToBoxAdapter(
child: SizedBox.shrink(key: headerKey),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Text(
key: headerKey,
category.name,
style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context)
.textTheme
.titleMedium,
),
GridView.builder(
),
SliverGrid.builder(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 56,
mainAxisExtent: 56,
),
itemCount: emojis.length,
shrinkWrap: true,
controller: scrollController,
itemBuilder: (context, index) {
final emoji = emojis[index];
@ -171,11 +218,13 @@ final class const EmojiPicker({
);
},
),
SizedBox(height: 4),
SliverToBoxAdapter(child: SizedBox(height: 4)),
],
);
},
),
],
),
),
],
);

View file

@ -581,14 +581,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:

View file

@ -25,7 +25,6 @@ dependencies:
json_annotation: ^4.12.0
flutter_hooks: ^0.21.3+1
http: ^1.6.0
super_sliver_list: ^0.4.1
material_ui: ^1.2.0
dev_dependencies: