diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 3b71fc124..15d424e05 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -45,6 +45,7 @@ import 'package:aves/widgets/common/thumbnail/notifications.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; @@ -500,6 +501,8 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge return Selector, List>( selector: (context, layout) => layout.sectionLayouts, builder: (context, sectionLayouts, child) { + final scrollController = widget.scrollController; + final offsetIncrementSnapThreshold = context.select((v) => (v.extentNotifier.value + v.spacing) / 4); return DraggableScrollbar( backgroundColor: Colors.white, scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight), @@ -507,7 +510,23 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge height: avesScrollThumbHeight, backgroundColor: Colors.white, ), - controller: widget.scrollController, + controller: scrollController, + dragOffsetSnapper: (scrollOffset, offsetIncrement) { + if (offsetIncrement > offsetIncrementSnapThreshold && scrollOffset < scrollController.position.maxScrollExtent) { + final section = sectionLayouts.firstWhereOrNull((section) => section.hasChildAtOffset(scrollOffset)); + if (section != null) { + if (section.maxOffset - section.minOffset < scrollController.position.viewportDimension) { + // snap to section header + return section.minOffset; + } else { + // snap to content row + final index = section.getMinChildIndexForScrollOffset(scrollOffset); + return section.indexToLayoutOffset(index); + } + } + } + return scrollOffset; + }, crumbsBuilder: () => _getCrumbs(sectionLayouts), padding: EdgeInsets.only( // padding to keep scroll thumb between app bar above and nav bar below diff --git a/lib/widgets/common/basic/draggable_scrollbar/scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar/scrollbar.dart index 0813a93a3..ee6fdb0f2 100644 --- a/lib/widgets/common/basic/draggable_scrollbar/scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar/scrollbar.dart @@ -59,6 +59,8 @@ class DraggableScrollbar extends StatefulWidget { /// The ScrollController for the BoxScrollView final ScrollController controller; + final double Function(double scrollOffset, double offsetIncrement)? dragOffsetSnapper; + /// The view that will be scrolled with the scroll thumb final ScrollView child; @@ -68,6 +70,7 @@ class DraggableScrollbar extends StatefulWidget { required this.scrollThumbSize, required this.scrollThumbBuilder, required this.controller, + this.dragOffsetSnapper, this.crumbsBuilder, this.padding = EdgeInsets.zero, this.scrollbarAnimationDuration = const Duration(milliseconds: 300), @@ -114,6 +117,7 @@ class DraggableScrollbar extends StatefulWidget { class _DraggableScrollbarState extends State with TickerProviderStateMixin { final ValueNotifier _thumbOffsetNotifier = ValueNotifier(0), _viewOffsetNotifier = ValueNotifier(0); bool _isDragInProcess = false; + double _boundlessThumbOffset = 0, _offsetIncrement = 0; late Offset _longPressLastGlobalPosition; late AnimationController _thumbAnimationController; @@ -281,6 +285,8 @@ class _DraggableScrollbarState extends State with TickerProv void _onVerticalDragStart() { const DraggableScrollbarNotification(DraggableScrollbarEvent.dragStart).dispatch(context); + _boundlessThumbOffset = _thumbOffsetNotifier.value; + _offsetIncrement = 1 / thumbMaxScrollExtent * controller.position.maxScrollExtent; _labelAnimationController.forward(); _fadeoutTimer?.cancel(); _showThumb(); @@ -292,12 +298,14 @@ class _DraggableScrollbarState extends State with TickerProv _showThumb(); if (_isDragInProcess) { // thumb offset - _thumbOffsetNotifier.value = (_thumbOffsetNotifier.value + deltaY).clamp(thumbMinScrollExtent, thumbMaxScrollExtent); + _boundlessThumbOffset += deltaY; + _thumbOffsetNotifier.value = _boundlessThumbOffset.clamp(thumbMinScrollExtent, thumbMaxScrollExtent); // scroll offset final min = controller.position.minScrollExtent; final max = controller.position.maxScrollExtent; - controller.jumpTo((_thumbOffsetNotifier.value / thumbMaxScrollExtent * max).clamp(min, max)); + final scrollOffset = _thumbOffsetNotifier.value / thumbMaxScrollExtent * max; + controller.jumpTo((widget.dragOffsetSnapper?.call(scrollOffset, _offsetIncrement) ?? scrollOffset).clamp(min, max)); } }