From b03e997dbaba06893b5375f0261cdde0545ec0df Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 1 Mar 2022 10:24:16 +0900 Subject: [PATCH] #193 viewer: thumbnails scroll snap, debounce fixes --- lib/l10n/app_en.arb | 2 +- lib/theme/durations.dart | 3 +- lib/utils/constants.dart | 5 - lib/widgets/aves_app.dart | 2 +- .../known_extent_scroll_physics.dart | 83 ++++++++++++ lib/widgets/common/thumbnail/scroller.dart | 123 +++++++++--------- lib/widgets/settings/viewer/overlay.dart | 2 +- .../overlay/bottom/thumbnail_preview.dart | 2 +- pubspec.lock | 7 - pubspec.yaml | 4 +- 10 files changed, 153 insertions(+), 80 deletions(-) create mode 100644 lib/widgets/common/behaviour/known_extent_scroll_physics.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 45e47689a..ccb5852b2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -613,7 +613,7 @@ "settingsViewerShowInformation": "Show information", "settingsViewerShowInformationSubtitle": "Show title, date, location, etc.", "settingsViewerShowShootingDetails": "Show shooting details", - "settingsViewerShowOverlayThumbnailPreview": "Show thumbnail preview", + "settingsViewerShowOverlayThumbnails": "Show thumbnails", "settingsViewerEnableOverlayBlurEffect": "Blur effect", "settingsVideoPageTitle": "Video Settings", diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 4c91a1685..6e9b05903 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -62,7 +62,8 @@ class Durations { static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const searchDebounceDelay = Duration(milliseconds: 250); - static const contentChangeDebounceDelay = Duration(milliseconds: 1000); + static const mediaContentChangeDebounceDelay = Duration(milliseconds: 1000); + static const viewerThumbnailScrollDebounceDelay = Duration(milliseconds: 1000); static const mapInfoDebounceDelay = Duration(milliseconds: 150); static const mapIdleDebounceDelay = Duration(milliseconds: 100); } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 500967f63..9d8266c28 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -227,11 +227,6 @@ class Constants { license: 'MIT', sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations', ), - Dependency( - name: 'Known Extents List View Builder', - license: 'BSD 3-Clause', - sourceUrl: 'https://github.com/bendelonlee/known_extents_list_view_builder', - ), Dependency( name: 'Material Design Icons Flutter', license: 'MIT', diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index db11d41c1..387338698 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -51,7 +51,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { final ValueNotifier appModeNotifier = ValueNotifier(AppMode.main); late Future _appSetup; final _mediaStoreSource = MediaStoreSource(); - final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay); + final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.mediaContentChangeDebounceDelay); final Set changedUris = {}; // observers are not registered when using the same list object with different items diff --git a/lib/widgets/common/behaviour/known_extent_scroll_physics.dart b/lib/widgets/common/behaviour/known_extent_scroll_physics.dart new file mode 100644 index 000000000..0dce20b84 --- /dev/null +++ b/lib/widgets/common/behaviour/known_extent_scroll_physics.dart @@ -0,0 +1,83 @@ +import 'package:flutter/physics.dart'; +import 'package:flutter/widgets.dart'; + +// adapted from Flutter `FixedExtentScrollPhysics` in `/widgets/list_wheel_scroll_view.dart` +class KnownExtentScrollPhysics extends ScrollPhysics { + final double Function(int index) indexToScrollOffset; + final int Function(double offset) scrollOffsetToIndex; + + const KnownExtentScrollPhysics({ + required this.indexToScrollOffset, + required this.scrollOffsetToIndex, + ScrollPhysics? parent, + }) : super(parent: parent); + + @override + KnownExtentScrollPhysics applyTo(ScrollPhysics? ancestor) { + return KnownExtentScrollPhysics( + indexToScrollOffset: indexToScrollOffset, + scrollOffsetToIndex: scrollOffsetToIndex, + parent: buildParent(ancestor), + ); + } + + @override + Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { + final ScrollMetrics metrics = position; + + // Scenario 1: + // If we're out of range and not headed back in range, defer to the parent + // ballistics, which should put us back in range at the scrollable's boundary. + if ((velocity <= 0.0 && metrics.pixels <= metrics.minScrollExtent) || (velocity >= 0.0 && metrics.pixels >= metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + // Create a test simulation to see where it would have ballistically fallen + // naturally without settling onto items. + final Simulation? testFrictionSimulation = super.createBallisticSimulation(metrics, velocity); + + // Scenario 2: + // If it was going to end up past the scroll extent, defer back to the + // parent physics' ballistics again which should put us on the scrollable's + // boundary. + if (testFrictionSimulation != null && (testFrictionSimulation.x(double.infinity) == metrics.minScrollExtent || testFrictionSimulation.x(double.infinity) == metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + // From the natural final position, find the nearest item it should have + // settled to. + final offset = (testFrictionSimulation?.x(double.infinity) ?? metrics.pixels).clamp(metrics.minScrollExtent, metrics.maxScrollExtent); + final int settlingItemIndex = scrollOffsetToIndex(offset); + final double settlingPixels = indexToScrollOffset(settlingItemIndex); + + // Scenario 3: + // If there's no velocity and we're already at where we intend to land, + // do nothing. + if (velocity.abs() < tolerance.velocity && (settlingPixels - metrics.pixels).abs() < tolerance.distance) { + return null; + } + + // Scenario 4: + // If we're going to end back at the same item because initial velocity + // is too low to break past it, use a spring simulation to get back. + if (settlingItemIndex == scrollOffsetToIndex(metrics.pixels)) { + return SpringSimulation( + spring, + metrics.pixels, + settlingPixels, + velocity, + tolerance: tolerance, + ); + } + + // Scenario 5: + // Create a new friction simulation except the drag will be tweaked to land + // exactly on the item closest to the natural stopping point. + return FrictionSimulation.through( + metrics.pixels, + settlingPixels, + velocity, + tolerance.velocity * velocity.sign, + ); + } +} diff --git a/lib/widgets/common/thumbnail/scroller.dart b/lib/widgets/common/thumbnail/scroller.dart index ab59d3764..2894b3ded 100644 --- a/lib/widgets/common/thumbnail/scroller.dart +++ b/lib/widgets/common/thumbnail/scroller.dart @@ -3,10 +3,10 @@ import 'dart:math'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/behaviour/known_extent_scroll_physics.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/thumbnail/decorated.dart'; import 'package:flutter/material.dart'; -import 'package:known_extents_list_view_builder/known_extents_list_view_builder.dart'; class ThumbnailScroller extends StatefulWidget { final double availableWidth; @@ -38,8 +38,9 @@ class _ThumbnailScrollerState extends State { late ScrollController _scrollController; bool _isAnimating = false, _isScrolling = false; - static const double extent = 48; + static const double thumbnailExtent = 48; static const double separatorWidth = 2; + static const double itemExtent = thumbnailExtent + separatorWidth; int get entryCount => widget.entryCount; @@ -82,76 +83,74 @@ class _ThumbnailScrollerState extends State { @override Widget build(BuildContext context) { - final marginWidth = max(0.0, (widget.availableWidth - extent) / 2 - separatorWidth); - final horizontalMargin = SizedBox(width: marginWidth); - - const regularExtent = extent + separatorWidth; - final itemExtents = List.generate(entryCount, (index) => regularExtent) - ..insert(entryCount, marginWidth) - ..insert(0, marginWidth + separatorWidth); + final marginWidth = max(0.0, (widget.availableWidth - thumbnailExtent) / 2 - separatorWidth); + final padding = EdgeInsets.only(left: marginWidth + separatorWidth, right: marginWidth); return GridTheme( - extent: extent, + extent: thumbnailExtent, showLocation: widget.showLocation && settings.showThumbnailLocation, showTrash: false, child: SizedBox( - height: extent, - // as of Flutter v2.10.2, using `jumpTo` with a `ListView` is prohibitively inefficient - // for large lists of items with variable height, so we use a `KnownExtentsListView` instead - child: KnownExtentsListView.builder( - itemExtents: itemExtents, + height: thumbnailExtent, + child: ListView.builder( scrollDirection: Axis.horizontal, controller: _scrollController, - // default padding in scroll direction matches `MediaQuery.viewPadding`, - // but we already accommodate for it, so make sure horizontal padding is 0 - padding: EdgeInsets.zero, - itemBuilder: (context, index) { - if (index == 0 || index == entryCount + 1) return horizontalMargin; - final page = index - 1; - final pageEntry = widget.entryBuilder(page); - if (pageEntry == null) return const SizedBox(); - - return Stack( - children: [ - GestureDetector( - onTap: () { - indexNotifier.value = page; - widget.onTap?.call(page); - }, - child: DecoratedThumbnail( - entry: pageEntry, - tileExtent: extent, - // the retrieval task queue can pile up for thumbnails of heavy pages - // (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers) - // so we cancel these requests when possible - cancellableNotifier: _cancellableNotifier, - selectable: false, - highlightable: widget.highlightable, - heroTagger: () => widget.heroTagger?.call(pageEntry), - ), - ), - IgnorePointer( - child: ValueListenableBuilder( - valueListenable: indexNotifier, - builder: (context, currentIndex, child) { - return AnimatedContainer( - color: currentIndex == page ? Colors.transparent : Colors.black45, - width: extent, - height: extent, - duration: Durations.thumbnailScrollerShadeAnimation, - ); - }, - ), - ), - ], - ); - }, - itemCount: entryCount + 2, + // as of Flutter v2.10.2, `FixedExtentScrollController` can only be used with `ListWheelScrollView` + // and `FixedExtentScrollPhysics` can only be used with Scrollables that uses the `FixedExtentScrollController` + // so we use `KnownExtentScrollPhysics`, adapted from `FixedExtentScrollPhysics` without the constraints + physics: KnownExtentScrollPhysics( + indexToScrollOffset: indexToScrollOffset, + scrollOffsetToIndex: scrollOffsetToIndex, + ), + padding: padding, + itemExtent: itemExtent, + itemBuilder: (context, index) => _buildThumbnail(index), + itemCount: entryCount, ), ), ); } + Widget _buildThumbnail(int index) { + final pageEntry = widget.entryBuilder(index); + if (pageEntry == null) return const SizedBox(); + + return Stack( + children: [ + GestureDetector( + onTap: () { + indexNotifier.value = index; + widget.onTap?.call(index); + }, + child: DecoratedThumbnail( + entry: pageEntry, + tileExtent: thumbnailExtent, + // the retrieval task queue can pile up for thumbnails of heavy pages + // (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers) + // so we cancel these requests when possible + cancellableNotifier: _cancellableNotifier, + selectable: false, + highlightable: widget.highlightable, + heroTagger: () => widget.heroTagger?.call(pageEntry), + ), + ), + IgnorePointer( + child: ValueListenableBuilder( + valueListenable: indexNotifier, + builder: (context, currentIndex, child) { + return AnimatedContainer( + color: currentIndex == index ? Colors.transparent : Colors.black45, + width: thumbnailExtent, + height: thumbnailExtent, + duration: Durations.thumbnailScrollerShadeAnimation, + ); + }, + ), + ), + ], + ); + } + Future _goTo(int index) async { final targetOffset = indexToScrollOffset(index); final offsetDelta = (targetOffset - _scrollController.offset).abs(); @@ -189,7 +188,7 @@ class _ThumbnailScrollerState extends State { _isScrolling = false; } - double indexToScrollOffset(int index) => index * (extent + separatorWidth); + double indexToScrollOffset(int index) => index * itemExtent; - int scrollOffsetToIndex(double offset) => (offset / (extent + separatorWidth)).round(); + int scrollOffsetToIndex(double offset) => (offset / itemExtent).round(); } diff --git a/lib/widgets/settings/viewer/overlay.dart b/lib/widgets/settings/viewer/overlay.dart index f6f448003..8bd8238ce 100644 --- a/lib/widgets/settings/viewer/overlay.dart +++ b/lib/widgets/settings/viewer/overlay.dart @@ -80,7 +80,7 @@ class ViewerOverlayPage extends StatelessWidget { builder: (context, current, child) => SwitchListTile( value: current, onChanged: (v) => settings.showOverlayThumbnailPreview = v, - title: Text(context.l10n.settingsViewerShowOverlayThumbnailPreview), + title: Text(context.l10n.settingsViewerShowOverlayThumbnails), ), ), Selector( diff --git a/lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart b/lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart index f223cf4c6..d7888e712 100644 --- a/lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart +++ b/lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart @@ -23,7 +23,7 @@ class ViewerThumbnailPreview extends StatefulWidget { class _ViewerThumbnailPreviewState extends State { final ValueNotifier _entryIndexNotifier = ValueNotifier(0); - final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); + final Debouncer _debouncer = Debouncer(delay: Durations.viewerThumbnailScrollDebounceDelay); List get entries => widget.entries; diff --git a/pubspec.lock b/pubspec.lock index 413cc518c..532b1ddf8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -525,13 +525,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.3" - known_extents_list_view_builder: - dependency: "direct main" - description: - name: known_extents_list_view_builder - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" latlong2: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 29f9110f9..09ddc7bf4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,7 +49,6 @@ dependencies: google_api_availability: google_maps_flutter: intl: - known_extents_list_view_builder: latlong2: material_design_icons_flutter: overlay_support: @@ -142,6 +141,9 @@ flutter: # `EagerScaleGestureRecognizer` in `/widgets/common/behaviour/eager_scale_gesture_recognizer.dart` # adapts from Flutter `ScaleGestureRecognizer` in `/gestures/scale.dart` # +# `KnownExtentScrollPhysics` in `/widgets/common/behaviour/known_extent_scroll_physics.dart` +# adapts from Flutter `FixedExtentScrollPhysics` in `/widgets/list_wheel_scroll_view.dart` +# # `TransitionImage` in `/widgets/common/fx/transition_image.dart` # adapts from Flutter `RawImage` in `/widgets/basic.dart` and `DecorationImagePainter` in `/painting/decoration_image.dart` #