228 lines
7.4 KiB
Dart
228 lines
7.4 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../../media/library_first_character.dart';
|
||
import 'alpha_jump_helper.dart';
|
||
|
||
/// Phone-optimized draggable scroll handle that appears on scroll and shows
|
||
/// a letter bubble when dragged. Designed to match the Plex app's scroll
|
||
/// indicator behavior.
|
||
///
|
||
/// Desktop/tablet/TV should use [AlphaJumpBar] instead.
|
||
class AlphaScrollHandle extends StatefulWidget {
|
||
final List<LibraryFirstCharacter> firstCharacters;
|
||
final void Function(int targetIndex) onJump;
|
||
|
||
/// The letter currently visible at the top of the grid, derived from the
|
||
/// actual item's sort title by the parent widget.
|
||
final String currentLetter;
|
||
|
||
/// Whether the parent scroll view is currently scrolling.
|
||
final bool isScrolling;
|
||
|
||
const AlphaScrollHandle({
|
||
super.key,
|
||
required this.firstCharacters,
|
||
required this.onJump,
|
||
required this.currentLetter,
|
||
required this.isScrolling,
|
||
});
|
||
|
||
@override
|
||
State<AlphaScrollHandle> createState() => _AlphaScrollHandleState();
|
||
}
|
||
|
||
class _AlphaScrollHandleState extends State<AlphaScrollHandle> with SingleTickerProviderStateMixin {
|
||
late AlphaJumpHelper _helper;
|
||
late AnimationController _opacityController;
|
||
Timer? _hideTimer;
|
||
bool _isDragging = false;
|
||
String? _dragLetter;
|
||
|
||
/// Accumulated fraction (0.0–1.0) during drag, driven by delta movement.
|
||
double? _dragFraction;
|
||
|
||
/// Cached track height from the last layout pass, used in drag callbacks.
|
||
double _trackHeight = 0;
|
||
|
||
static const _showDuration = Duration(milliseconds: 200);
|
||
static const _hideDuration = Duration(milliseconds: 200);
|
||
static const _autoHideDelay = Duration(seconds: 2);
|
||
|
||
// Handle dimensions
|
||
static const double _handleWidth = 6.0;
|
||
static const double _handleHeight = 44.0;
|
||
static const double _touchTargetWidth = 44.0;
|
||
static const double _touchTargetVerticalPadding = 20.0;
|
||
static const double _handleRadius = 3.0;
|
||
|
||
// Bubble dimensions
|
||
static const double _bubbleSize = 56.0;
|
||
static const double _bubbleFontSize = 24.0;
|
||
static const double _bubbleMarginRight = 8.0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_helper = AlphaJumpHelper(widget.firstCharacters);
|
||
_opacityController = AnimationController(vsync: this, duration: _showDuration, reverseDuration: _hideDuration);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(AlphaScrollHandle oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
|
||
if (oldWidget.firstCharacters != widget.firstCharacters) {
|
||
_helper = AlphaJumpHelper(widget.firstCharacters);
|
||
}
|
||
|
||
if (widget.isScrolling && !oldWidget.isScrolling) {
|
||
_show();
|
||
} else if (!widget.isScrolling && oldWidget.isScrolling) {
|
||
_scheduleHide();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_hideTimer?.cancel();
|
||
_opacityController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _show() {
|
||
_hideTimer?.cancel();
|
||
_opacityController.forward();
|
||
}
|
||
|
||
void _scheduleHide() {
|
||
if (_isDragging) return;
|
||
_hideTimer?.cancel();
|
||
_hideTimer = Timer(_autoHideDelay, () {
|
||
if (mounted && !_isDragging) {
|
||
_opacityController.reverse();
|
||
}
|
||
});
|
||
}
|
||
|
||
void _onDragStart(DragStartDetails _) {
|
||
setState(() {
|
||
_isDragging = true;
|
||
_dragFraction = _helper.fractionForLetter(widget.currentLetter);
|
||
_dragLetter = widget.currentLetter;
|
||
});
|
||
_hideTimer?.cancel();
|
||
_show();
|
||
}
|
||
|
||
void _onDragUpdate(DragUpdateDetails details) {
|
||
final usableHeight = _trackHeight - _handleHeight;
|
||
if (usableHeight <= 0 || _dragFraction == null) return;
|
||
|
||
final newFraction = (_dragFraction! + details.delta.dy / usableHeight).clamp(0.0, 1.0);
|
||
_dragFraction = newFraction;
|
||
|
||
final letter = _helper.letterAtFraction(newFraction);
|
||
|
||
setState(() => _dragLetter = letter);
|
||
widget.onJump(_helper.indexForLetter(letter) ?? 0);
|
||
}
|
||
|
||
void _onDragEnd(DragEndDetails _) {
|
||
setState(() {
|
||
_isDragging = false;
|
||
_dragLetter = null;
|
||
_dragFraction = null;
|
||
});
|
||
_scheduleHide();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AnimatedBuilder(
|
||
animation: _opacityController,
|
||
builder: (context, child) {
|
||
final opacity = _opacityController.value;
|
||
// Prevent stealing taps when fully hidden
|
||
if (opacity == 0.0) return const SizedBox.shrink();
|
||
|
||
return Opacity(opacity: opacity, child: child);
|
||
},
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final trackHeight = constraints.maxHeight;
|
||
_trackHeight = trackHeight;
|
||
|
||
final fraction = _isDragging && _dragFraction != null
|
||
? _dragFraction!
|
||
: _helper.fractionForLetter(widget.currentLetter);
|
||
final usableHeight = trackHeight - _handleHeight;
|
||
final handleTop = usableHeight > 0 ? (fraction * usableHeight) : 0.0;
|
||
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
return SizedBox(
|
||
width: _touchTargetWidth + _bubbleSize + _bubbleMarginRight,
|
||
height: trackHeight,
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
// Handle pill with touch target — only around the pill's position,
|
||
// not the full track, so it doesn't steal scroll gestures.
|
||
// Extra vertical padding makes it easier to grab.
|
||
Positioned(
|
||
right: 0,
|
||
top: handleTop - _touchTargetVerticalPadding,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onVerticalDragStart: _onDragStart,
|
||
onVerticalDragUpdate: _onDragUpdate,
|
||
onVerticalDragEnd: _onDragEnd,
|
||
child: SizedBox(
|
||
width: _touchTargetWidth,
|
||
height: _handleHeight + _touchTargetVerticalPadding * 2,
|
||
child: Align(
|
||
alignment: Alignment.centerRight,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(right: 2),
|
||
width: _handleWidth,
|
||
height: _handleHeight,
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||
borderRadius: const BorderRadius.all(Radius.circular(_handleRadius)),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Letter bubble (only while dragging)
|
||
if (_isDragging && _dragLetter != null)
|
||
Positioned(
|
||
right: _touchTargetWidth + _bubbleMarginRight,
|
||
top: handleTop + (_handleHeight - _bubbleSize) / 2,
|
||
child: Container(
|
||
width: _bubbleSize,
|
||
height: _bubbleSize,
|
||
decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
_dragLetter!,
|
||
style: TextStyle(
|
||
color: colorScheme.onPrimary,
|
||
fontSize: _bubbleFontSize,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|