Files
SpotiFLAC-Mobile/lib/widgets/library_fast_scroller.dart
T

421 lines
15 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
/// A draggable scroll thumb for long library lists, with a bubble naming the
/// section under the thumb (a letter or a month).
///
/// The thumb tracks the scroll position like a scrollbar. Dragging moves the
/// list by the finger's travel rather than jumping to where it touched, so
/// grabbing the thumb never moves the list by itself. Slow drags scrub with
/// finer control in large libraries; quick drags still cover the full range.
/// The thumb shows while scrolling and only for lists several screens long.
class LibraryFastScroller extends StatefulWidget {
const LibraryFastScroller({
super.key,
required this.child,
required this.labelAt,
this.onDragStart,
this.onDragEnd,
this.topPadding = 0,
this.bottomPadding = 0,
});
/// A scroll view. The scroller drives the [Scrollable] that reports scroll
/// notifications from it.
final Widget child;
/// Label for the row at [fraction] of the scrollable content, or null.
final String? Function(double fraction) labelAt;
final VoidCallback? onDragStart;
final VoidCallback? onDragEnd;
/// Height of the pinned header. While an outer scroll view is expanded,
/// only its overlap with this viewport is reserved.
final double topPadding;
final double bottomPadding;
@override
State<LibraryFastScroller> createState() => _LibraryFastScrollerState();
}
class _LibraryFastScrollerState extends State<LibraryFastScroller> {
static const _thumbHeight = 52.0;
static const _hitWidth = 32.0;
static const _minScreens = 4;
ScrollPosition? _position;
ScrollPosition? _outerPosition;
double _pixels = 0;
double _maxExtent = 0;
double _viewport = 0;
bool _visible = false;
bool _dragging = false;
double _lastDragDy = 0;
Duration? _lastDragTime;
double _dragTravel = 0;
double _dragDirection = 0;
Timer? _hideTimer;
bool get _long => _maxExtent > _viewport * _minScreens;
double get _fraction =>
_maxExtent <= 0 ? 0 : (_pixels / _maxExtent).clamp(0.0, 1.0);
@override
void didChangeDependencies() {
super.didChangeDependencies();
final outer = Scrollable.maybeOf(context, axis: Axis.vertical)?.position;
if (outer == _outerPosition) return;
_outerPosition?.removeListener(_onOuterPositionChanged);
_outerPosition = outer;
outer?.addListener(_onOuterPositionChanged);
}
void _onOuterPositionChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_hideTimer?.cancel();
_position?.removeListener(_onPositionChanged);
_outerPosition?.removeListener(_onOuterPositionChanged);
if (_dragging) {
final onDragEnd = widget.onDragEnd;
WidgetsBinding.instance.addPostFrameCallback((_) => onDragEnd?.call());
}
super.dispose();
}
void _attachPosition(BuildContext? context) {
if (context == null) return;
final position = Scrollable.maybeOf(context, axis: Axis.vertical)?.position;
if (position == null || position == _position) return;
_position?.removeListener(_onPositionChanged);
_position = position;
position.addListener(_onPositionChanged);
}
void _onPositionChanged() {
final position = _position;
if (!mounted || position == null || !position.hasContentDimensions) return;
_readMetrics(position, show: true);
}
void _readMetrics(ScrollMetrics metrics, {bool show = false}) {
setState(() {
_pixels = metrics.pixels;
_maxExtent = metrics.maxScrollExtent;
_viewport = metrics.viewportDimension;
if (show) _show();
});
}
bool _onMetricsNotification(ScrollMetricsNotification notification) {
if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) {
return false;
}
_attachPosition(notification.context);
_readMetrics(notification.metrics);
return false;
}
bool _onNotification(ScrollNotification notification) {
if (notification.depth != 0) return false;
final metrics = notification.metrics;
if (metrics.axis != Axis.vertical) return false;
_attachPosition(notification.context);
_readMetrics(metrics, show: notification is ScrollUpdateNotification);
return false;
}
void _show() {
_visible = true;
_hideTimer?.cancel();
if (!_dragging) {
_hideTimer = Timer(const Duration(milliseconds: 1500), () {
if (mounted && !_dragging) setState(() => _visible = false);
});
}
}
void _startDrag(DragStartDetails details) {
HapticFeedback.selectionClick();
_lastDragDy = details.globalPosition.dy;
_lastDragTime = details.sourceTimeStamp;
_dragTravel = 0;
_dragDirection = 0;
setState(() {
_dragging = true;
_show();
});
widget.onDragStart?.call();
}
void _updateDrag(DragUpdateDetails details, double trackHeight) {
final position = _position;
if (position == null || !position.hasContentDimensions) return;
final travel = math.max(1.0, trackHeight - _thumbHeight);
final dy = details.globalPosition.dy - _lastDragDy;
final timestamp = details.sourceTimeStamp;
final elapsed = timestamp != null && _lastDragTime != null
? (timestamp - _lastDragTime!).inMicroseconds / 1e6
: 0.0;
_lastDragDy = details.globalPosition.dy;
_lastDragTime = timestamp;
if (dy != 0 && dy.sign != _dragDirection) {
_dragTravel = 0;
_dragDirection = dy.sign;
}
_dragTravel += dy.abs();
final speed = dy.abs() / (elapsed > 0 ? elapsed : 1 / 60);
final coarse = position.maxScrollExtent / travel;
// Tiny movements must not skip hundreds of rows in a large library.
// Accelerate to scrollbar speed only for a deliberate, quick sweep.
final fine = math.min(coarse, 16.0);
final acceleration =
Curves.easeIn.transform(((speed - 240) / 720).clamp(0.0, 1.0)) *
((_dragTravel - 24) / 96).clamp(0.0, 1.0);
final scale = fine + (coarse - fine) * acceleration;
position.jumpTo(
(position.pixels + dy * scale).clamp(
position.minScrollExtent,
position.maxScrollExtent,
),
);
}
void _endDrag() {
if (!_dragging) return;
setState(() {
_dragging = false;
_show();
});
widget.onDragEnd?.call();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final mornye = context.isMornye;
final reduced = MediaQuery.disableAnimationsOf(context);
final motion = AppMotion.spatialFast(
context,
AppMotionSpec(
reduced ? Duration.zero : const Duration(milliseconds: 180),
Curves.easeOutCubic,
),
);
// NestedScrollView's body slides behind its pinned header. Reserve only
// the part of that header currently overlapping this body's viewport.
final remainingHeader = _outerPosition?.hasContentDimensions == true
? _outerPosition!.extentAfter
: 0.0;
final topPadding = (widget.topPadding - remainingHeader).clamp(
0.0,
widget.topPadding,
);
return NotificationListener<ScrollMetricsNotification>(
onNotification: _onMetricsNotification,
child: NotificationListener<ScrollNotification>(
onNotification: _onNotification,
child: Stack(
children: [
widget.child,
if (_long)
Positioned(
top: topPadding,
bottom: widget.bottomPadding,
right: 0,
width: 240,
child: LayoutBuilder(
builder: (context, constraints) {
final track = constraints.maxHeight;
final top = _fraction * math.max(0, track - _thumbHeight);
final label = _dragging ? widget.labelAt(_fraction) : null;
return Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
top: top,
right: 0,
width: _hitWidth,
height: _thumbHeight,
child: Semantics(
// Screen readers scroll the list itself.
excludeSemantics: true,
child: IgnorePointer(
// A hidden thumb must not catch edge swipes.
ignoring: !_visible && !_dragging,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragStart: _startDrag,
onVerticalDragUpdate: (details) =>
_updateDrag(details, track),
onVerticalDragEnd: (_) => _endDrag(),
onVerticalDragCancel: _endDrag,
child: AnimatedOpacity(
opacity: _visible || _dragging ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: Align(
alignment: Alignment.centerRight,
child: AnimatedContainer(
duration: motion.duration,
curve: motion.curve,
margin: const EdgeInsets.only(right: 4),
width: _dragging ? 10 : 6,
height: _thumbHeight,
decoration: ShapeDecoration(
color: _dragging
? colors.primary
: colors.onSurfaceVariant
.withValues(
alpha: mornye ? 0.45 : 0.6,
),
shape: const StadiumBorder(),
),
),
),
),
),
),
),
),
if (label != null)
Positioned.fill(
child: IgnorePointer(
child: CustomSingleChildLayout(
delegate: _BubbleLayout(
center: top + _thumbHeight / 2,
right: _hitWidth + 8,
),
child: _Bubble(
key: const ValueKey('library-scroll-bubble'),
label: label,
mornye: mornye,
motion: motion,
),
),
),
),
],
);
},
),
),
],
),
),
);
}
}
class _BubbleLayout extends SingleChildLayoutDelegate {
const _BubbleLayout({required this.center, required this.right});
final double center;
final double right;
static const _inset = 8.0;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) =>
BoxConstraints(
maxWidth: math.max(0, constraints.maxWidth - right - _inset),
maxHeight: math.max(0, constraints.maxHeight - _inset * 2),
);
@override
Offset getPositionForChild(Size size, Size childSize) => Offset(
math.max(0, size.width - right - childSize.width),
(center - childSize.height / 2).clamp(
_inset,
math.max(_inset, size.height - childSize.height - _inset),
),
);
@override
bool shouldRelayout(_BubbleLayout oldDelegate) =>
center != oldDelegate.center || right != oldDelegate.right;
}
/// The section bubble beside the thumb. Material uses a container-colored
/// shape pointing at the thumb; Mornye a plain rounded tile.
class _Bubble extends StatelessWidget {
const _Bubble({
super.key,
required this.label,
required this.mornye,
required this.motion,
});
final String label;
final bool mornye;
final AppMotionSpec motion;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final short = label.characters.length <= 2;
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.6, end: 1),
duration: motion.duration,
curve: motion.curve,
builder: (context, scale, child) => Transform.scale(
scale: scale,
alignment: Alignment.centerRight,
child: child,
),
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 72, minHeight: 72),
child: DecoratedBox(
decoration: ShapeDecoration(
color: mornye
? colors.surfaceContainerHighest
: colors.primaryContainer,
shadows: kElevationToShadow[2],
shape: RoundedRectangleBorder(
borderRadius: mornye
? BorderRadius.circular(18)
: const BorderRadius.only(
topLeft: Radius.circular(36),
topRight: Radius.circular(36),
bottomLeft: Radius.circular(36),
bottomRight: Radius.circular(6),
),
),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: short ? 0 : 18),
child: Center(
widthFactor: short ? null : 1,
heightFactor: 1,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
label,
style:
(short
? Theme.of(context).textTheme.headlineMedium
: Theme.of(context).textTheme.titleMedium)
?.copyWith(
fontWeight: FontWeight.w700,
color: mornye
? colors.onSurface
: colors.onPrimaryContainer,
),
),
),
),
),
),
),
);
}
}