From 67a31eb1fd06c6e0c2bc59b4424d3368bdc01156 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 17 Sep 2026 11:13:36 -0400 Subject: [PATCH] use a sliver-based approach --- lib/src/widgets/emoji_picker.dart | 275 ++++++++++++++++++------------ pubspec.lock | 8 - pubspec.yaml | 1 - 3 files changed, 162 insertions(+), 122 deletions(-) diff --git a/lib/src/widgets/emoji_picker.dart b/lib/src/widgets/emoji_picker.dart index 6980b9a..eecdf95 100644 --- a/lib/src/widgets/emoji_picker.dart +++ b/lib/src/widgets/emoji_picker.dart @@ -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 prependCategories = const IList.empty(), - final IList appendCategories = const IList.empty(), - required final FutureOr Function(String value) onSelection, - final bool allowFreeText = false, - super.key, -}) extends HookConsumerWidget { +class EmojiPicker extends HookConsumerWidget { + const EmojiPicker({ + this.prependCategories = const IList.empty(), + this.appendCategories = const IList.empty(), + required this.onSelection, + this.allowFreeText = false, + super.key, + }); + + final IList prependCategories; + final IList appendCategories; + final FutureOr Function(String value) onSelection; + final bool allowFreeText; + @override Widget build(BuildContext context, WidgetRef ref) { - final selectedCategory = useState(null); + final selectedCategory = useState(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,96 +122,108 @@ 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( - padding: .symmetric(horizontal: 4), - child: FilterChip( - label: Text(category.name), - avatar: category.icon, - selected: selectedCategory.value == index, - showCheckmark: false, - onSelected: (_) { - selectedCategory.value = index; - - listController.animateToItem( - index: index, - scrollController: scrollController, - curve: (_) => Curves.easeInOut, - duration: (_) => - Duration(milliseconds: 300), + children: [ + for (final (index, category) in combined.indexed) + Padding( + key: chipKeys[index], + padding: .symmetric(horizontal: 4), + child: FilterChip( + label: Text(category.name), + avatar: category.icon, + selected: selectedCategory.value == index, + showCheckmark: false, + onSelected: (_) { + selectedCategory.value = index; + 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]; - final emojis = search.value.isEmpty - ? category.emojis - : category.emojis - .where( - (emoji) => - emoji.aliases.join().contains( - search.value, - ) || - emoji.description.contains( - search.value, - ) || - emoji.tags.join().contains(search.value), - ) - .toIList(); + slivers: [ + for (final (index, category) in combined.indexed) + Builder( + builder: (context) { + final headerKey = headerKeys[index]; + final emojis = search.value.isEmpty + ? category.emojis + : category.emojis + .where( + (emoji) => + emoji.aliases.join().contains( + search.value, + ) || + emoji.description.contains( + search.value, + ) || + emoji.tags.join().contains( + search.value, + ), + ) + .toIList(); - if (emojis.isEmpty) return SizedBox.shrink(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category.name, - style: Theme.of(context).textTheme.titleMedium, - ), - GridView.builder( - gridDelegate: - const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 56, - mainAxisExtent: 56, - ), - itemCount: emojis.length, - shrinkWrap: true, - controller: scrollController, - itemBuilder: (context, index) { - final emoji = emojis[index]; - - return IconButton( - padding: EdgeInsets.zero, - onPressed: () => onSelection(emoji.value), - icon: SizedBox.square( - dimension: 36, - child: FittedBox(child: emoji.widget), - ), + if (emojis.isEmpty) { + return SliverToBoxAdapter( + child: SizedBox.shrink(key: headerKey), ); - }, - ), - SizedBox(height: 4), - ], - ); - }, + } + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Text( + key: headerKey, + category.name, + style: Theme.of(context) + .textTheme + .titleMedium, + ), + ), + SliverGrid.builder( + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 56, + mainAxisExtent: 56, + ), + itemCount: emojis.length, + itemBuilder: (context, index) { + final emoji = emojis[index]; + + return IconButton( + padding: EdgeInsets.zero, + onPressed: () => onSelection(emoji.value), + icon: SizedBox.square( + dimension: 36, + child: FittedBox(child: emoji.widget), + ), + ); + }, + ), + SliverToBoxAdapter(child: SizedBox(height: 4)), + ], + ); + }, + ), + ], ), ), ], diff --git a/pubspec.lock b/pubspec.lock index 2bf7a40..0b0d9e3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 99398c4..9d2efc1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: