mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 23:11:45 +02:00
421 lines
15 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|