album: scale gesture to change column count
This commit is contained in:
parent
4b9625afea
commit
aa697f3a37
6 changed files with 274 additions and 51 deletions
|
@ -64,7 +64,7 @@ class _HomePageState extends State<HomePage> {
|
|||
final permissions = await PermissionHandler().requestPermissions([
|
||||
PermissionGroup.storage,
|
||||
// unredacted EXIF with scoped storage (Android 10+)
|
||||
PermissionGroup.access_media_location,
|
||||
PermissionGroup.accessMediaLocation,
|
||||
]); // 350ms
|
||||
if (permissions[PermissionGroup.storage] != PermissionStatus.granted) {
|
||||
unawaited(SystemNavigator.pop());
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/model/image_collection.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/widgets/album/sections.dart';
|
||||
import 'package:aves/widgets/album/sliver_transition_grid_delegate.dart';
|
||||
import 'package:aves/widgets/album/thumbnail.dart';
|
||||
import 'package:aves/widgets/album/transparent_material_page_route.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
|
@ -12,13 +13,13 @@ import 'package:provider/provider.dart';
|
|||
class SectionSliver extends StatelessWidget {
|
||||
final ImageCollection collection;
|
||||
final dynamic sectionKey;
|
||||
|
||||
static const columnCount = 4;
|
||||
final double columnCount;
|
||||
|
||||
const SectionSliver({
|
||||
Key key,
|
||||
@required this.collection,
|
||||
@required this.sectionKey,
|
||||
@required this.columnCount,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -50,8 +51,7 @@ class SectionSliver extends StatelessWidget {
|
|||
addAutomaticKeepAlives: false,
|
||||
addRepaintBoundaries: true,
|
||||
),
|
||||
// TODO TLAD custom SliverGridDelegate / SliverGridLayout to lerp between columnCount
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
gridDelegate: SliverTransitionGridDelegateWithCrossAxisCount(
|
||||
crossAxisCount: columnCount,
|
||||
),
|
||||
);
|
||||
|
|
176
lib/widgets/album/sliver_transition_grid_delegate.dart
Normal file
176
lib/widgets/album/sliver_transition_grid_delegate.dart
Normal file
|
@ -0,0 +1,176 @@
|
|||
import 'dart:math' as math;
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class SliverTransitionGridDelegateWithCrossAxisCount extends SliverGridDelegate {
|
||||
const SliverTransitionGridDelegateWithCrossAxisCount({
|
||||
@required this.crossAxisCount,
|
||||
this.mainAxisSpacing = 0.0,
|
||||
this.crossAxisSpacing = 0.0,
|
||||
this.childAspectRatio = 1.0,
|
||||
}) : assert(crossAxisCount != null && crossAxisCount > 0),
|
||||
assert(mainAxisSpacing != null && mainAxisSpacing >= 0),
|
||||
assert(crossAxisSpacing != null && crossAxisSpacing >= 0),
|
||||
assert(childAspectRatio != null && childAspectRatio > 0);
|
||||
|
||||
/// The number of children in the cross axis.
|
||||
final double crossAxisCount;
|
||||
|
||||
/// The number of logical pixels between each child along the main axis.
|
||||
final double mainAxisSpacing;
|
||||
|
||||
/// The number of logical pixels between each child along the cross axis.
|
||||
final double crossAxisSpacing;
|
||||
|
||||
/// The ratio of the cross-axis to the main-axis extent of each child.
|
||||
final double childAspectRatio;
|
||||
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
final t = crossAxisCount - crossAxisCount.truncateToDouble();
|
||||
return SliverTransitionGridTileLayout(
|
||||
current: _buildSettings(constraints, crossAxisCount),
|
||||
floor: t != 0 ? _buildSettings(constraints, crossAxisCount.floorToDouble()) : null,
|
||||
ceil: t != 0 ? _buildSettings(constraints, crossAxisCount.ceilToDouble()) : null,
|
||||
t: t,
|
||||
reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection),
|
||||
);
|
||||
}
|
||||
|
||||
SliverTransitionGridTileLayoutSettings _buildSettings(SliverConstraints constraints, double crossAxisCount) {
|
||||
final double usableCrossAxisExtent = constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1);
|
||||
final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount;
|
||||
final double childMainAxisExtent = childCrossAxisExtent / childAspectRatio;
|
||||
final current = SliverTransitionGridTileLayoutSettings(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisStride: childMainAxisExtent + mainAxisSpacing,
|
||||
crossAxisStride: childCrossAxisExtent + crossAxisSpacing,
|
||||
childMainAxisExtent: childMainAxisExtent,
|
||||
childCrossAxisExtent: childCrossAxisExtent,
|
||||
);
|
||||
return current;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(SliverTransitionGridDelegateWithCrossAxisCount oldDelegate) {
|
||||
return oldDelegate.crossAxisCount != crossAxisCount || oldDelegate.mainAxisSpacing != mainAxisSpacing || oldDelegate.crossAxisSpacing != crossAxisSpacing || oldDelegate.childAspectRatio != childAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
class SliverTransitionGridTileLayoutSettings {
|
||||
final double crossAxisCount;
|
||||
|
||||
/// The number of pixels from the leading edge of one tile to the leading edge
|
||||
/// of the next tile in the main axis.
|
||||
final double mainAxisStride;
|
||||
|
||||
/// The number of pixels from the leading edge of one tile to the leading edge
|
||||
/// of the next tile in the cross axis.
|
||||
final double crossAxisStride;
|
||||
|
||||
/// The number of pixels from the leading edge of one tile to the trailing
|
||||
/// edge of the same tile in the main axis.
|
||||
final double childMainAxisExtent;
|
||||
|
||||
/// The number of pixels from the leading edge of one tile to the trailing
|
||||
/// edge of the same tile in the cross axis.
|
||||
final double childCrossAxisExtent;
|
||||
|
||||
const SliverTransitionGridTileLayoutSettings({
|
||||
@required this.crossAxisCount,
|
||||
@required this.mainAxisStride,
|
||||
@required this.crossAxisStride,
|
||||
@required this.childMainAxisExtent,
|
||||
@required this.childCrossAxisExtent,
|
||||
}) : assert(crossAxisCount != null && crossAxisCount > 0),
|
||||
assert(mainAxisStride != null && mainAxisStride >= 0),
|
||||
assert(crossAxisStride != null && crossAxisStride >= 0),
|
||||
assert(childMainAxisExtent != null && childMainAxisExtent >= 0),
|
||||
assert(childCrossAxisExtent != null && childCrossAxisExtent >= 0);
|
||||
}
|
||||
|
||||
class SliverTransitionGridTileLayout extends SliverGridLayout {
|
||||
/// Creates a layout that uses equally sized and spaced tiles.
|
||||
///
|
||||
/// All of the arguments must not be null and must not be negative. The
|
||||
/// `crossAxisCount` argument must be greater than zero.
|
||||
const SliverTransitionGridTileLayout({
|
||||
@required this.current,
|
||||
this.floor,
|
||||
this.ceil,
|
||||
this.t = 0,
|
||||
@required this.reverseCrossAxis,
|
||||
}) : assert(reverseCrossAxis != null);
|
||||
|
||||
final SliverTransitionGridTileLayoutSettings current, floor, ceil;
|
||||
final double t;
|
||||
|
||||
/// Whether the children should be placed in the opposite order of increasing
|
||||
/// coordinates in the cross axis.
|
||||
///
|
||||
/// For example, if the cross axis is horizontal, the children are placed from
|
||||
/// left to right when [reverseCrossAxis] is false and from right to left when
|
||||
/// [reverseCrossAxis] is true.
|
||||
///
|
||||
/// Typically set to the return value of [axisDirectionIsReversed] applied to
|
||||
/// the [SliverConstraints.crossAxisDirection].
|
||||
final bool reverseCrossAxis;
|
||||
|
||||
@override
|
||||
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||
final settings = t == 0 ? current : floor;
|
||||
final index = settings.mainAxisStride > 0.0 ? (settings.crossAxisCount * (scrollOffset ~/ settings.mainAxisStride)).floor() : 0;
|
||||
return index;
|
||||
}
|
||||
|
||||
@override
|
||||
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||
final settings = t == 0 ? current : floor;
|
||||
if (settings.mainAxisStride > 0.0) {
|
||||
final int mainAxisCount = (scrollOffset / settings.mainAxisStride).ceil();
|
||||
final index = math.max(0, settings.crossAxisCount * mainAxisCount - 1).ceil();
|
||||
return index;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
double _getScrollOffset(int index, SliverTransitionGridTileLayoutSettings settings) {
|
||||
return (index ~/ settings.crossAxisCount) * settings.mainAxisStride;
|
||||
}
|
||||
|
||||
double _getCrossAxisOffset(int index, SliverTransitionGridTileLayoutSettings settings) {
|
||||
final double crossAxisStart = (index % settings.crossAxisCount) * settings.crossAxisStride;
|
||||
if (reverseCrossAxis) {
|
||||
return settings.crossAxisCount * settings.crossAxisStride - crossAxisStart - settings.childCrossAxisExtent - (settings.crossAxisStride - settings.childCrossAxisExtent);
|
||||
}
|
||||
return crossAxisStart;
|
||||
}
|
||||
|
||||
@override
|
||||
SliverGridGeometry getGeometryForChildIndex(int index) {
|
||||
return SliverGridGeometry(
|
||||
scrollOffset: t == 0 ? _getScrollOffset(index, current) : lerpDouble(_getScrollOffset(index, floor), _getScrollOffset(index, ceil), t),
|
||||
crossAxisOffset: t == 0 ? _getCrossAxisOffset(index, current) : lerpDouble(_getCrossAxisOffset(index, floor), _getCrossAxisOffset(index, ceil), t),
|
||||
mainAxisExtent: current.childMainAxisExtent,
|
||||
crossAxisExtent: current.childCrossAxisExtent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMaxScrollOffset(int childCount) {
|
||||
assert(childCount != null);
|
||||
|
||||
if (t != 0) {
|
||||
final index = childCount - 1;
|
||||
var maxScrollOffset = lerpDouble(_getScrollOffset(index, floor), _getScrollOffset(index, ceil), t) + current.mainAxisStride;
|
||||
return maxScrollOffset;
|
||||
}
|
||||
|
||||
final int mainAxisCount = ((childCount - 1) ~/ current.crossAxisCount) + 1;
|
||||
final double mainAxisSpacing = current.mainAxisStride - current.childMainAxisExtent;
|
||||
final maxScrollOffset = current.mainAxisStride * mainAxisCount - mainAxisSpacing;
|
||||
return maxScrollOffset;
|
||||
}
|
||||
}
|
|
@ -19,8 +19,9 @@ class Thumbnail extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final image = ImagePreview(
|
||||
entry: entry,
|
||||
width: extent,
|
||||
height: extent,
|
||||
// TODO TLAD smarter sizing, but shouldn't only depend on `extent` so that it doesn't reload during gridview scaling
|
||||
width: 50,
|
||||
height: 50,
|
||||
builder: (bytes) {
|
||||
return Hero(
|
||||
tag: entry.uri,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/image_collection.dart';
|
||||
import 'package:aves/widgets/album/collection_section.dart';
|
||||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
||||
|
@ -7,6 +9,7 @@ import 'package:provider/provider.dart';
|
|||
class ThumbnailCollection extends StatelessWidget {
|
||||
final Widget appBar;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final ValueNotifier<double> _columnCountNotifier = ValueNotifier(4);
|
||||
|
||||
ThumbnailCollection({
|
||||
Key key,
|
||||
|
@ -29,13 +32,32 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
final scrollView = CustomScrollView(
|
||||
return SafeArea(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.viewInsets.bottom,
|
||||
builder: (c, mqViewInsetsBottom, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _columnCountNotifier,
|
||||
builder: (context, columnCount, child) => GridScaleGestureDetector(
|
||||
columnCountNotifier: _columnCountNotifier,
|
||||
child: DraggableScrollbar(
|
||||
heightScrollThumb: 48,
|
||||
backgroundColor: Colors.white,
|
||||
scrollThumbBuilder: _thumbArrowBuilder(false),
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
// padding to get scroll thumb below app bar, above nav bar
|
||||
top: topPadding,
|
||||
bottom: mqViewInsetsBottom,
|
||||
),
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
if (appBar != null) appBar,
|
||||
...sectionKeys.map((sectionKey) => SectionSliver(
|
||||
collection: collection,
|
||||
sectionKey: sectionKey,
|
||||
columnCount: columnCount,
|
||||
)),
|
||||
SliverToBoxAdapter(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
|
@ -46,23 +68,9 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return SafeArea(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.viewInsets.bottom,
|
||||
builder: (c, mqViewInsetsBottom, child) {
|
||||
return DraggableScrollbar(
|
||||
heightScrollThumb: 48,
|
||||
backgroundColor: Colors.white,
|
||||
scrollThumbBuilder: _thumbArrowBuilder(false),
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
// padding to get scroll thumb below app bar, above nav bar
|
||||
top: topPadding,
|
||||
bottom: mqViewInsetsBottom,
|
||||
),
|
||||
child: scrollView,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -112,3 +120,41 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
30
pubspec.lock
30
pubspec.lock
|
@ -105,7 +105,7 @@ packages:
|
|||
name: flutter_plugin_android_lifecycle
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
version: "1.0.6"
|
||||
flutter_staggered_grid_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -118,17 +118,17 @@ packages:
|
|||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: d30ab5fbe87f590fdf16201e8195e8449344804f
|
||||
resolved-ref: "14be154f50f5d14e88cc05b93b12377012b8905a"
|
||||
url: "git://github.com/deckerst/flutter_sticky_header.git"
|
||||
source: git
|
||||
version: "0.4.0"
|
||||
version: "0.4.2"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.17.1"
|
||||
version: "0.17.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
@ -152,7 +152,7 @@ packages:
|
|||
name: google_maps_flutter
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.23+1"
|
||||
version: "0.5.24+1"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -236,7 +236,7 @@ packages:
|
|||
name: pdf
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.5.0"
|
||||
pedantic:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -250,7 +250,7 @@ packages:
|
|||
name: permission_handler
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.2.0+hotfix.3"
|
||||
version: "4.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -306,28 +306,28 @@ packages:
|
|||
name: shared_preferences
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.6+1"
|
||||
version: "0.5.6+2"
|
||||
shared_preferences_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+5"
|
||||
version: "0.0.1+6"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "1.0.3"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.2+3"
|
||||
version: "0.1.2+4"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
@ -346,7 +346,7 @@ packages:
|
|||
name: sqflite
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.2.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -430,21 +430,21 @@ packages:
|
|||
name: video_player
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.10.7"
|
||||
version: "0.10.8+1"
|
||||
video_player_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
version: "1.0.5"
|
||||
video_player_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
version: "0.1.2+1"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
Loading…
Reference in a new issue