import 'dart:math'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/thumbnail/decorated.dart'; import 'package:flutter/material.dart'; import 'package:known_extents_list_view_builder/known_extents_list_view_builder.dart'; class ThumbnailScroller extends StatefulWidget { final double availableWidth; final int entryCount; final AvesEntry? Function(int index) entryBuilder; final ValueNotifier indexNotifier; final void Function(int index)? onTap; final Object? Function(AvesEntry entry)? heroTagger; final bool highlightable, showLocation; const ThumbnailScroller({ Key? key, required this.availableWidth, required this.entryCount, required this.entryBuilder, required this.indexNotifier, this.onTap, this.heroTagger, this.highlightable = false, this.showLocation = true, }) : super(key: key); @override _ThumbnailScrollerState createState() => _ThumbnailScrollerState(); } class _ThumbnailScrollerState extends State { final _cancellableNotifier = ValueNotifier(true); late ScrollController _scrollController; bool _isAnimating = false, _isScrolling = false; static const double extent = 48; static const double separatorWidth = 2; int get entryCount => widget.entryCount; ValueNotifier get indexNotifier => widget.indexNotifier; @override void initState() { super.initState(); _registerWidget(widget); } @override void didUpdateWidget(covariant ThumbnailScroller oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.indexNotifier != widget.indexNotifier) { _unregisterWidget(oldWidget); _registerWidget(widget); } } @override void dispose() { _unregisterWidget(widget); super.dispose(); } void _registerWidget(ThumbnailScroller widget) { final scrollOffset = indexToScrollOffset(indexNotifier.value ?? 0); _scrollController = ScrollController(initialScrollOffset: scrollOffset); _scrollController.addListener(_onScrollChange); widget.indexNotifier.addListener(_onIndexChange); } void _unregisterWidget(ThumbnailScroller widget) { _scrollController.removeListener(_onScrollChange); _scrollController.dispose(); widget.indexNotifier.removeListener(_onIndexChange); } @override Widget build(BuildContext context) { final marginWidth = max(0.0, (widget.availableWidth - extent) / 2 - separatorWidth); final horizontalMargin = SizedBox(width: marginWidth); const regularExtent = extent + separatorWidth; final itemExtents = List.generate(entryCount, (index) => regularExtent) ..insert(entryCount, marginWidth) ..insert(0, marginWidth + separatorWidth); return GridTheme( extent: extent, showLocation: widget.showLocation && settings.showThumbnailLocation, showTrash: false, child: SizedBox( height: extent, // as of Flutter v2.10.2, using `jumpTo` with a `ListView` is prohibitively inefficient // for large lists of items with variable height, so we use a `KnownExtentsListView` instead child: KnownExtentsListView.builder( itemExtents: itemExtents, scrollDirection: Axis.horizontal, controller: _scrollController, // default padding in scroll direction matches `MediaQuery.viewPadding`, // but we already accommodate for it, so make sure horizontal padding is 0 padding: EdgeInsets.zero, itemBuilder: (context, index) { if (index == 0 || index == entryCount + 1) return horizontalMargin; final page = index - 1; final pageEntry = widget.entryBuilder(page); if (pageEntry == null) return const SizedBox(); return Stack( children: [ GestureDetector( onTap: () { indexNotifier.value = page; widget.onTap?.call(page); }, child: DecoratedThumbnail( entry: pageEntry, tileExtent: extent, // the retrieval task queue can pile up for thumbnails of heavy pages // (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers) // so we cancel these requests when possible cancellableNotifier: _cancellableNotifier, selectable: false, highlightable: widget.highlightable, heroTagger: () => widget.heroTagger?.call(pageEntry), ), ), IgnorePointer( child: ValueListenableBuilder( valueListenable: indexNotifier, builder: (context, currentIndex, child) { return AnimatedContainer( color: currentIndex == page ? Colors.transparent : Colors.black45, width: extent, height: extent, duration: Durations.thumbnailScrollerShadeAnimation, ); }, ), ), ], ); }, itemCount: entryCount + 2, ), ), ); } Future _goTo(int index) async { final targetOffset = indexToScrollOffset(index); final offsetDelta = (targetOffset - _scrollController.offset).abs(); if (offsetDelta > widget.availableWidth * 2) { _scrollController.jumpTo(targetOffset); } else { _isAnimating = true; await _scrollController.animateTo( targetOffset, duration: Durations.thumbnailScrollerScrollAnimation, curve: Curves.easeOutCubic, ); _isAnimating = false; } } void _onScrollChange() { if (!_isAnimating) { final index = scrollOffsetToIndex(_scrollController.offset); if (indexNotifier.value != index) { _isScrolling = true; indexNotifier.value = index; } } } void _onIndexChange() { if (!_isScrolling && !_isAnimating) { final index = indexNotifier.value; if (index != null) { _goTo(index); } } _isScrolling = false; } double indexToScrollOffset(int index) => index * (extent + separatorWidth); int scrollOffsetToIndex(double offset) => (offset / (extent + separatorWidth)).round(); }