#193 viewer: thumbnails scroll snap, debounce fixes
This commit is contained in:
parent
085f4b2eca
commit
b03e997dba
10 changed files with 153 additions and 80 deletions
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -51,7 +51,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
|
|||
final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main);
|
||||
late Future<void> _appSetup;
|
||||
final _mediaStoreSource = MediaStoreSource();
|
||||
final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
|
||||
final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.mediaContentChangeDebounceDelay);
|
||||
final Set<String> changedUris = {};
|
||||
|
||||
// observers are not registered when using the same list object with different items
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<ThumbnailScroller> {
|
|||
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<ThumbnailScroller> {
|
|||
|
||||
@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<int?>(
|
||||
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<int?>(
|
||||
valueListenable: indexNotifier,
|
||||
builder: (context, currentIndex, child) {
|
||||
return AnimatedContainer(
|
||||
color: currentIndex == index ? Colors.transparent : Colors.black45,
|
||||
width: thumbnailExtent,
|
||||
height: thumbnailExtent,
|
||||
duration: Durations.thumbnailScrollerShadeAnimation,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _goTo(int index) async {
|
||||
final targetOffset = indexToScrollOffset(index);
|
||||
final offsetDelta = (targetOffset - _scrollController.offset).abs();
|
||||
|
@ -189,7 +188,7 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
|
|||
_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();
|
||||
}
|
||||
|
|
|
@ -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<Settings, bool>(
|
||||
|
|
|
@ -23,7 +23,7 @@ class ViewerThumbnailPreview extends StatefulWidget {
|
|||
|
||||
class _ViewerThumbnailPreviewState extends State<ViewerThumbnailPreview> {
|
||||
final ValueNotifier<int> _entryIndexNotifier = ValueNotifier(0);
|
||||
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
|
||||
final Debouncer _debouncer = Debouncer(delay: Durations.viewerThumbnailScrollDebounceDelay);
|
||||
|
||||
List<AvesEntry> get entries => widget.entries;
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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`
|
||||
#
|
||||
|
|
Loading…
Reference in a new issue