280 lines
10 KiB
Dart
280 lines
10 KiB
Dart
import 'dart:math';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:aves/model/image_entry.dart';
|
|
import 'package:aves/utils/durations.dart';
|
|
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
|
import 'package:aves/widgets/collection/grid/list_sliver.dart';
|
|
import 'package:aves/widgets/collection/grid/tile_extent_manager.dart';
|
|
import 'package:aves/widgets/collection/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<double> appBarHeightNotifier;
|
|
final ValueNotifier<double> 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,
|
|
this.onScaled,
|
|
@required this.child,
|
|
});
|
|
|
|
@override
|
|
_GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState();
|
|
}
|
|
|
|
class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
|
|
double _startExtent, _extentMin, _extentMax;
|
|
bool _applyingScale = false;
|
|
ValueNotifier<double> _scaledExtentNotifier;
|
|
OverlayEntry _overlayEntry;
|
|
ThumbnailMetadata _metadata;
|
|
|
|
ValueNotifier<double> get tileExtentNotifier => widget.extentNotifier;
|
|
|
|
@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
|
|
T firstOf<T>(BoxHitTestResult result) => result.path.firstWhere((el) => el.target is T, orElse: () => null)?.target as T;
|
|
final renderMetaData = firstOf<RenderMetaData>(result);
|
|
// abort if we cannot find an image to show on overlay
|
|
if (renderMetaData == null) return;
|
|
_metadata = renderMetaData.metaData;
|
|
_startExtent = tileExtentNotifier.value;
|
|
_scaledExtentNotifier = ValueNotifier(_startExtent);
|
|
|
|
// not the same as `MediaQuery.size.width`, because of screen insets/padding
|
|
final gridWidth = scrollableBox.size.width;
|
|
_extentMin = gridWidth / (gridWidth / TileExtentManager.tileExtentMin).round();
|
|
_extentMax = gridWidth / (gridWidth / TileExtentManager.extentMaxForSize(widget.mqSize)).round();
|
|
final halfExtent = _startExtent / 2;
|
|
final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent));
|
|
_overlayEntry = OverlayEntry(
|
|
builder: (context) {
|
|
return ScaleOverlay(
|
|
imageEntry: _metadata.entry,
|
|
center: thumbnailCenter,
|
|
gridWidth: gridWidth,
|
|
scaledExtentNotifier: _scaledExtentNotifier,
|
|
);
|
|
},
|
|
);
|
|
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 = tileExtentNotifier.value;
|
|
// sanitize and update grid layout if necessary
|
|
final newExtent = TileExtentManager.applyTileExtent(
|
|
widget.mqSize,
|
|
widget.mqHorizontalPadding,
|
|
tileExtentNotifier,
|
|
newExtent: _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.entry;
|
|
_scrollToEntry(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 _scrollToEntry(ImageEntry entry) {
|
|
final scrollableContext = widget.scrollableKey.currentContext;
|
|
final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height;
|
|
final sectionedListLayout = Provider.of<SectionedListLayout>(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 {
|
|
final ImageEntry imageEntry;
|
|
final Offset center;
|
|
final double gridWidth;
|
|
final ValueNotifier<double> scaledExtentNotifier;
|
|
|
|
const ScaleOverlay({
|
|
@required this.imageEntry,
|
|
@required this.center,
|
|
@required this.gridWidth,
|
|
@required this.scaledExtentNotifier,
|
|
});
|
|
|
|
@override
|
|
_ScaleOverlayState createState() => _ScaleOverlayState();
|
|
}
|
|
|
|
class _ScaleOverlayState extends State<ScaleOverlay> {
|
|
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<double>(
|
|
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);
|
|
|
|
return CustomPaint(
|
|
painter: GridPainter(
|
|
center: clampedCenter,
|
|
extent: extent,
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
left: clampedCenter.dx - extent / 2,
|
|
top: clampedCenter.dy - extent / 2,
|
|
child: DecoratedThumbnail(
|
|
entry: widget.imageEntry,
|
|
extent: extent,
|
|
showOverlay: false,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class GridPainter extends CustomPainter {
|
|
final Offset center;
|
|
final double extent;
|
|
|
|
const GridPainter({
|
|
@required this.center,
|
|
@required this.extent,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..strokeWidth = DecoratedThumbnail.borderWidth
|
|
..shader = ui.Gradient.radial(
|
|
center,
|
|
size.width / 2,
|
|
[
|
|
DecoratedThumbnail.borderColor,
|
|
Colors.transparent,
|
|
],
|
|
[
|
|
min(.5, 2 * extent / size.width),
|
|
1,
|
|
],
|
|
);
|
|
final topLeft = center.translate(-extent / 2, -extent / 2);
|
|
for (var i = -1; i <= 2; i++) {
|
|
canvas.drawLine(Offset(0, topLeft.dy + extent * i), Offset(size.width, topLeft.dy + extent * i), paint);
|
|
canvas.drawLine(Offset(topLeft.dx + extent * i, 0), Offset(topLeft.dx + extent * i, size.height), paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(CustomPainter oldDelegate) => true;
|
|
}
|