import 'dart:math'; import 'dart:ui' as ui; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/tile_extent_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; // metadata to identify entry from RenderObject hit test during collection scaling class ScalerMetadata { final T item; const ScalerMetadata(this.item); } class GridScaleGestureDetector extends StatefulWidget { final TileExtentManager tileExtentManager; final GlobalKey scrollableKey; final ValueNotifier appBarHeightNotifier; final Size viewportSize; final bool showScaledGrid; final Widget Function(T item, double extent) scaledBuilder; final Rect Function(BuildContext context, T item) getScaledItemTileRect; final void Function(T item) onScaled; final Widget child; const GridScaleGestureDetector({ @required this.tileExtentManager, @required this.scrollableKey, @required this.appBarHeightNotifier, @required this.viewportSize, @required this.showScaledGrid, @required this.scaledBuilder, @required this.getScaledItemTileRect, @required this.onScaled, @required this.child, }); @override _GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState(); } class _GridScaleGestureDetectorState extends State> { double _startExtent, _extentMin, _extentMax; bool _applyingScale = false; ValueNotifier _scaledExtentNotifier; OverlayEntry _overlayEntry; ScalerMetadata _metadata; TileExtentManager get tileExtentManager => widget.tileExtentManager; Size get viewportSize => widget.viewportSize; @override Widget build(BuildContext context) { return GestureDetector( onHorizontalDragStart: (details) { // if `onHorizontalDragStart` callback is not defined, // horizontal drag gestures are interpreted as scaling }, onScaleStart: (details) { // the gesture detector wrongly detects a new scaling gesture // when scaling ends and we apply the new extent, so we prevent this // until we scaled and scrolled to the tile in the new grid if (_applyingScale) return; 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 U firstOf(BoxHitTestResult result) => result.path.firstWhere((el) => el.target is U, orElse: () => null)?.target as U; final renderMetaData = firstOf(result); // abort if we cannot find an image to show on overlay if (renderMetaData == null) return; _metadata = renderMetaData.metaData; _startExtent = renderMetaData.size.width; _scaledExtentNotifier = ValueNotifier(_startExtent); // not the same as `MediaQuery.size.width`, because of screen insets/padding final gridWidth = scrollableBox.size.width; _extentMin = tileExtentManager.getEffectiveExtentMin(viewportSize); _extentMax = tileExtentManager.getEffectiveExtentMax(viewportSize); final halfExtent = _startExtent / 2; final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent)); _overlayEntry = OverlayEntry( builder: (context) => ScaleOverlay( builder: (extent) => widget.scaledBuilder(_metadata.item, extent), center: thumbnailCenter, gridWidth: gridWidth, spacing: tileExtentManager.spacing, scaledExtentNotifier: _scaledExtentNotifier, showScaledGrid: widget.showScaledGrid, ), ); Overlay.of(scrollableContext).insert(_overlayEntry); }, onScaleUpdate: (details) { if (_scaledExtentNotifier == null) return; final s = details.scale; _scaledExtentNotifier.value = (_startExtent * s).clamp(_extentMin, _extentMax); }, onScaleEnd: (details) { if (_scaledExtentNotifier == null) return; if (_overlayEntry != null) { _overlayEntry.remove(); _overlayEntry = null; } _applyingScale = true; final oldExtent = tileExtentManager.extentNotifier.value; // sanitize and update grid layout if necessary final newExtent = tileExtentManager.applyTileExtent( viewportSize: widget.viewportSize, userPreferredExtent: _scaledExtentNotifier.value, ); _scaledExtentNotifier = null; if (newExtent == oldExtent) { _applyingScale = false; } else { // scroll to show the focal point thumbnail at its new position WidgetsBinding.instance.addPostFrameCallback((_) { final entry = _metadata.item; _scrollToItem(entry); // warning: posting `onScaled` in the next frame with `addPostFrameCallback` // would trigger only when the scrollable offset actually changes Future.delayed(Durations.collectionScalingCompleteNotificationDelay).then((_) => widget.onScaled?.call(entry)); _applyingScale = false; }); } }, 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 _scrollToItem(T item) { final scrollableContext = widget.scrollableKey.currentContext; final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height; final tileRect = widget.getScaledItemTileRect(context, item); // 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 { final Widget Function(double extent) builder; final Offset center; final double gridWidth; final double spacing; final ValueNotifier scaledExtentNotifier; final bool showScaledGrid; const ScaleOverlay({ @required this.builder, @required this.center, @required this.gridWidth, @required this.spacing, @required this.scaledExtentNotifier, @required this.showScaledGrid, }); @override _ScaleOverlayState createState() => _ScaleOverlayState(); } class _ScaleOverlayState extends State { bool _init = false; Offset get center => widget.center; double get gridWidth => widget.gridWidth; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => setState(() => _init = true)); } @override Widget build(BuildContext context) { return MediaQueryDataProvider( child: IgnorePointer( child: AnimatedContainer( decoration: _init ? BoxDecoration( gradient: RadialGradient( center: FractionalOffset.fromOffsetAndSize(center, MediaQuery.of(context).size), radius: 1, colors: [ Colors.black, Colors.black54, ], ), ) : BoxDecoration( // provide dummy gradient to lerp to the other one during animation gradient: RadialGradient( colors: [ Colors.transparent, Colors.transparent, ], ), ), duration: Durations.collectionScalingBackgroundAnimation, child: ValueListenableBuilder( valueListenable: widget.scaledExtentNotifier, builder: (context, extent, child) { // keep scaled thumbnail within the screen final xMin = MediaQuery.of(context).padding.left; final xMax = xMin + gridWidth; var dx = .0; if (center.dx - extent / 2 < xMin) { dx = xMin - (center.dx - extent / 2); } else if (center.dx + extent / 2 > xMax) { dx = xMax - (center.dx + extent / 2); } final clampedCenter = center.translate(dx, 0); var child = widget.builder(extent); child = Stack( children: [ Positioned( left: clampedCenter.dx - extent / 2, top: clampedCenter.dy - extent / 2, child: DefaultTextStyle( style: TextStyle(), child: child, ), ), ], ); if (widget.showScaledGrid) { child = CustomPaint( painter: GridPainter( center: clampedCenter, extent: extent, spacing: widget.spacing, ), child: child, ); } return child; }, ), ), ), ); } } class GridPainter extends CustomPainter { final Offset center; final double extent, spacing; const GridPainter({ @required this.center, @required this.extent, @required this.spacing, }); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..strokeWidth = DecoratedThumbnail.borderWidth ..shader = ui.Gradient.radial( center, size.width * .7, [ DecoratedThumbnail.borderColor, Colors.transparent, ], [ min(.5, 2 * extent / size.width), 1, ], ); void draw(Offset topLeft) { for (var i = -2; i <= 3; i++) { final ref = (extent + spacing) * i; canvas.drawLine(Offset(0, topLeft.dy + ref), Offset(size.width, topLeft.dy + ref), paint); canvas.drawLine(Offset(topLeft.dx + ref, 0), Offset(topLeft.dx + ref, size.height), paint); } } final topLeft = center.translate(-extent / 2, -extent / 2); draw(topLeft); if (spacing > 0) { draw(topLeft.translate(-spacing, -spacing)); } } @override bool shouldRepaint(CustomPainter oldDelegate) => true; }