From aa697f3a379ce2fe6dc33c4ecb0e5f66f092f802 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 3 Mar 2020 15:16:33 +0900 Subject: [PATCH] album: scale gesture to change column count --- lib/main.dart | 2 +- lib/widgets/album/collection_section.dart | 8 +- .../sliver_transition_grid_delegate.dart | 176 ++++++++++++++++++ lib/widgets/album/thumbnail.dart | 5 +- lib/widgets/album/thumbnail_collection.dart | 104 ++++++++--- pubspec.lock | 30 +-- 6 files changed, 274 insertions(+), 51 deletions(-) create mode 100644 lib/widgets/album/sliver_transition_grid_delegate.dart diff --git a/lib/main.dart b/lib/main.dart index 71944c4bf..b80a2695b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -64,7 +64,7 @@ class _HomePageState extends State { 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()); diff --git a/lib/widgets/album/collection_section.dart b/lib/widgets/album/collection_section.dart index 1566eeecd..91df3ec3b 100644 --- a/lib/widgets/album/collection_section.dart +++ b/lib/widgets/album/collection_section.dart @@ -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, ), ); diff --git a/lib/widgets/album/sliver_transition_grid_delegate.dart b/lib/widgets/album/sliver_transition_grid_delegate.dart new file mode 100644 index 000000000..90ea0d1be --- /dev/null +++ b/lib/widgets/album/sliver_transition_grid_delegate.dart @@ -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; + } +} diff --git a/lib/widgets/album/thumbnail.dart b/lib/widgets/album/thumbnail.dart index 3268ad332..a33e45dfb 100644 --- a/lib/widgets/album/thumbnail.dart +++ b/lib/widgets/album/thumbnail.dart @@ -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, diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index 77770a7a4..fd98c6811 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -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 _columnCountNotifier = ValueNotifier(4); ThumbnailCollection({ Key key, @@ -29,40 +32,45 @@ class ThumbnailCollection extends StatelessWidget { } } - final scrollView = CustomScrollView( - controller: _scrollController, - slivers: [ - if (appBar != null) appBar, - ...sectionKeys.map((sectionKey) => SectionSliver( - collection: collection, - sectionKey: sectionKey, - )), - SliverToBoxAdapter( - child: Selector( - selector: (c, mq) => mq.viewInsets.bottom, - builder: (c, mqViewInsetsBottom, child) { - return SizedBox(height: mqViewInsetsBottom); - }, - ), - ), - ], - ); - return SafeArea( child: Selector( 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, + 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( + selector: (c, mq) => mq.viewInsets.bottom, + builder: (c, mqViewInsetsBottom, child) { + return SizedBox(height: mqViewInsetsBottom); + }, + ), + ), + ], + ), + ), ), - child: scrollView, ); }, ), @@ -112,3 +120,41 @@ class ThumbnailCollection extends StatelessWidget { }; } } + +class GridScaleGestureDetector extends StatefulWidget { + final ValueNotifier columnCountNotifier; + final Widget child; + + const GridScaleGestureDetector({ + @required this.columnCountNotifier, + @required this.child, + }); + + @override + _GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState(); +} + +class _GridScaleGestureDetectorState extends State { + double _start; + + ValueNotifier 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); + } +} diff --git a/pubspec.lock b/pubspec.lock index 505ffcfca..ee2491e40 100644 --- a/pubspec.lock +++ b/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: