#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", "settingsViewerShowInformation": "Show information",
"settingsViewerShowInformationSubtitle": "Show title, date, location, etc.", "settingsViewerShowInformationSubtitle": "Show title, date, location, etc.",
"settingsViewerShowShootingDetails": "Show shooting details", "settingsViewerShowShootingDetails": "Show shooting details",
"settingsViewerShowOverlayThumbnailPreview": "Show thumbnail preview", "settingsViewerShowOverlayThumbnails": "Show thumbnails",
"settingsViewerEnableOverlayBlurEffect": "Blur effect", "settingsViewerEnableOverlayBlurEffect": "Blur effect",
"settingsVideoPageTitle": "Video Settings", "settingsVideoPageTitle": "Video Settings",

View file

@ -62,7 +62,8 @@ class Durations {
static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const doubleBackTimerDelay = Duration(milliseconds: 1000);
static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
static const searchDebounceDelay = Duration(milliseconds: 250); 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 mapInfoDebounceDelay = Duration(milliseconds: 150);
static const mapIdleDebounceDelay = Duration(milliseconds: 100); static const mapIdleDebounceDelay = Duration(milliseconds: 100);
} }

View file

@ -227,11 +227,6 @@ class Constants {
license: 'MIT', license: 'MIT',
sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations', 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( Dependency(
name: 'Material Design Icons Flutter', name: 'Material Design Icons Flutter',
license: 'MIT', license: 'MIT',

View file

@ -51,7 +51,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main); final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main);
late Future<void> _appSetup; late Future<void> _appSetup;
final _mediaStoreSource = MediaStoreSource(); final _mediaStoreSource = MediaStoreSource();
final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay); final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.mediaContentChangeDebounceDelay);
final Set<String> changedUris = {}; final Set<String> changedUris = {};
// observers are not registered when using the same list object with different items // 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/entry.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.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/grid/theme.dart';
import 'package:aves/widgets/common/thumbnail/decorated.dart'; import 'package:aves/widgets/common/thumbnail/decorated.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:known_extents_list_view_builder/known_extents_list_view_builder.dart';
class ThumbnailScroller extends StatefulWidget { class ThumbnailScroller extends StatefulWidget {
final double availableWidth; final double availableWidth;
@ -38,8 +38,9 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
late ScrollController _scrollController; late ScrollController _scrollController;
bool _isAnimating = false, _isScrolling = false; bool _isAnimating = false, _isScrolling = false;
static const double extent = 48; static const double thumbnailExtent = 48;
static const double separatorWidth = 2; static const double separatorWidth = 2;
static const double itemExtent = thumbnailExtent + separatorWidth;
int get entryCount => widget.entryCount; int get entryCount => widget.entryCount;
@ -82,76 +83,74 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final marginWidth = max(0.0, (widget.availableWidth - extent) / 2 - separatorWidth); final marginWidth = max(0.0, (widget.availableWidth - thumbnailExtent) / 2 - separatorWidth);
final horizontalMargin = SizedBox(width: marginWidth); final padding = EdgeInsets.only(left: marginWidth + separatorWidth, right: marginWidth);
const regularExtent = extent + separatorWidth;
final itemExtents = List.generate(entryCount, (index) => regularExtent)
..insert(entryCount, marginWidth)
..insert(0, marginWidth + separatorWidth);
return GridTheme( return GridTheme(
extent: extent, extent: thumbnailExtent,
showLocation: widget.showLocation && settings.showThumbnailLocation, showLocation: widget.showLocation && settings.showThumbnailLocation,
showTrash: false, showTrash: false,
child: SizedBox( child: SizedBox(
height: extent, height: thumbnailExtent,
// as of Flutter v2.10.2, using `jumpTo` with a `ListView` is prohibitively inefficient child: ListView.builder(
// for large lists of items with variable height, so we use a `KnownExtentsListView` instead
child: KnownExtentsListView.builder(
itemExtents: itemExtents,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
controller: _scrollController, controller: _scrollController,
// default padding in scroll direction matches `MediaQuery.viewPadding`, // as of Flutter v2.10.2, `FixedExtentScrollController` can only be used with `ListWheelScrollView`
// but we already accommodate for it, so make sure horizontal padding is 0 // and `FixedExtentScrollPhysics` can only be used with Scrollables that uses the `FixedExtentScrollController`
padding: EdgeInsets.zero, // so we use `KnownExtentScrollPhysics`, adapted from `FixedExtentScrollPhysics` without the constraints
itemBuilder: (context, index) { physics: KnownExtentScrollPhysics(
if (index == 0 || index == entryCount + 1) return horizontalMargin; indexToScrollOffset: indexToScrollOffset,
final page = index - 1; scrollOffsetToIndex: scrollOffsetToIndex,
final pageEntry = widget.entryBuilder(page); ),
if (pageEntry == null) return const SizedBox(); padding: padding,
itemExtent: itemExtent,
return Stack( itemBuilder: (context, index) => _buildThumbnail(index),
children: [ itemCount: entryCount,
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,
), ),
), ),
); );
} }
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 { Future<void> _goTo(int index) async {
final targetOffset = indexToScrollOffset(index); final targetOffset = indexToScrollOffset(index);
final offsetDelta = (targetOffset - _scrollController.offset).abs(); final offsetDelta = (targetOffset - _scrollController.offset).abs();
@ -189,7 +188,7 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
_isScrolling = false; _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( builder: (context, current, child) => SwitchListTile(
value: current, value: current,
onChanged: (v) => settings.showOverlayThumbnailPreview = v, onChanged: (v) => settings.showOverlayThumbnailPreview = v,
title: Text(context.l10n.settingsViewerShowOverlayThumbnailPreview), title: Text(context.l10n.settingsViewerShowOverlayThumbnails),
), ),
), ),
Selector<Settings, bool>( Selector<Settings, bool>(

View file

@ -23,7 +23,7 @@ class ViewerThumbnailPreview extends StatefulWidget {
class _ViewerThumbnailPreviewState extends State<ViewerThumbnailPreview> { class _ViewerThumbnailPreviewState extends State<ViewerThumbnailPreview> {
final ValueNotifier<int> _entryIndexNotifier = ValueNotifier(0); 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; List<AvesEntry> get entries => widget.entries;

View file

@ -525,13 +525,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.3" 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: latlong2:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -49,7 +49,6 @@ dependencies:
google_api_availability: google_api_availability:
google_maps_flutter: google_maps_flutter:
intl: intl:
known_extents_list_view_builder:
latlong2: latlong2:
material_design_icons_flutter: material_design_icons_flutter:
overlay_support: overlay_support:
@ -142,6 +141,9 @@ flutter:
# `EagerScaleGestureRecognizer` in `/widgets/common/behaviour/eager_scale_gesture_recognizer.dart` # `EagerScaleGestureRecognizer` in `/widgets/common/behaviour/eager_scale_gesture_recognizer.dart`
# adapts from Flutter `ScaleGestureRecognizer` in `/gestures/scale.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` # `TransitionImage` in `/widgets/common/fx/transition_image.dart`
# adapts from Flutter `RawImage` in `/widgets/basic.dart` and `DecorationImagePainter` in `/painting/decoration_image.dart` # adapts from Flutter `RawImage` in `/widgets/basic.dart` and `DecorationImagePainter` in `/painting/decoration_image.dart`
# #