#193 viewer: thumbnails scroll snap, debounce fixes

This commit is contained in:
Thibault Deckers 2022-03-01 10:24:16 +09:00
parent 085f4b2eca
commit b03e997dba
10 changed files with 153 additions and 80 deletions

View file

@ -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",

View file

@ -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);
}

View file

@ -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',

View file

@ -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

View file

@ -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,
);
}
}

View file

@ -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();
}

View file

@ -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>(

View file

@ -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;

View file

@ -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:

View file

@ -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`
#