diff --git a/lib/widgets/album/grid/list_section_layout.dart b/lib/widgets/album/grid/list_section_layout.dart index 58e004c88..6993081e8 100644 --- a/lib/widgets/album/grid/list_section_layout.dart +++ b/lib/widgets/album/grid/list_section_layout.dart @@ -3,8 +3,8 @@ import 'dart:math'; import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/album/grid/header_generic.dart'; -import 'package:aves/widgets/album/grid/list_sliver.dart'; import 'package:aves/widgets/album/grid/tile_extent_manager.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -13,12 +13,14 @@ class SectionedListLayoutProvider extends StatelessWidget { final int columnCount; final double scrollableWidth; final double tileExtent; + final Widget Function(ImageEntry entry) thumbnailBuilder; final Widget child; SectionedListLayoutProvider({ @required this.collection, @required this.scrollableWidth, @required this.tileExtent, + @required this.thumbnailBuilder, @required this.child, }) : columnCount = max((scrollableWidth / tileExtent).round(), TileExtentManager.columnCountMin); @@ -89,22 +91,12 @@ class SectionedListLayoutProvider extends StatelessWidget { final minEntryIndex = sectionChildIndex * columnCount; final maxEntryIndex = min(sectionEntryCount, minEntryIndex + columnCount); - final ids = []; final children = []; for (var i = minEntryIndex; i < maxEntryIndex; i++) { final entry = section[i]; - final id = entry.contentId; - ids.add(id); - children.add(GridThumbnail( - key: ValueKey(id), - collection: collection, - index: i, - entry: entry, - tileExtent: tileExtent, - )); + children.add(thumbnailBuilder(entry)); } return Row( - key: ValueKey(ids.join('-')), mainAxisSize: MainAxisSize.min, children: children, ); @@ -142,6 +134,25 @@ class SectionedListLayout { final top = sectionLayout.indexToLayoutOffset(listIndex); return Rect.fromLTWH(left, top, tileExtent, tileExtent); } + + int rowIndex(dynamic sectionKey, List builtIds) { + if (!collection.sections.containsKey(sectionKey)) return null; + + final section = collection.sections[sectionKey]; + final firstId = builtIds.first; + final firstIndexInSection = section.indexWhere((entry) => entry.contentId == firstId); + if (firstIndexInSection % columnCount != 0) return null; + + final collectionIds = section.skip(firstIndexInSection).take(builtIds.length).map((entry) => entry.contentId); + final eq = const IterableEquality().equals; + if (eq(builtIds, collectionIds)) { + final sectionLayout = sectionLayouts.firstWhere((section) => section.sectionKey == sectionKey, orElse: () => null); + if (sectionLayout == null) return null; + return sectionLayout.firstIndex + 1 + firstIndexInSection ~/ columnCount; + } + + return null; + } } class SectionLayout { diff --git a/lib/widgets/album/grid/list_sliver.dart b/lib/widgets/album/grid/list_sliver.dart index c4411f3be..275b80c22 100644 --- a/lib/widgets/album/grid/list_sliver.dart +++ b/lib/widgets/album/grid/list_sliver.dart @@ -35,18 +35,16 @@ class CollectionListSliver extends StatelessWidget { class GridThumbnail extends StatelessWidget { final CollectionLens collection; - final int index; final ImageEntry entry; final double tileExtent; - final GestureTapCallback onTap; + final ValueNotifier isScrollingNotifier; const GridThumbnail({ Key key, this.collection, - this.index, - this.entry, - this.tileExtent, - this.onTap, + @required this.entry, + @required this.tileExtent, + this.isScrollingNotifier, }) : super(key: key); @override @@ -66,11 +64,12 @@ class GridThumbnail extends StatelessWidget { } }, child: MetaData( - metaData: ThumbnailMetadata(index, entry), + metaData: ThumbnailMetadata(entry), child: DecoratedThumbnail( entry: entry, extent: tileExtent, - heroTag: collection.heroTag(entry), + collection: collection, + isScrollingNotifier: isScrollingNotifier, ), ), ); @@ -91,8 +90,7 @@ class GridThumbnail extends StatelessWidget { // metadata to identify entry from RenderObject hit test during collection scaling class ThumbnailMetadata { - final int index; final ImageEntry entry; - const ThumbnailMetadata(this.index, this.entry); + const ThumbnailMetadata(this.entry); } diff --git a/lib/widgets/album/grid/scaling.dart b/lib/widgets/album/grid/scaling.dart index 4f26fcd63..2b9200d8c 100644 --- a/lib/widgets/album/grid/scaling.dart +++ b/lib/widgets/album/grid/scaling.dart @@ -2,27 +2,29 @@ import 'dart:math'; import 'dart:ui' as ui; import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/album/grid/list_section_layout.dart'; import 'package:aves/widgets/album/grid/list_sliver.dart'; import 'package:aves/widgets/album/grid/tile_extent_manager.dart'; import 'package:aves/widgets/album/thumbnail/decorated.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; class GridScaleGestureDetector extends StatefulWidget { final GlobalKey scrollableKey; + final ValueNotifier appBarHeightNotifier; final ValueNotifier extentNotifier; final Size mqSize; final double mqHorizontalPadding; - final void Function(ImageEntry entry) onScaled; final Widget child; const GridScaleGestureDetector({ this.scrollableKey, + @required this.appBarHeightNotifier, @required this.extentNotifier, @required this.mqSize, @required this.mqHorizontalPadding, - @required this.onScaled, @required this.child, }); @@ -110,7 +112,7 @@ class _GridScaleGestureDetectorState extends State { } else { // scroll to show the focal point thumbnail at its new position WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onScaled(_metadata.entry); + _scrollToEntry(_metadata.entry); _applyingScale = false; }); } @@ -118,6 +120,23 @@ class _GridScaleGestureDetectorState extends State { child: widget.child, ); } + + // about scrolling & offset retrieval: + // `Scrollable.ensureVisible` only works on already rendered objects + // `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata` + // `RenderViewport.scrollOffsetOf` is a good alternative + void _scrollToEntry(ImageEntry entry) { + final scrollableContext = widget.scrollableKey.currentContext; + final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height; + final sectionedListLayout = Provider.of(context, listen: false); + final tileRect = sectionedListLayout.getTileRect(entry) ?? Rect.zero; + // most of the time the app bar will be scrolled away after scaling, + // so we compensate for it to center the focal point thumbnail + final appBarHeight = widget.appBarHeightNotifier.value; + final scrollOffset = tileRect.top + (tileRect.height - scrollableHeight) / 2 + appBarHeight; + + PrimaryScrollController.of(context)?.jumpTo(max(.0, scrollOffset)); + } } class ScaleOverlay extends StatefulWidget { diff --git a/lib/widgets/album/thumbnail/decorated.dart b/lib/widgets/album/thumbnail/decorated.dart index 0ce1ff0e1..7f4a45fa1 100644 --- a/lib/widgets/album/thumbnail/decorated.dart +++ b/lib/widgets/album/thumbnail/decorated.dart @@ -8,6 +8,7 @@ class DecoratedThumbnail extends StatelessWidget { final ImageEntry entry; final double extent; final Object heroTag; + final ValueNotifier isScrollingNotifier; final bool showOverlay; static final Color borderColor = Colors.grey.shade700; @@ -18,11 +19,42 @@ class DecoratedThumbnail extends StatelessWidget { @required this.entry, @required this.extent, this.heroTag, + this.isScrollingNotifier, this.showOverlay = true, }) : super(key: key); @override Widget build(BuildContext context) { + final child = Stack( + children: [ + entry.isSvg + ? ThumbnailVectorImage( + entry: entry, + extent: extent, + heroTag: heroTag, + ) + : ThumbnailRasterImage( + entry: entry, + extent: extent, + isScrollingNotifier: isScrollingNotifier, + heroTag: heroTag, + ), + if (showOverlay) + Positioned( + bottom: 0, + left: 0, + child: ThumbnailEntryOverlay( + entry: entry, + extent: extent, + ), + ), + if (showOverlay) + ThumbnailSelectionOverlay( + entry: entry, + extent: extent, + ), + ], + ); return Container( decoration: BoxDecoration( border: Border.all( @@ -32,35 +64,7 @@ class DecoratedThumbnail extends StatelessWidget { ), width: extent, height: extent, - child: Stack( - children: [ - entry.isSvg - ? ThumbnailVectorImage( - entry: entry, - extent: extent, - heroTag: heroTag, - ) - : ThumbnailRasterImage( - entry: entry, - extent: extent, - heroTag: heroTag, - ), - if (showOverlay) - Positioned( - bottom: 0, - left: 0, - child: ThumbnailEntryOverlay( - entry: entry, - extent: extent, - ), - ), - if (showOverlay) - ThumbnailSelectionOverlay( - entry: entry, - extent: extent, - ), - ], - ), + child: child, ); } } diff --git a/lib/widgets/album/thumbnail/raster.dart b/lib/widgets/album/thumbnail/raster.dart index 9baefef0d..ad8e36631 100644 --- a/lib/widgets/album/thumbnail/raster.dart +++ b/lib/widgets/album/thumbnail/raster.dart @@ -8,12 +8,14 @@ import 'package:flutter/material.dart'; class ThumbnailRasterImage extends StatefulWidget { final ImageEntry entry; final double extent; + final ValueNotifier isScrollingNotifier; final Object heroTag; const ThumbnailRasterImage({ Key key, @required this.entry, @required this.extent, + this.isScrollingNotifier, this.heroTag, }) : super(key: key); @@ -53,7 +55,15 @@ class _ThumbnailRasterImageState extends State { void _initProvider() => _imageProvider = ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent); - void _pauseProvider() => _imageProvider?.pause(); + void _pauseProvider() { + final isScrolling = widget.isScrollingNotifier?.value ?? false; + // when the user is scrolling faster than we can retrieve the thumbnails, + // the retrieval task queue can pile up for thumbnails that got disposed + // in this case we pause the image retrieval task to get it out of the queue + if (isScrolling) { + _imageProvider?.pause(); + } + } @override Widget build(BuildContext context) { diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index 6fac4eb59..ef20a01a7 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -1,9 +1,8 @@ -import 'dart:math'; +import 'dart:async'; import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; -import 'package:aves/model/image_entry.dart'; import 'package:aves/model/mime_types.dart'; import 'package:aves/widgets/album/app_bar.dart'; import 'package:aves/widgets/album/empty.dart'; @@ -21,6 +20,8 @@ import 'package:tuple/tuple.dart'; class ThumbnailCollection extends StatelessWidget { final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); final ValueNotifier _tileExtentNotifier = ValueNotifier(0); + final ValueNotifier _isScrollingNotifier = ValueNotifier(false); + final GlobalKey _scrollableKey = GlobalKey(); @override Widget build(BuildContext context) { @@ -36,9 +37,25 @@ class ThumbnailCollection extends StatelessWidget { // so that view updates on collection filter changes return Consumer( builder: (context, collection, child) { - final appBar = CollectionAppBar( - appBarHeightNotifier: _appBarHeightNotifier, + final scrollView = CollectionScrollView( + scrollableKey: _scrollableKey, collection: collection, + appBar: CollectionAppBar( + appBarHeightNotifier: _appBarHeightNotifier, + collection: collection, + ), + appBarHeightNotifier: _appBarHeightNotifier, + isScrollingNotifier: _isScrollingNotifier, + scrollController: PrimaryScrollController.of(context), + ); + + final scaler = GridScaleGestureDetector( + scrollableKey: _scrollableKey, + appBarHeightNotifier: _appBarHeightNotifier, + extentNotifier: _tileExtentNotifier, + mqSize: mqSize, + mqHorizontalPadding: mqHorizontalPadding, + child: scrollView, ); final sectionedListLayoutProvider = ValueListenableBuilder( @@ -47,14 +64,14 @@ class ThumbnailCollection extends StatelessWidget { collection: collection, scrollableWidth: mqSize.width - mqHorizontalPadding, tileExtent: tileExtent, - child: _ScalableThumbnailCollection( - appBarHeightNotifier: _appBarHeightNotifier, - tileExtentNotifier: _tileExtentNotifier, + thumbnailBuilder: (entry) => GridThumbnail( + key: ValueKey(entry.contentId), collection: collection, - mqSize: mqSize, - mqHorizontalPadding: mqHorizontalPadding, - appBar: appBar, + entry: entry, + tileExtent: tileExtent, + isScrollingNotifier: _isScrollingNotifier, ), + child: scaler, ), ); return sectionedListLayoutProvider; @@ -66,42 +83,67 @@ class ThumbnailCollection extends StatelessWidget { } } -class _ScalableThumbnailCollection extends StatelessWidget { +class CollectionScrollView extends StatefulWidget { + final GlobalKey scrollableKey; final CollectionLens collection; - final ValueNotifier appBarHeightNotifier; - final ValueNotifier tileExtentNotifier; - final Size mqSize; - final double mqHorizontalPadding; final Widget appBar; + final ValueNotifier appBarHeightNotifier; + final ValueNotifier isScrollingNotifier; + final ScrollController scrollController; - final GlobalKey _scrollableKey = GlobalKey(); - - _ScalableThumbnailCollection({ - @required this.appBarHeightNotifier, - @required this.tileExtentNotifier, + const CollectionScrollView({ + @required this.scrollableKey, @required this.collection, - @required this.mqSize, - @required this.mqHorizontalPadding, @required this.appBar, + @required this.appBarHeightNotifier, + @required this.isScrollingNotifier, + @required this.scrollController, }); + @override + _CollectionScrollViewState createState() => _CollectionScrollViewState(); +} + +class _CollectionScrollViewState extends State { + Timer _scrollMonitoringTimer; + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(CollectionScrollView oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + _stopScrollMonitoringTimer(); + super.dispose(); + } + + void _registerWidget(CollectionScrollView widget) { + widget.scrollController.addListener(_onScrollChange); + } + + void _unregisterWidget(CollectionScrollView widget) { + widget.scrollController.removeListener(_onScrollChange); + } + @override Widget build(BuildContext context) { - final scrollView = _buildScrollView(appBar, collection); - final draggable = _buildDraggableScrollView(scrollView); - return GridScaleGestureDetector( - scrollableKey: _scrollableKey, - extentNotifier: tileExtentNotifier, - mqSize: mqSize, - mqHorizontalPadding: mqHorizontalPadding, - onScaled: (entry) => _scrollToEntry(context, entry), - child: draggable, - ); + final scrollView = _buildScrollView(widget.appBar, widget.collection); + return _buildDraggableScrollView(scrollView); } ScrollView _buildScrollView(Widget appBar, CollectionLens collection) { return CustomScrollView( - key: _scrollableKey, + key: widget.scrollableKey, primary: true, // workaround to prevent scrolling the app bar away // when there is no content and we use `SliverFillRemaining` @@ -128,7 +170,7 @@ class _ScalableThumbnailCollection extends StatelessWidget { Widget _buildDraggableScrollView(ScrollView scrollView) { return ValueListenableBuilder( - valueListenable: appBarHeightNotifier, + valueListenable: widget.appBarHeightNotifier, builder: (context, appBarHeight, child) => Selector( selector: (context, mq) => mq.viewInsets.bottom, builder: (context, mqViewInsetsBottom, child) => DraggableScrollbar( @@ -138,7 +180,7 @@ class _ScalableThumbnailCollection extends StatelessWidget { height: avesScrollThumbHeight, backgroundColor: Colors.white, ), - controller: PrimaryScrollController.of(context), + controller: widget.scrollController, padding: EdgeInsets.only( // padding to keep scroll thumb between app bar above and nav bar below top: appBarHeight, @@ -164,20 +206,16 @@ class _ScalableThumbnailCollection extends StatelessWidget { : const EmptyContent(); } - // about scrolling & offset retrieval: - // `Scrollable.ensureVisible` only works on already rendered objects - // `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata` - // `RenderViewport.scrollOffsetOf` is a good alternative - void _scrollToEntry(BuildContext context, ImageEntry entry) { - final scrollableContext = _scrollableKey.currentContext; - final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height; - final sectionLayout = Provider.of(context, listen: false); - final tileRect = sectionLayout.getTileRect(entry) ?? Rect.zero; - // most of the time the app bar will be scrolled away after scaling, - // so we compensate for it to center the focal point thumbnail - final appBarHeight = appBarHeightNotifier.value; - final scrollOffset = tileRect.top + (tileRect.height - scrollableHeight) / 2 + appBarHeight; + void _onScrollChange() { + widget.isScrollingNotifier.value = true; + _stopScrollMonitoringTimer(); + _scrollMonitoringTimer = Timer(const Duration(milliseconds: 100), () { + debugPrint('$runtimeType _onScrollChange is scrolling false'); + widget.isScrollingNotifier.value = false; + }); + } - PrimaryScrollController.of(context)?.jumpTo(max(.0, scrollOffset)); + void _stopScrollMonitoringTimer() { + _scrollMonitoringTimer?.cancel(); } }