mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
Nothing read parentPageController, parentPageIndex or nextPageIndex, and the Library tab root watched showExtensionStore only to compute them, so toggling that setting rebuilt the tab for nothing. No caller passed the fast scroller onDragStart/onDragEnd callbacks.
410 lines
14 KiB
Dart
410 lines
14 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.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;
|
|
|
|
/// 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);
|
|
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();
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
@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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|