You've already forked immich
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:
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user