Created
November 21, 2025 12:10
-
-
Save gasaichandesu/83dd7c3d046471725dc0531bc2252cab to your computer and use it in GitHub Desktop.
This file contains hidden or 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 'package:flutter/foundation.dart'; | |
| import 'package:flutter/material.dart'; | |
| typedef VisibilityReportingSliverItemBuilder<T> = | |
| Widget Function(BuildContext context, int index, T item); | |
| /// A sliver-compatible list that reports which items are currently visible | |
| /// in the viewport. | |
| /// | |
| /// `VisibilityReportingSliverList<T>` behaves like a normal [SliverList], but | |
| /// additionally tracks which list items intersect with the scroll viewport | |
| /// and notifies the parent whenever the visible subset changes. | |
| /// | |
| /// This is useful for: | |
| /// • marking items as "seen" when they enter the viewport | |
| /// • implementing infinite scroll prefetching | |
| /// • analytics / viewability tracking | |
| /// | |
| /// The list does **not** create its own scrollable. It must be placed inside | |
| /// a [CustomScrollView] (or another sliver-based scrollable) and relies on | |
| /// the surrounding sliver viewport. | |
| /// | |
| /// ### How visibility is computed | |
| /// Each built item is wrapped in an internal [GlobalKey]. On a scroll, | |
| /// the widget: | |
| /// 1. Gets the sliver viewport (using [Scrollable.of]). | |
| /// 2. Computes the vertical bounds of the viewport in global coordinates. | |
| /// 3. For each item, compares its global bounds with the viewport’s bounds. | |
| /// Any item whose vertical range overlaps the viewport is considered visible. | |
| /// | |
| /// When the set of visible items changes, [onVisibleItemsChanged] is called | |
| /// with the list of currently visible items. | |
| /// | |
| /// ### Usage | |
| /// | |
| /// Wrap your `CustomScrollView` with a [NotificationListener] and call | |
| /// `handleScroll()` on this widget’s state when a scroll notification | |
| /// arrives: | |
| /// | |
| /// ```dart | |
| /// final sliverKey = GlobalKey<VisibilityReportingSliverListState<NotificationEntity>>(); | |
| /// | |
| /// NotificationListener<ScrollNotification>( | |
| /// onNotification: (_) { | |
| /// WidgetsBinding.instance.addPostFrameCallback((_) { | |
| /// sliverKey.currentState?.handleScroll(); | |
| /// }); | |
| /// return false; | |
| /// }, | |
| /// child: CustomScrollView( | |
| /// slivers: [ | |
| /// VisibilityReportingSliverList<NotificationEntity>( | |
| /// key: sliverKey, | |
| /// items: notifications, | |
| /// itemBuilder: (context, index, item) { | |
| /// return NotificationTile(notification: item); | |
| /// }, | |
| /// onVisibleItemsChanged: (visible) { | |
| /// context | |
| /// .read<NotificationsBloc>() | |
| /// .add(VisibleNotificationsChanged(visible)); | |
| /// }, | |
| /// ), | |
| /// ], | |
| /// ), | |
| /// ); | |
| /// ``` | |
| /// | |
| /// Note: [onVisibleItemsChanged] may be called frequently during scrolling. | |
| /// If you do heavy work there (e.g. network calls), consider debouncing or | |
| /// batching it at a higher level. | |
| class VisibilityReportingSliverList<T> extends StatefulWidget { | |
| const VisibilityReportingSliverList({ | |
| super.key, | |
| required this.items, | |
| required this.itemBuilder, | |
| required this.onVisibleItemsChanged, | |
| this.keyBy, | |
| }); | |
| /// Items to be rendered in the sliver list. | |
| final List<T> items; | |
| /// Builder for each item in the list. | |
| /// | |
| /// The returned widget will be wrapped in an internal [GlobalKey] used for | |
| /// visibility calculations, so you don't need to worry about keys here | |
| /// unless you have additional requirements. | |
| final VisibilityReportingSliverItemBuilder<T> itemBuilder; | |
| /// Called whenever the set of visible items in the viewport changes. | |
| final void Function(List<T> visibleItems) onVisibleItemsChanged; | |
| /// Unique identifier factory. It is used to assign [_VisibilityReportingSliverListItemGlobalKey] for an item. | |
| /// Otherwise [GlobalKey] is used which may affect performance on larger lists | |
| final String Function(T item)? keyBy; | |
| @override | |
| VisibilityReportingSliverListState<T> createState() => | |
| VisibilityReportingSliverListState<T>(); | |
| } | |
| class VisibilityReportingSliverListState<T> | |
| extends State<VisibilityReportingSliverList<T>> { | |
| late List<GlobalKey> _itemKeys; | |
| Set<T> _visibleItems = {}; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _itemKeys = _regenerateKeys(); | |
| // Initial measurement after first layout | |
| WidgetsBinding.instance.addPostFrameCallback((_) => _onScroll()); | |
| } | |
| @override | |
| void didUpdateWidget(covariant VisibilityReportingSliverList<T> oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (!listEquals(oldWidget.items, widget.items)) { | |
| _itemKeys = _regenerateKeys(); | |
| WidgetsBinding.instance.addPostFrameCallback((_) => _onScroll()); | |
| } | |
| } | |
| List<GlobalKey> _regenerateKeys() { | |
| return widget.items.map((i) { | |
| final identifier = widget.keyBy?.call(i); | |
| return identifier == null | |
| ? GlobalKey() | |
| : _VisibilityReportingSliverListItemGlobalKey(identifier); | |
| }).toList(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return SliverList.builder( | |
| itemCount: widget.items.length, | |
| itemBuilder: (context, index) { | |
| final item = widget.items[index]; | |
| final child = widget.itemBuilder(context, index, item); | |
| return KeyedSubtree(key: _itemKeys[index], child: child); | |
| }, | |
| ); | |
| } | |
| /// Triggers a recalculation of which items are visible. | |
| /// | |
| /// Call this from a [ScrollNotification] listener wrapped around the | |
| /// [CustomScrollView] that contains this sliver. | |
| void handleScroll() { | |
| _onScroll(); | |
| } | |
| void _onScroll() { | |
| final scrollable = Scrollable.of(context); | |
| final viewportBox = scrollable.context.findRenderObject() as RenderBox?; | |
| if (viewportBox == null) return; | |
| final viewportTop = viewportBox.localToGlobal(Offset.zero).dy; | |
| final viewportBottom = viewportTop + viewportBox.size.height; | |
| final visibleItems = <T>[]; | |
| for (var i = 0; i < _itemKeys.length; i++) { | |
| final ctx = _itemKeys[i].currentContext; | |
| if (ctx == null) continue; | |
| final box = ctx.findRenderObject() as RenderBox?; | |
| if (box == null || !box.attached) continue; | |
| final top = box.localToGlobal(Offset.zero).dy; | |
| final bottom = top + box.size.height; | |
| final overlap = bottom > viewportTop && top < viewportBottom; | |
| if (overlap) { | |
| visibleItems.add(widget.items[i]); | |
| } | |
| } | |
| final visibleItemsSet = visibleItems.toSet(); | |
| if (!setEquals(_visibleItems, visibleItemsSet)) { | |
| _visibleItems = visibleItemsSet; | |
| widget.onVisibleItemsChanged(visibleItems); | |
| } | |
| } | |
| } | |
| final class _VisibilityReportingSliverListItemGlobalKey | |
| extends GlobalObjectKey { | |
| const _VisibilityReportingSliverListItemGlobalKey(super.value); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment