#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",
|
"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",
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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/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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>(
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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`
|
||||||
#
|
#
|
||||||
|
|
Loading…
Reference in a new issue