diff --git a/lib/widgets/album/collection_scaling.dart b/lib/widgets/album/collection_scaling.dart new file mode 100644 index 000000000..ee94417d3 --- /dev/null +++ b/lib/widgets/album/collection_scaling.dart @@ -0,0 +1,159 @@ +import 'dart:ui'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/album/collection_section.dart'; +import 'package:aves/widgets/album/thumbnail.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_sticky_header/flutter_sticky_header.dart'; + +class GridScaleGestureDetector extends StatefulWidget { + final GlobalKey scrollableKey; + final ValueNotifier columnCountNotifier; + final Widget child; + + const GridScaleGestureDetector({ + this.scrollableKey, + @required this.columnCountNotifier, + @required this.child, + }); + + @override + _GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState(); +} + +class _GridScaleGestureDetectorState extends State { + double _start; + ValueNotifier _scaledCountNotifier; + OverlayEntry _overlayEntry; + ThumbnailMetadata _metadata; + RenderSliver _renderSliver; + RenderViewport _renderViewport; + + ValueNotifier get countNotifier => widget.columnCountNotifier; + + static const columnCountMin = 2.0; + static const columnCountMax = 8.0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onScaleStart: (details) { + final scrollableContext = widget.scrollableKey.currentContext; + final RenderBox scrollableBox = scrollableContext.findRenderObject(); + final result = BoxHitTestResult(); + scrollableBox.hitTest(result, position: details.localFocalPoint); + + // find `RenderObject`s at the gesture focal point + final firstOf = (BoxHitTestResult result) => result.path.firstWhere((el) => el.target is T, orElse: () => null)?.target as T; + final renderMetaData = firstOf(result); + // abort if we cannot find an image to show on overlay + if (renderMetaData == null) return; + _renderSliver = firstOf(result); + _renderViewport = firstOf(result); + _metadata = renderMetaData.metaData; + _start = countNotifier.value; + _scaledCountNotifier = ValueNotifier(_start); + + final gridWidth = scrollableBox.size.width; + final halfExtent = gridWidth / _start / 2; + final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent)); + _overlayEntry = OverlayEntry( + builder: (context) { + return ScaleOverlay( + imageEntry: _metadata.entry, + thumbnailCenter: thumbnailCenter, + gridWidth: gridWidth, + scaledCountNotifier: _scaledCountNotifier, + ); + }, + ); + Overlay.of(scrollableContext).insert(_overlayEntry); + }, + onScaleUpdate: (details) { + if (_scaledCountNotifier == null) return; + final s = details.scale; + _scaledCountNotifier.value = (s <= 1 ? lerpDouble(_start * 2, _start, s) : lerpDouble(_start, _start / 2, s / 6)).clamp(columnCountMin, columnCountMax); + }, + onScaleEnd: (details) { + if (_overlayEntry != null) { + _overlayEntry.remove(); + _overlayEntry = null; + } + if (_scaledCountNotifier == null) return; + + final newColumnCount = _scaledCountNotifier.value.roundToDouble(); + _scaledCountNotifier = null; + if (newColumnCount == countNotifier.value) return; + + // update grid layout + countNotifier.value = newColumnCount; + + // scroll to show the focal point thumbnail at its new position + final sliverClosure = _renderSliver; + final viewportClosure = _renderViewport; + WidgetsBinding.instance.addPostFrameCallback((_) { + final scrollableContext = widget.scrollableKey.currentContext; + final gridSize = (scrollableContext.findRenderObject() as RenderBox).size; + final newExtent = gridSize.width / newColumnCount; + final row = _metadata.index ~/ newColumnCount; + // `Scrollable.ensureVisible` only works on already rendered objects + // `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata` + final scrollOffset = viewportClosure.scrollOffsetOf(sliverClosure, (row + 1) * newExtent - gridSize.height / 2); + viewportClosure.offset.jumpTo(scrollOffset); + }); + }, + child: widget.child, + ); + } +} + +class ScaleOverlay extends StatefulWidget { + final ImageEntry imageEntry; + final Offset thumbnailCenter; + final double gridWidth; + final ValueNotifier scaledCountNotifier; + + const ScaleOverlay({ + @required this.imageEntry, + @required this.thumbnailCenter, + @required this.gridWidth, + @required this.scaledCountNotifier, + }); + + @override + _ScaleOverlayState createState() => _ScaleOverlayState(); +} + +class _ScaleOverlayState extends State { + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: IgnorePointer( + child: AnimatedContainer( + color: Colors.black54, + duration: const Duration(milliseconds: 300), + child: ValueListenableBuilder( + valueListenable: widget.scaledCountNotifier, + builder: (context, columnCount, child) { + final extent = widget.gridWidth / columnCount; + return Stack( + children: [ + Positioned( + left: widget.thumbnailCenter.dx - extent / 2, + top: widget.thumbnailCenter.dy - extent / 2, + child: Thumbnail( + entry: widget.imageEntry, + extent: extent, + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/album/collection_section.dart b/lib/widgets/album/collection_section.dart index 91df3ec3b..e23d60fed 100644 --- a/lib/widgets/album/collection_section.dart +++ b/lib/widgets/album/collection_section.dart @@ -39,9 +39,12 @@ class SectionSliver extends StatelessWidget { child: Selector( selector: (c, mq) => mq.size.width, builder: (c, mqWidth, child) { - return Thumbnail( - entry: entry, - extent: mqWidth / columnCount, + return MetaData( + metaData: ThumbnailMetadata(index, entry), + child: Thumbnail( + entry: entry, + extent: mqWidth / columnCount, + ), ); }, ), @@ -80,6 +83,14 @@ class SectionSliver 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); +} + class SectionHeader extends StatelessWidget { final ImageCollection collection; final Map> sections; diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index fd98c6811..ec6f1364f 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -1,6 +1,5 @@ -import 'dart:ui'; - import 'package:aves/model/image_collection.dart'; +import 'package:aves/widgets/album/collection_scaling.dart'; import 'package:aves/widgets/album/collection_section.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; @@ -10,6 +9,7 @@ class ThumbnailCollection extends StatelessWidget { final Widget appBar; final ScrollController _scrollController = ScrollController(); final ValueNotifier _columnCountNotifier = ValueNotifier(4); + final GlobalKey _scrollableKey = GlobalKey(); ThumbnailCollection({ Key key, @@ -36,11 +36,12 @@ class ThumbnailCollection extends StatelessWidget { child: Selector( selector: (c, mq) => mq.viewInsets.bottom, builder: (c, mqViewInsetsBottom, child) { - return ValueListenableBuilder( - valueListenable: _columnCountNotifier, - builder: (context, columnCount, child) => GridScaleGestureDetector( - columnCountNotifier: _columnCountNotifier, - child: DraggableScrollbar( + return GridScaleGestureDetector( + scrollableKey: _scrollableKey, + columnCountNotifier: _columnCountNotifier, + child: ValueListenableBuilder( + valueListenable: _columnCountNotifier, + builder: (context, columnCount, child) => DraggableScrollbar( heightScrollThumb: 48, backgroundColor: Colors.white, scrollThumbBuilder: _thumbArrowBuilder(false), @@ -51,6 +52,7 @@ class ThumbnailCollection extends StatelessWidget { bottom: mqViewInsetsBottom, ), child: CustomScrollView( + key: _scrollableKey, controller: _scrollController, slivers: [ if (appBar != null) appBar, @@ -120,41 +122,3 @@ class ThumbnailCollection extends StatelessWidget { }; } } - -class GridScaleGestureDetector extends StatefulWidget { - final ValueNotifier columnCountNotifier; - final Widget child; - - const GridScaleGestureDetector({ - @required this.columnCountNotifier, - @required this.child, - }); - - @override - _GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState(); -} - -class _GridScaleGestureDetectorState extends State { - double _start; - - ValueNotifier get countNotifier => widget.columnCountNotifier; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onScaleStart: (details) => _start = countNotifier.value, - onScaleUpdate: (details) { - final s = details.scale; - _updateColumnCount(s <= 1 ? lerpDouble(_start * 2, _start, s) : lerpDouble(_start, _start / 2, s / 6)); - }, - onScaleEnd: (details) { - _updateColumnCount(countNotifier.value.roundToDouble()); - }, - child: widget.child, - ); - } - - void _updateColumnCount(double count) { - countNotifier.value = count.clamp(2.0, 8.0); - } -}