mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat: sliver appbar and snap scrubbing (#19446)
This commit is contained in:
parent
522cdbac99
commit
05064f87f0
9 changed files with 780 additions and 58 deletions
|
|
@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
|
|
@ -80,12 +81,15 @@ class TimelineHeader extends ConsumerWidget {
|
|||
if (header != HeaderType.monthAndDay)
|
||||
_BulkSelectIconButton(
|
||||
isAllSelected: isAllSelected,
|
||||
onPressed: () => ref
|
||||
.read(multiSelectProvider.notifier)
|
||||
.toggleBucketSelection(
|
||||
assetOffset,
|
||||
bucket.assetCount,
|
||||
),
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(multiSelectProvider.notifier)
|
||||
.toggleBucketSelection(
|
||||
assetOffset,
|
||||
bucket.assetCount,
|
||||
);
|
||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -101,9 +105,15 @@ class TimelineHeader extends ConsumerWidget {
|
|||
const Spacer(),
|
||||
_BulkSelectIconButton(
|
||||
isAllSelected: isAllSelected,
|
||||
onPressed: () => ref
|
||||
.read(multiSelectProvider.notifier)
|
||||
.toggleBucketSelection(assetOffset, bucket.assetCount),
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(multiSelectProvider.notifier)
|
||||
.toggleBucketSelection(
|
||||
assetOffset,
|
||||
bucket.assetCount,
|
||||
);
|
||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
|||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({super.key});
|
||||
|
|
@ -63,38 +65,68 @@ class _SliverTimelineState extends State<_SliverTimeline> {
|
|||
final asyncSegments = ref.watch(timelineSegmentProvider);
|
||||
final maxHeight =
|
||||
ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
|
||||
final isMultiSelectEnabled =
|
||||
ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||
return asyncSegments.widgetWhen(
|
||||
onData: (segments) {
|
||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||
final statusBarHeight = context.padding.top;
|
||||
final totalAppBarHeight = statusBarHeight + kToolbarHeight;
|
||||
const scrubberBottomPadding = 100.0;
|
||||
|
||||
return PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
child: Scrubber(
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: context.padding.top + 10,
|
||||
bottomPadding: context.padding.bottom + 10,
|
||||
child: CustomScrollView(
|
||||
primary: true,
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
_SliverSegmentedList(
|
||||
segments: segments,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
if (index >= childCount) return null;
|
||||
final segment = segments.findByIndex(index);
|
||||
return segment?.builder(ctx, index) ??
|
||||
const SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
// We add repaint boundary around tiles, so skip the auto boundaries
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Scrubber(
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: totalAppBarHeight + 10,
|
||||
bottomPadding:
|
||||
context.padding.bottom + scrubberBottomPadding,
|
||||
child: CustomScrollView(
|
||||
primary: true,
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
SliverAnimatedOpacity(
|
||||
duration: Durations.medium1,
|
||||
opacity: isMultiSelectEnabled ? 0 : 1,
|
||||
sliver: const ImmichSliverAppBar(
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
),
|
||||
),
|
||||
_SliverSegmentedList(
|
||||
segments: segments,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
if (index >= childCount) return null;
|
||||
final segment = segments.findByIndex(index);
|
||||
return segment?.builder(ctx, index) ??
|
||||
const SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
// We add repaint boundary around tiles, so skip the auto boundaries
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
),
|
||||
const SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: scrubberBottomPadding,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isMultiSelectEnabled)
|
||||
const Positioned(
|
||||
top: 60,
|
||||
left: 25,
|
||||
child: _MultiSelectStatusButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -363,3 +395,27 @@ class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
|||
childManager.didFinishLayout();
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiSelectStatusButton extends ConsumerWidget {
|
||||
const _MultiSelectStatusButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectCount =
|
||||
ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
|
||||
return ElevatedButton.icon(
|
||||
onPressed: () => ref.read(multiSelectProvider.notifier).clearSelection(),
|
||||
icon: Icon(
|
||||
Icons.close_rounded,
|
||||
color: context.colorScheme.onPrimary,
|
||||
),
|
||||
label: Text(
|
||||
selectCount.toString(),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
height: 2.5,
|
||||
color: context.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue