album: scale gesture to change column count

This commit is contained in:
Thibault Deckers 2020-03-03 15:16:33 +09:00
parent 4b9625afea
commit aa697f3a37
6 changed files with 274 additions and 51 deletions

View file

@ -64,7 +64,7 @@ class _HomePageState extends State<HomePage> {
final permissions = await PermissionHandler().requestPermissions([ final permissions = await PermissionHandler().requestPermissions([
PermissionGroup.storage, PermissionGroup.storage,
// unredacted EXIF with scoped storage (Android 10+) // unredacted EXIF with scoped storage (Android 10+)
PermissionGroup.access_media_location, PermissionGroup.accessMediaLocation,
]); // 350ms ]); // 350ms
if (permissions[PermissionGroup.storage] != PermissionStatus.granted) { if (permissions[PermissionGroup.storage] != PermissionStatus.granted) {
unawaited(SystemNavigator.pop()); unawaited(SystemNavigator.pop());

View file

@ -1,6 +1,7 @@
import 'package:aves/model/image_collection.dart'; import 'package:aves/model/image_collection.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/album/sections.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/thumbnail.dart';
import 'package:aves/widgets/album/transparent_material_page_route.dart'; import 'package:aves/widgets/album/transparent_material_page_route.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
@ -12,13 +13,13 @@ import 'package:provider/provider.dart';
class SectionSliver extends StatelessWidget { class SectionSliver extends StatelessWidget {
final ImageCollection collection; final ImageCollection collection;
final dynamic sectionKey; final dynamic sectionKey;
final double columnCount;
static const columnCount = 4;
const SectionSliver({ const SectionSliver({
Key key, Key key,
@required this.collection, @required this.collection,
@required this.sectionKey, @required this.sectionKey,
@required this.columnCount,
}) : super(key: key); }) : super(key: key);
@override @override
@ -50,8 +51,7 @@ class SectionSliver extends StatelessWidget {
addAutomaticKeepAlives: false, addAutomaticKeepAlives: false,
addRepaintBoundaries: true, addRepaintBoundaries: true,
), ),
// TODO TLAD custom SliverGridDelegate / SliverGridLayout to lerp between columnCount gridDelegate: SliverTransitionGridDelegateWithCrossAxisCount(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columnCount, crossAxisCount: columnCount,
), ),
); );

View 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;
}
}

View file

@ -19,8 +19,9 @@ class Thumbnail extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final image = ImagePreview( final image = ImagePreview(
entry: entry, entry: entry,
width: extent, // TODO TLAD smarter sizing, but shouldn't only depend on `extent` so that it doesn't reload during gridview scaling
height: extent, width: 50,
height: 50,
builder: (bytes) { builder: (bytes) {
return Hero( return Hero(
tag: entry.uri, tag: entry.uri,

View file

@ -1,3 +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_section.dart'; import 'package:aves/widgets/album/collection_section.dart';
import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart';
@ -7,6 +9,7 @@ import 'package:provider/provider.dart';
class ThumbnailCollection extends StatelessWidget { class ThumbnailCollection extends StatelessWidget {
final Widget appBar; final Widget appBar;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final ValueNotifier<double> _columnCountNotifier = ValueNotifier(4);
ThumbnailCollection({ ThumbnailCollection({
Key key, 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, controller: _scrollController,
slivers: [ slivers: [
if (appBar != null) appBar, if (appBar != null) appBar,
...sectionKeys.map((sectionKey) => SectionSliver( ...sectionKeys.map((sectionKey) => SectionSliver(
collection: collection, collection: collection,
sectionKey: sectionKey, sectionKey: sectionKey,
columnCount: columnCount,
)), )),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Selector<MediaQueryData, double>( 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);
}
}

View file

@ -105,7 +105,7 @@ packages:
name: flutter_plugin_android_lifecycle name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.5" version: "1.0.6"
flutter_staggered_grid_view: flutter_staggered_grid_view:
dependency: "direct main" dependency: "direct main"
description: description:
@ -118,17 +118,17 @@ packages:
description: description:
path: "." path: "."
ref: HEAD ref: HEAD
resolved-ref: d30ab5fbe87f590fdf16201e8195e8449344804f resolved-ref: "14be154f50f5d14e88cc05b93b12377012b8905a"
url: "git://github.com/deckerst/flutter_sticky_header.git" url: "git://github.com/deckerst/flutter_sticky_header.git"
source: git source: git
version: "0.4.0" version: "0.4.2"
flutter_svg: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_svg name: flutter_svg
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.17.1" version: "0.17.2"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -152,7 +152,7 @@ packages:
name: google_maps_flutter name: google_maps_flutter
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.23+1" version: "0.5.24+1"
image: image:
dependency: transitive dependency: transitive
description: description:
@ -236,7 +236,7 @@ packages:
name: pdf name: pdf
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.4.1" version: "1.5.0"
pedantic: pedantic:
dependency: "direct main" dependency: "direct main"
description: description:
@ -250,7 +250,7 @@ packages:
name: permission_handler name: permission_handler
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.2.0+hotfix.3" version: "4.3.0"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@ -306,28 +306,28 @@ packages:
name: shared_preferences name: shared_preferences
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.6+1" version: "0.5.6+2"
shared_preferences_macos: shared_preferences_macos:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_macos name: shared_preferences_macos
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.1+5" version: "0.0.1+6"
shared_preferences_platform_interface: shared_preferences_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" version: "1.0.3"
shared_preferences_web: shared_preferences_web:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_web name: shared_preferences_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.2+3" version: "0.1.2+4"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -346,7 +346,7 @@ packages:
name: sqflite name: sqflite
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.0" version: "1.2.1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -430,21 +430,21 @@ packages:
name: video_player name: video_player
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.10.7" version: "0.10.8+1"
video_player_platform_interface: video_player_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: video_player_platform_interface name: video_player_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.4" version: "1.0.5"
video_player_web: video_player_web:
dependency: transitive dependency: transitive
description: description:
name: video_player_web name: video_player_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.2" version: "0.1.2+1"
xml: xml:
dependency: transitive dependency: transitive
description: description: