album: scaling overlay

This commit is contained in:
Thibault Deckers 2020-03-04 13:13:58 +09:00
parent aa697f3a37
commit 5fc1510982
3 changed files with 182 additions and 48 deletions

View file

@ -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<double> columnCountNotifier;
final Widget child;
const GridScaleGestureDetector({
this.scrollableKey,
@required this.columnCountNotifier,
@required this.child,
});
@override
_GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState();
}
class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
double _start;
ValueNotifier<double> _scaledCountNotifier;
OverlayEntry _overlayEntry;
ThumbnailMetadata _metadata;
RenderSliver _renderSliver;
RenderViewport _renderViewport;
ValueNotifier<double> 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 = <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;
_renderSliver = firstOf<RenderSliverStickyHeader>(result);
_renderViewport = firstOf<RenderViewport>(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<double> scaledCountNotifier;
const ScaleOverlay({
@required this.imageEntry,
@required this.thumbnailCenter,
@required this.gridWidth,
@required this.scaledCountNotifier,
});
@override
_ScaleOverlayState createState() => _ScaleOverlayState();
}
class _ScaleOverlayState extends State<ScaleOverlay> {
@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,
),
),
],
);
},
),
),
),
);
}
}

View file

@ -39,9 +39,12 @@ class SectionSliver extends StatelessWidget {
child: Selector<MediaQueryData, double>( child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width, selector: (c, mq) => mq.size.width,
builder: (c, mqWidth, child) { builder: (c, mqWidth, child) {
return Thumbnail( return MetaData(
metaData: ThumbnailMetadata(index, entry),
child: Thumbnail(
entry: entry, entry: entry,
extent: mqWidth / columnCount, 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 { class SectionHeader extends StatelessWidget {
final ImageCollection collection; final ImageCollection collection;
final Map<dynamic, List<ImageEntry>> sections; final Map<dynamic, List<ImageEntry>> sections;

View file

@ -1,6 +1,5 @@
import 'dart:ui';
import 'package:aves/model/image_collection.dart'; 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:aves/widgets/album/collection_section.dart';
import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -10,6 +9,7 @@ class ThumbnailCollection extends StatelessWidget {
final Widget appBar; final Widget appBar;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final ValueNotifier<double> _columnCountNotifier = ValueNotifier(4); final ValueNotifier<double> _columnCountNotifier = ValueNotifier(4);
final GlobalKey _scrollableKey = GlobalKey();
ThumbnailCollection({ ThumbnailCollection({
Key key, Key key,
@ -36,11 +36,12 @@ class ThumbnailCollection extends StatelessWidget {
child: Selector<MediaQueryData, double>( child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.viewInsets.bottom, selector: (c, mq) => mq.viewInsets.bottom,
builder: (c, mqViewInsetsBottom, child) { builder: (c, mqViewInsetsBottom, child) {
return ValueListenableBuilder( return GridScaleGestureDetector(
valueListenable: _columnCountNotifier, scrollableKey: _scrollableKey,
builder: (context, columnCount, child) => GridScaleGestureDetector(
columnCountNotifier: _columnCountNotifier, columnCountNotifier: _columnCountNotifier,
child: DraggableScrollbar( child: ValueListenableBuilder(
valueListenable: _columnCountNotifier,
builder: (context, columnCount, child) => DraggableScrollbar(
heightScrollThumb: 48, heightScrollThumb: 48,
backgroundColor: Colors.white, backgroundColor: Colors.white,
scrollThumbBuilder: _thumbArrowBuilder(false), scrollThumbBuilder: _thumbArrowBuilder(false),
@ -51,6 +52,7 @@ class ThumbnailCollection extends StatelessWidget {
bottom: mqViewInsetsBottom, bottom: mqViewInsetsBottom,
), ),
child: CustomScrollView( child: CustomScrollView(
key: _scrollableKey,
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
if (appBar != null) appBar, if (appBar != null) appBar,
@ -120,41 +122,3 @@ class ThumbnailCollection extends StatelessWidget {
}; };
} }
} }
class GridScaleGestureDetector extends StatefulWidget {
final ValueNotifier<double> columnCountNotifier;
final Widget child;
const GridScaleGestureDetector({
@required this.columnCountNotifier,
@required this.child,
});
@override
_GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState();
}
class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
double _start;
ValueNotifier<double> 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);
}
}