diff --git a/lib/components/scroll.dart b/lib/components/scroll.dart index dd0df47..e780b1e 100644 --- a/lib/components/scroll.dart +++ b/lib/components/scroll.dart @@ -99,11 +99,13 @@ class _SmoothScrollProviderState extends State { ); if (_futurePosition == old) return; var target = _futurePosition!; - _controller.animateTo( + _controller + .animateTo( _futurePosition!, duration: _fastAnimationDuration, curve: Curves.linear, - ).then((_) { + ) + .then((_) { var current = _controller.position.pixels; if (current == target && current == _futurePosition) { _futurePosition = null; @@ -144,3 +146,169 @@ class ScrollControllerProvider extends InheritedWidget { return oldWidget.controller != controller; } } + +class AppScrollBar extends StatefulWidget { + const AppScrollBar({ + super.key, + required this.controller, + required this.child, + this.topPadding = 0, + }); + + final ScrollController controller; + + final Widget child; + + final double topPadding; + + @override + State createState() => _AppScrollBarState(); +} + +class _AppScrollBarState extends State { + late final ScrollController _scrollController; + + double minExtent = 0; + double maxExtent = 0; + double position = 0; + + double viewHeight = 0; + + final _scrollIndicatorSize = App.isDesktop ? 42.0 : 48.0; + + late final VerticalDragGestureRecognizer _dragGestureRecognizer; + + @override + void initState() { + super.initState(); + _scrollController = widget.controller; + _scrollController.addListener(onChanged); + Future.microtask(onChanged); + _dragGestureRecognizer = VerticalDragGestureRecognizer() + ..onUpdate = onUpdate; + } + + void onUpdate(DragUpdateDetails details) { + if (maxExtent - minExtent <= 0 || + viewHeight == 0 || + details.primaryDelta == null) { + return; + } + var offset = details.primaryDelta!; + var positionOffset = + offset / (viewHeight - _scrollIndicatorSize) * (maxExtent - minExtent); + _scrollController.jumpTo((position + positionOffset).clamp( + minExtent, + maxExtent, + )); + } + + void onChanged() { + if (_scrollController.positions.isEmpty) return; + var position = _scrollController.position; + if (position.minScrollExtent != minExtent || + position.maxScrollExtent != maxExtent || + position.pixels != this.position) { + setState(() { + minExtent = position.minScrollExtent; + maxExtent = position.maxScrollExtent; + this.position = position.pixels; + }); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constrains) { + var scrollHeight = (maxExtent - minExtent); + var height = constrains.maxHeight - widget.topPadding; + viewHeight = height; + var top = scrollHeight == 0 + ? 0.0 + : (position - minExtent) / + scrollHeight * + (height - _scrollIndicatorSize); + return Stack( + children: [ + Positioned.fill( + child: widget.child, + ), + Positioned( + top: top + widget.topPadding, + right: 0, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + _dragGestureRecognizer.addPointer(event); + }, + child: SizedBox( + width: _scrollIndicatorSize/2, + height: _scrollIndicatorSize, + child: CustomPaint( + painter: _ScrollIndicatorPainter( + backgroundColor: context.colorScheme.surface, + shadowColor: context.colorScheme.shadow, + ), + child: Column( + children: [ + const Spacer(), + Icon(Icons.arrow_drop_up, size: 18), + Icon(Icons.arrow_drop_down, size: 18), + const Spacer(), + ], + ).paddingLeft(4), + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _ScrollIndicatorPainter extends CustomPainter { + final Color backgroundColor; + + final Color shadowColor; + + const _ScrollIndicatorPainter({ + required this.backgroundColor, + required this.shadowColor, + }); + + @override + void paint(Canvas canvas, Size size) { + var path = Path() + ..moveTo(size.width, 0) + ..lineTo(size.width, size.height) + ..arcToPoint( + Offset(size.width, 0), + radius: Radius.circular(size.width), + ); + canvas.drawShadow(path, shadowColor, 4, true); + var backgroundPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.fill; + path = Path() + ..moveTo(size.width, 0) + ..lineTo(size.width, size.height) + ..arcToPoint( + Offset(size.width, 0), + radius: Radius.circular(size.width), + ); + canvas.drawPath(path, backgroundPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return oldDelegate is! _ScrollIndicatorPainter || + oldDelegate.backgroundColor != backgroundColor || + oldDelegate.shadowColor != shadowColor; + } +} diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart index d714be9..eea258a 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -518,11 +518,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { ), ], ); - body = Scrollbar( + body = AppScrollBar( + topPadding: 48, controller: scrollController, - thickness: App.isDesktop ? 8 : 12, - radius: const Radius.circular(8), - interactive: true, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: body,