1
0
mirror of https://github.com/immich-app/immich.git synced 2025-08-08 23:07:06 +02:00

feat: sliver appbar and snap scrubbing (#19446)

This commit is contained in:
Alex
2025-06-24 20:02:46 -05:00
committed by GitHub
parent 522cdbac99
commit 05064f87f0
9 changed files with 780 additions and 58 deletions

View File

@ -44,12 +44,16 @@ List<_Segment> _buildSegments({
required List<Segment> layoutSegments,
required double timelineHeight,
}) {
const double offsetThreshold = 20.0;
final segments = <_Segment>[];
if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) {
return [];
}
final formatter = DateFormat.yMMM();
DateTime? lastDate;
double lastOffset = -offsetThreshold;
for (final layoutSegment in layoutSegments) {
final scrollPercentage =
layoutSegment.startOffset / layoutSegments.last.endOffset;
@ -58,13 +62,21 @@ List<_Segment> _buildSegments({
final date = (layoutSegment.bucket as TimeBucket).date;
final label = formatter.format(date);
final showSegment = lastOffset + offsetThreshold <= startOffset &&
(lastDate == null || date.year != lastDate.year);
segments.add(
_Segment(
date: date,
startOffset: startOffset,
scrollLabel: label,
showSegment: showSegment,
),
);
lastDate = date;
if (showSegment) {
lastOffset = startOffset;
}
}
return segments;
@ -85,12 +97,15 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
double get _scrubberHeight =>
widget.timelineHeight - widget.topPadding - widget.bottomPadding;
late final ScrollController _scrollController;
late ScrollController _scrollController;
double get _currentOffset =>
_scrollController.offset *
_scrubberHeight /
_scrollController.position.maxScrollExtent;
double get _currentOffset {
if (_scrollController.hasClients != true) return 0.0;
return _scrollController.offset *
_scrubberHeight /
_scrollController.position.maxScrollExtent;
}
@override
void initState() {
@ -194,28 +209,102 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
_thumbAnimationController.forward();
}
final newOffset =
details.globalPosition.dy - widget.topPadding - widget.bottomPadding;
final dragPosition = _calculateDragPosition(details);
final nearestMonthSegment = _findNearestMonthSegment(dragPosition);
if (nearestMonthSegment != null) {
_snapToSegment(nearestMonthSegment);
}
}
/// Calculate the drag position relative to the scrubber area
///
/// This method converts the global drag coordinates from the gesture detector
/// into a position relative to the scrubber's active area (excluding padding).
///
/// The scrubber has padding at the top and bottom, so we need to:
/// 1. Calculate the actual draggable area (timelineHeight - topPadding - bottomPadding)
/// 2. Convert the global Y position to a position within this draggable area
/// 3. Clamp the result to ensure it stays within bounds (0 to dragAreaHeight)
///
/// Example:
/// - If timelineHeight = 800, topPadding = 50, bottomPadding = 50
/// - Then dragAreaHeight = 700 (the actual scrubber area)
/// - If user drags to global Y position that's 100 pixels from the top
/// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area)
double _calculateDragPosition(DragUpdateDetails details) {
final dragAreaTop = widget.topPadding;
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
final dragAreaHeight = dragAreaBottom - dragAreaTop;
final relativePosition = details.globalPosition.dy - dragAreaTop;
// Make sure the position stays within the scrubber's bounds
return relativePosition.clamp(0.0, dragAreaHeight);
}
/// Find the segment closest to the given position
_Segment? _findNearestMonthSegment(double position) {
_Segment? nearestSegment;
double minDistance = double.infinity;
for (final segment in _segments) {
final distance = (segment.startOffset - position).abs();
if (distance < minDistance) {
minDistance = distance;
nearestSegment = segment;
}
}
return nearestSegment;
}
/// Snap the scrubber thumb and scroll view to the given segment
void _snapToSegment(_Segment segment) {
setState(() {
_thumbTopOffset = newOffset.clamp(0, _scrubberHeight);
final scrollPercentage = _thumbTopOffset / _scrubberHeight;
final maxScrollExtent = _scrollController.position.maxScrollExtent;
_scrollController.jumpTo(maxScrollExtent * scrollPercentage);
_thumbTopOffset = segment.startOffset;
final layoutSegmentIndex = _findLayoutSegmentIndex(segment);
if (layoutSegmentIndex >= 0) {
_scrollToLayoutSegment(layoutSegmentIndex);
}
});
}
int _findLayoutSegmentIndex(_Segment segment) {
return widget.layoutSegments.indexWhere(
(layoutSegment) {
final bucket = layoutSegment.bucket as TimeBucket;
return bucket.date.year == segment.date.year &&
bucket.date.month == segment.date.month;
},
);
}
void _scrollToLayoutSegment(int layoutSegmentIndex) {
final layoutSegment = widget.layoutSegments[layoutSegmentIndex];
final maxScrollExtent = _scrollController.position.maxScrollExtent;
final viewportHeight = _scrollController.position.viewportDimension;
final targetScrollOffset = layoutSegment.startOffset;
final centeredOffset = targetScrollOffset - (viewportHeight / 4) + 100;
_scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent));
}
void _onDragEnd(WidgetRef ref) {
ref.read(timelineStateProvider.notifier).setScrubbing(false);
_labelAnimationController.reverse();
_isDragging = false;
_resetThumbTimer();
}
@override
Widget build(BuildContext ctx) {
Text? label;
if (_scrollController.hasClients) {
if (_scrollController.hasClients == true) {
// Cache to avoid multiple calls to [_currentOffset]
final scrollOffset = _currentOffset;
final labelText = _segments
@ -240,20 +329,31 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
child: Stack(
children: [
RepaintBoundary(child: widget.child),
// Scroll Segments - wrapped in RepaintBoundary for better performance
RepaintBoundary(
child: _SegmentsLayer(
key: ValueKey('segments_${_isDragging}_${_segments.length}'),
segments: _segments,
topPadding: widget.topPadding,
isDragging: _isDragging,
),
),
PositionedDirectional(
top: _thumbTopOffset + widget.topPadding,
end: 0,
child: Consumer(
builder: (_, ref, child) => GestureDetector(
onVerticalDragStart: (_) => _onDragStart(ref),
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: (_) => _onDragEnd(ref),
child: child,
),
child: _Scrubber(
thumbAnimation: _thumbAnimation,
labelAnimation: _labelAnimation,
label: label,
child: RepaintBoundary(
child: Consumer(
builder: (_, ref, child) => GestureDetector(
onVerticalDragStart: (_) => _onDragStart(ref),
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: (_) => _onDragEnd(ref),
child: child,
),
child: _Scrubber(
thumbAnimation: _thumbAnimation,
labelAnimation: _labelAnimation,
label: label,
),
),
),
),
@ -263,6 +363,72 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
}
}
class _SegmentsLayer extends StatelessWidget {
final List<_Segment> segments;
final double topPadding;
final bool isDragging;
const _SegmentsLayer({
super.key,
required this.segments,
required this.topPadding,
required this.isDragging,
});
@override
Widget build(BuildContext context) {
return Visibility(
visible: isDragging,
child: Stack(
children: segments
.where((segment) => segment.showSegment)
.map(
(segment) => PositionedDirectional(
key: ValueKey('segment_${segment.date.millisecondsSinceEpoch}'),
top: topPadding + segment.startOffset,
end: 100,
child: RepaintBoundary(
child: _SegmentWidget(segment),
),
),
)
.toList(),
),
);
}
}
class _SegmentWidget extends StatelessWidget {
final _Segment _segment;
const _SegmentWidget(this._segment);
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
margin: const EdgeInsets.only(right: 12.0),
child: Material(
color: context.colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: Container(
constraints: const BoxConstraints(maxHeight: 28),
padding: const EdgeInsets.symmetric(horizontal: 10.0),
alignment: Alignment.center,
child: Text(
_segment.date.year.toString(),
style: context.textTheme.labelMedium?.copyWith(
fontFamily: "OverpassMono",
fontWeight: FontWeight.w600,
),
),
),
),
),
);
}
}
class _ScrollLabel extends StatelessWidget {
final Text label;
final Color backgroundColor;
@ -429,22 +595,26 @@ class _Segment {
final DateTime date;
final double startOffset;
final String scrollLabel;
final bool showSegment;
const _Segment({
required this.date,
required this.startOffset,
required this.scrollLabel,
this.showSegment = false,
});
_Segment copyWith({
DateTime? date,
double? startOffset,
String? scrollLabel,
bool? showSegment,
}) {
return _Segment(
date: date ?? this.date,
startOffset: startOffset ?? this.startOffset,
scrollLabel: scrollLabel ?? this.scrollLabel,
showSegment: showSegment ?? this.showSegment,
);
}