diff --git a/src/screens/PhotosScreen/components/PhotosScrubber/index.tsx b/src/screens/PhotosScreen/components/PhotosScrubber/index.tsx
new file mode 100644
index 000000000..c9c1ccabf
--- /dev/null
+++ b/src/screens/PhotosScreen/components/PhotosScrubber/index.tsx
@@ -0,0 +1,213 @@
+import { CaretDownIcon, CaretUpIcon } from 'phosphor-react-native';
+import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
+import { Animated, StyleSheet, View } from 'react-native';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import Reanimated, { useAnimatedStyle } from 'react-native-reanimated';
+import { scheduleOnRN } from 'react-native-worklets';
+import AppText from 'src/components/AppText';
+import useGetColor from 'src/hooks/useColor';
+import { useTailwind } from 'tailwind-rn';
+import {
+ PhotosScrubberResult,
+ SCRUBBER_HANDLE_HIT_MARGIN,
+ SCRUBBER_HANDLE_SIZE,
+ SCRUBBER_RAIL_WIDTH,
+ SCRUBBER_YEAR_MARKER_HEIGHT,
+ ScrubberMonthLabelStore,
+} from '../../hooks/usePhotosScrubber';
+import { getRailAnchorForScroll } from '../../utils/photoTimelineLayout';
+
+interface PhotosScrubberProps {
+ scrubber: PhotosScrubberResult;
+}
+
+const HANDLE_ROW_RIGHT_INSET = 4;
+const PILL_HANDLE_GAP = 8;
+const PILL_RIGHT_OFFSET = HANDLE_ROW_RIGHT_INSET + SCRUBBER_HANDLE_SIZE + PILL_HANDLE_GAP;
+
+const ScrubberMonthPill = ({ store }: { store: ScrubberMonthLabelStore }): JSX.Element | null => {
+ const tailwind = useTailwind();
+ const getColor = useGetColor();
+ const label = useSyncExternalStore(store.subscribe, store.getSnapshot);
+
+ if (!label) {
+ return null;
+ }
+
+ return (
+
+
+ {label}
+
+
+ );
+};
+
+const PhotosScrubber = ({ scrubber }: PhotosScrubberProps): JSX.Element | null => {
+ const tailwind = useTailwind();
+ const getColor = useGetColor();
+
+ const { isAvailable, opacity, yearMarkersOpacity, railTop, railHeight, drag, yearMarkers, monthLabelStore } =
+ scrubber;
+
+ const scrubberRef = useRef(scrubber);
+ scrubberRef.current = scrubber;
+
+ const isDraggingHandleRef = useRef(false);
+
+ const startScrub = useCallback(() => {
+ isDraggingHandleRef.current = true;
+ scrubberRef.current.onScrubStart();
+ }, []);
+
+ const moveScrub = useCallback((centerY: number) => {
+ scrubberRef.current.onScrubMove(centerY);
+ }, []);
+
+ const endScrub = useCallback(() => {
+ if (!isDraggingHandleRef.current) {
+ return;
+ }
+ isDraggingHandleRef.current = false;
+ scrubberRef.current.onScrubEnd();
+ }, []);
+
+ const handleStyle = useAnimatedStyle(() => {
+ const anchor = drag.isDragging.value
+ ? drag.startCenterY.value
+ : getRailAnchorForScroll({
+ scrollY: drag.scrollY.value,
+ maxScroll: drag.maxScroll.value,
+ railHeight: drag.railHeight.value,
+ });
+
+ return { transform: [{ translateY: anchor + drag.translateY.value - SCRUBBER_HANDLE_SIZE / 2 }] };
+ });
+
+ // Rewrites the finger delta as part of the anchor, leaving the handle at the same coordinate.
+ const foldDragIntoAnchor = useCallback(() => {
+ 'worklet';
+ drag.startCenterY.value = drag.startCenterY.value + drag.translateY.value;
+ drag.translateY.value = 0;
+ }, [drag]);
+
+ const panGesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .enabled(isAvailable)
+ .maxPointers(1)
+ .hitSlop(SCRUBBER_HANDLE_HIT_MARGIN)
+ // Not .onStart(), which only fires past Pan's activation threshold — holding the handle
+ // without moving has to reveal the year markers.
+ .onTouchesDown(() => {
+ // Touch callbacks fire per pointer, so a second finger would otherwise re-pin the anchor
+ // mid-drag.
+ if (drag.isDragging.value) {
+ return;
+ }
+ // Not in startScrub: that arrives a beat later, and a first onUpdate would then clamp
+ // against the previous drag's anchor.
+ drag.startCenterY.value = getRailAnchorForScroll({
+ scrollY: drag.scrollY.value,
+ maxScroll: drag.maxScroll.value,
+ railHeight: drag.railHeight.value,
+ });
+ drag.translateY.value = 0;
+ drag.isDragging.value = true;
+ // Stops a previous release still settling from unpinning this drag.
+ drag.isReleasing.value = false;
+ scheduleOnRN(startScrub);
+ })
+ .onUpdate((event) => {
+ // Clamped as an absolute centre, not as a delta, so the handle stops at the rail's ends
+ // while the finger keeps going.
+ const startCenterY = drag.startCenterY.value;
+ const centerY = Math.min(drag.railHeight.value, Math.max(0, startCenterY + event.translationY));
+ drag.translateY.value = centerY - startCenterY;
+ scheduleOnRN(moveScrub, centerY);
+ })
+ // A tap without movement never activates Pan, so onFinalize alone would not fire.
+ .onTouchesUp(() => {
+ foldDragIntoAnchor();
+ scheduleOnRN(endScrub);
+ })
+ // Backstop for cancellation paths. endScrub is idempotent, so both firing is fine.
+ .onFinalize(() => {
+ foldDragIntoAnchor();
+ scheduleOnRN(endScrub);
+ }),
+ [isAvailable, drag, foldDragIntoAnchor, startScrub, moveScrub, endScrub],
+ );
+
+ if (!isAvailable) {
+ return null;
+ }
+
+ return (
+
+
+ {yearMarkers.map((marker) => (
+
+
+ {marker.label}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'absolute',
+ right: 0,
+ width: SCRUBBER_RAIL_WIDTH,
+ zIndex: 5,
+ },
+ yearMarker: {
+ position: 'absolute',
+ right: PILL_RIGHT_OFFSET,
+ height: SCRUBBER_YEAR_MARKER_HEIGHT,
+ justifyContent: 'center',
+ paddingHorizontal: 10,
+ borderRadius: 12,
+ },
+ handleRow: {
+ position: 'absolute',
+ right: HANDLE_ROW_RIGHT_INSET,
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ handle: {
+ width: SCRUBBER_HANDLE_SIZE,
+ height: SCRUBBER_HANDLE_SIZE,
+ borderRadius: SCRUBBER_HANDLE_SIZE / 2,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ floatingPill: {
+ marginRight: PILL_HANDLE_GAP,
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ borderRadius: 16,
+ minWidth: 96,
+ alignItems: 'center',
+ },
+});
+
+export default PhotosScrubber;
diff --git a/src/screens/PhotosScreen/components/PhotosTimeline.tsx b/src/screens/PhotosScreen/components/PhotosTimeline.tsx
index be7c42b17..8e1718994 100644
--- a/src/screens/PhotosScreen/components/PhotosTimeline.tsx
+++ b/src/screens/PhotosScreen/components/PhotosTimeline.tsx
@@ -1,14 +1,16 @@
import { FlashList, FlashListRef, ListRenderItem } from '@shopify/flash-list';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
-import { Animated, Platform, StyleSheet, View } from 'react-native';
+import { Animated, LayoutChangeEvent, Platform, StyleSheet, View } from 'react-native';
import { GestureDetector } from 'react-native-gesture-handler';
import { useTailwind } from 'tailwind-rn';
import { useDragSelectGesture } from '../hooks/useDragSelectGesture';
+import { usePhotosScrubber } from '../hooks/usePhotosScrubber';
import { PhotoBackupState, PhotoDateGroup, TimelinePhotoItem } from '../types';
import { GroupBoundary, buildFlatTimeline, findGroupForIndex } from '../utils/photoTimelineGroups';
import PhotosGroupHeader, { GroupSyncStatus } from './GroupHeader/PhotosGroupHeader';
import PhotoItem from './PhotoItem';
import PhotosEmptyState from './PhotosEmptyState';
+import PhotosScrubber from './PhotosScrubber';
export interface PhotosTimelineHandle {
scrollToAssetId: (id: string) => void;
@@ -63,6 +65,14 @@ const HEADER_FADE_SCROLL_DISTANCE = 24;
const FLOATING_HEADER_MIN_OPACITY = 0.02;
const PULL_TO_REFRESH_FADE_START = -10;
const PULL_TO_REFRESH_FADE_END = -60;
+const LIST_PADDING_BOTTOM = 80;
+
+// Hoisted so the FlashList does not see a new object identity on every render.
+const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 10 };
+const MAINTAIN_VISIBLE_CONTENT_POSITION = { disabled: true } as const;
+const CONTENT_STYLE_EMPTY = { paddingBottom: LIST_PADDING_BOTTOM, flexGrow: 1 };
+const CONTENT_STYLE = { paddingTop: HEADER_HEIGHT, paddingBottom: LIST_PADDING_BOTTOM };
+const CONTENT_STYLE_REFRESHING = { paddingTop: 0, paddingBottom: LIST_PADDING_BOTTOM };
interface PhotosTimelineProps {
assetsGroupsByDate: TimelineDateGroup[];
@@ -126,6 +136,23 @@ const PhotosTimeline = forwardRef(
const boundariesRef = useRef(boundaries);
boundariesRef.current = boundaries;
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
+ const [listHeaderHeight, setListHeaderHeight] = useState(0);
+
+ const onListHeaderLayout = useCallback(
+ (e: LayoutChangeEvent) => setListHeaderHeight(e.nativeEvent.layout.height),
+ [],
+ );
+
+ useEffect(() => {
+ if (!ListHeaderComponent) {
+ setListHeaderHeight(0);
+ }
+ }, [ListHeaderComponent]);
+
+ const cellSize = containerSize.width / NUM_COLUMNS;
+ const contentTopInset = HEADER_HEIGHT + listHeaderHeight;
+
const scrollY = useRef(new Animated.Value(0)).current;
// UIKit drops touches below alpha 0.01, so use FLOATING_HEADER_MIN_OPACITY as the floor so
// pause/resume buttons are always touchable even when the floating layer is nearly invisible
@@ -141,7 +168,12 @@ const PhotosTimeline = forwardRef(
extrapolate: 'clamp',
});
- const { gesture, onContainerLayout, onScroll, scrollOffsetRef } = useDragSelectGesture({
+ const {
+ gesture,
+ onContainerLayout,
+ onScroll: onDragSelectScroll,
+ scrollOffsetRef,
+ } = useDragSelectGesture({
isSelectMode: !!isSelectMode,
photos,
scrollY,
@@ -153,6 +185,40 @@ const PhotosTimeline = forwardRef(
onDragEnd,
});
+ const scrollToOffset = useCallback((offset: number) => {
+ flashListRef.current?.scrollToOffset({ offset, animated: false });
+ }, []);
+
+ const scrubber = usePhotosScrubber({
+ scrollY,
+ boundaries,
+ itemCount: photos.length,
+ cellSize,
+ contentTopInset,
+ containerHeight: containerSize.height,
+ numColumns: NUM_COLUMNS,
+ listPaddingBottom: LIST_PADDING_BOTTOM,
+ isEnabled: !isSelectMode,
+ scrollToOffset,
+ });
+
+ const onScroll = useCallback(
+ (e: Parameters[0]) => {
+ onDragSelectScroll(e);
+ scrubber.notifyScroll();
+ },
+ // notifyScroll specifically, not `scrubber`, which is a new object literal every render.
+ [onDragSelectScroll, scrubber.notifyScroll],
+ );
+
+ const onContainerLayoutMerged = useCallback(
+ (e: LayoutChangeEvent) => {
+ onContainerLayout(e);
+ setContainerSize({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height });
+ },
+ [onContainerLayout],
+ );
+
const extraData = useMemo(
() => ({ isSelectMode, selectedIds, onPausePress, onResumePress, onRetryPress }),
[isSelectMode, selectedIds, onPausePress, onResumePress, onRetryPress],
@@ -225,19 +291,37 @@ const PhotosTimeline = forwardRef(
}, [isIosRefreshing]);
const isEmpty = !isLoading && assetsGroupsByDate.length === 0;
- const currentBoundary = boundaries.find((b) => b.id === topGroupId) ?? boundaries[0];
- const currentSyncStatus = currentBoundary?.syncStatus ?? { type: 'none' };
- const overlaySyncStatus = resolveOverlaySyncStatus({
- isSelectMode: !!isSelectMode,
- selectedCount: selectedIds?.size ?? 0,
- isAtListTop,
- currentSyncStatus,
- totalAssetsCount,
- });
+
+ const currentBoundary = useMemo(
+ () => boundaries.find((b) => b.id === topGroupId) ?? boundaries[0],
+ [boundaries, topGroupId],
+ );
+
+ const overlaySyncStatus = useMemo(
+ () =>
+ resolveOverlaySyncStatus({
+ isSelectMode: !!isSelectMode,
+ selectedCount: selectedIds?.size ?? 0,
+ isAtListTop,
+ currentSyncStatus: currentBoundary?.syncStatus ?? { type: 'none' },
+ totalAssetsCount,
+ }),
+ [isSelectMode, selectedIds, isAtListTop, currentBoundary, totalAssetsCount],
+ );
+
+ const contentContainerStyle = isEmpty
+ ? CONTENT_STYLE_EMPTY
+ : isIosRefreshing
+ ? CONTENT_STYLE_REFRESHING
+ : CONTENT_STYLE;
+
+ const measuredListHeader = ListHeaderComponent ? (
+ {ListHeaderComponent}
+ ) : undefined;
return (
-
+
(
keyExtractor={keyExtractor}
numColumns={NUM_COLUMNS}
extraData={extraData}
- ListHeaderComponent={ListHeaderComponent}
+ ListHeaderComponent={measuredListHeader}
ListEmptyComponent={isEmpty ? : undefined}
- contentContainerStyle={
- isEmpty
- ? { paddingBottom: 80, flexGrow: 1 }
- : { paddingTop: isIosRefreshing ? 0 : HEADER_HEIGHT, paddingBottom: 80 }
- }
+ contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={false}
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
refreshing={refreshing}
onRefresh={onRefresh}
onViewableItemsChanged={onViewableItemsChanged}
- viewabilityConfig={{ itemVisiblePercentThreshold: 10 }}
+ viewabilityConfig={VIEWABILITY_CONFIG}
onScroll={onScroll}
scrollEventThrottle={16}
progressViewOffset={Platform.OS === 'android' ? HEADER_HEIGHT : 0}
- maintainVisibleContentPosition={{ disabled: true }}
+ maintainVisibleContentPosition={MAINTAIN_VISIBLE_CONTENT_POSITION}
drawDistance={500}
/>
@@ -286,6 +366,8 @@ const PhotosTimeline = forwardRef(
>
)}
+
+ {!isEmpty && }
);
diff --git a/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts b/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts
index a4616543b..6f1e927a7 100644
--- a/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts
+++ b/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts
@@ -12,6 +12,11 @@ export const useCloudThumbnail = (item: CloudPhotoItem): { uri: string | null; o
const userRef = useRef(user);
userRef.current = user;
+ // The effect below also runs on retryCount, so id and path changes are tracked explicitly to tell
+ // a recycle or a backfill apart from a retry.
+ const lastItemIdRef = useRef(item.id);
+ const lastThumbnailPathPropRef = useRef(item.thumbnailPath);
+
const onImageError = useCallback(() => {
photosLocalDB.setCloudThumbnailPath(item.id, null);
setLocalPath(null);
@@ -19,19 +24,26 @@ export const useCloudThumbnail = (item: CloudPhotoItem): { uri: string | null; o
}, [item.id]);
useEffect(() => {
- // FlashList recycles cells: reset to the new item's persisted path before checking
- if (retryCount === 0) {
+ const idChanged = lastItemIdRef.current !== item.id;
+ const persistedPathChanged = lastThumbnailPathPropRef.current !== item.thumbnailPath;
+ lastItemIdRef.current = item.id;
+ lastThumbnailPathPropRef.current = item.thumbnailPath;
+
+ if (idChanged || persistedPathChanged) {
setLocalPath(item.thumbnailPath);
}
+ // retryCount belongs to the cell, which FlashList recycles across items, so it only counts as a
+ // retry for the item that actually failed — otherwise one failed image would make every later
+ // item in that cell refetch a thumbnail it already has on disk.
+ const isRetry = retryCount > 0 && !idChanged;
+ if (!isRetry && item.thumbnailPath) {
+ return;
+ }
+
const thumbnailBucketId = item.thumbnailBucketId;
const thumbnailBucketFile = item.thumbnailBucketFile;
const currentUser = userRef.current;
-
- // On first mount: skip if path already persisted. On retry (image load failed): always re-fetch.
- if (retryCount === 0 && item.thumbnailPath) {
- return;
- }
if (!thumbnailBucketId || !thumbnailBucketFile || !currentUser) {
return;
}
diff --git a/src/screens/PhotosScreen/hooks/usePhotosScrubber.ts b/src/screens/PhotosScreen/hooks/usePhotosScrubber.ts
new file mode 100644
index 000000000..d0205574b
--- /dev/null
+++ b/src/screens/PhotosScreen/hooks/usePhotosScrubber.ts
@@ -0,0 +1,411 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Animated } from 'react-native';
+import { SharedValue, useAnimatedReaction, useSharedValue } from 'react-native-reanimated';
+import { GroupBoundary } from '../utils/photoTimelineGroups';
+import {
+ buildTimelineDateIndex,
+ findAnchorForIndex,
+ getIndexForOffset,
+ getOffsetForIndex,
+ getRailAnchorForScroll,
+ getTimelineContentHeight,
+} from '../utils/photoTimelineLayout';
+
+const SCRUBBER_IDLE_HIDE_MS = 1500;
+const SCRUBBER_RELEASE_SETTLE_PX = 2;
+const SCRUBBER_RELEASE_TIMEOUT_MS = 400;
+const SCRUBBER_FADE_MS = 200;
+const SCRUBBER_MIN_SCROLLABLE_SCREENS = 2;
+
+export const SCRUBBER_RAIL_WIDTH = 56;
+export const SCRUBBER_HANDLE_SIZE = 44;
+export const SCRUBBER_HANDLE_HIT_MARGIN = 12;
+export const SCRUBBER_YEAR_MARKER_HEIGHT = 24;
+
+const SCRUBBER_RAIL_TOP_INSET = 72;
+const SCRUBBER_RAIL_BOTTOM_INSET = 32;
+const SCRUBBER_YEAR_LABEL_MIN_GAP = 28;
+
+export interface ScrubberYearMarker {
+ label: string;
+ y: number;
+}
+
+/** Month label shown while dragging, shaped for `useSyncExternalStore`. Null when not dragging. */
+export interface ScrubberMonthLabelStore {
+ subscribe: (listener: () => void) => () => void;
+ getSnapshot: () => string | null;
+}
+
+interface WritableMonthLabelStore extends ScrubberMonthLabelStore {
+ set: (label: string | null) => void;
+}
+
+const createMonthLabelStore = (): WritableMonthLabelStore => {
+ let label: string | null = null;
+ const listeners = new Set<() => void>();
+
+ return {
+ subscribe: (listener) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ getSnapshot: () => label,
+ set: (next) => {
+ if (next === label) {
+ return;
+ }
+ label = next;
+ listeners.forEach((listener) => listener());
+ },
+ };
+};
+
+interface PhotosScrubberConfig {
+ scrollY: Animated.Value;
+ boundaries: GroupBoundary[];
+ itemCount: number;
+ cellSize: number;
+ contentTopInset: number;
+ containerHeight: number;
+ numColumns: number;
+ listPaddingBottom: number;
+ isEnabled: boolean;
+ scrollToOffset: (offset: number) => void;
+}
+
+/** Handle position inputs, readable from both the gesture worklet and the animated style. */
+export interface ScrubberDragValues {
+ /** Rail length in pixels. The handle's centre is clamped to [0, railHeight]. */
+ railHeight: SharedValue;
+ /** Maximum scroll offset, for mapping between the handle's position and the list's. */
+ maxScroll: SharedValue;
+ /** Scroll position, mirrored from the RN Animated value FlashList drives. Positions the handle
+ * whenever a drag is not in progress. */
+ scrollY: SharedValue;
+ /** Whether the handle is pinned to startCenterY rather than following the scroll. Stays true
+ * after the finger lifts until isReleasing resolves. */
+ isDragging: SharedValue;
+ /** True between lifting the finger and the list reaching the offset the drag asked for. */
+ isReleasing: SharedValue;
+ /** Where the handle's centre sits for the duration of a drag. */
+ startCenterY: SharedValue;
+ /** Clamped finger displacement since the drag began. */
+ translateY: SharedValue;
+}
+
+export interface PhotosScrubberResult {
+ isAvailable: boolean;
+ opacity: Animated.Value;
+ yearMarkersOpacity: Animated.Value;
+ railTop: number;
+ railHeight: number;
+ drag: ScrubberDragValues;
+ yearMarkers: ScrubberYearMarker[];
+ monthLabelStore: ScrubberMonthLabelStore;
+ onScrubStart: () => void;
+ /** @param centerY the handle centre's position along the rail (0..railHeight), already clamped. */
+ onScrubMove: (centerY: number) => void;
+ onScrubEnd: () => void;
+ notifyScroll: () => void;
+}
+
+const clampFraction = (value: number): number => Math.min(1, Math.max(0, value));
+
+export const usePhotosScrubber = ({
+ scrollY,
+ boundaries,
+ itemCount,
+ cellSize,
+ contentTopInset,
+ containerHeight,
+ numColumns,
+ listPaddingBottom,
+ isEnabled,
+ scrollToOffset,
+}: PhotosScrubberConfig): PhotosScrubberResult => {
+ const [isVisible, setIsVisible] = useState(false);
+ const [isScrubbing, setIsScrubbing] = useState(false);
+
+ const isScrubbingRef = useRef(isScrubbing);
+ isScrubbingRef.current = isScrubbing;
+
+ const railHeight = Math.max(0, containerHeight - SCRUBBER_RAIL_TOP_INSET - SCRUBBER_RAIL_BOTTOM_INSET);
+
+ const contentHeight = getTimelineContentHeight({
+ itemCount,
+ cellSize,
+ contentTopInset,
+ paddingBottom: listPaddingBottom,
+ numColumns,
+ });
+ const maxScroll = Math.max(1, contentHeight - containerHeight);
+
+ const isAvailable =
+ isEnabled &&
+ itemCount > 0 &&
+ railHeight > 0 &&
+ cellSize > 0 &&
+ contentHeight > containerHeight * SCRUBBER_MIN_SCROLLABLE_SCREENS;
+
+ const isAvailableRef = useRef(isAvailable);
+ isAvailableRef.current = isAvailable;
+
+ const scrubMetricsRef = useRef({ railHeight, maxScroll, cellSize, contentTopInset, numColumns });
+ scrubMetricsRef.current = { railHeight, maxScroll, cellSize, contentTopInset, numColumns };
+
+ const scrollToOffsetRef = useRef(scrollToOffset);
+ scrollToOffsetRef.current = scrollToOffset;
+
+ const dragTranslateY = useSharedValue(0);
+ const dragStartCenterY = useSharedValue(0);
+ const dragIsDragging = useSharedValue(false);
+ const dragIsReleasing = useSharedValue(false);
+ const railHeightShared = useSharedValue(0);
+ const maxScrollShared = useSharedValue(0);
+ const scrollYShared = useSharedValue(0);
+
+ const drag = useMemo(
+ () => ({
+ railHeight: railHeightShared,
+ maxScroll: maxScrollShared,
+ scrollY: scrollYShared,
+ isDragging: dragIsDragging,
+ isReleasing: dragIsReleasing,
+ startCenterY: dragStartCenterY,
+ translateY: dragTranslateY,
+ }),
+ [
+ railHeightShared,
+ maxScrollShared,
+ scrollYShared,
+ dragIsDragging,
+ dragIsReleasing,
+ dragStartCenterY,
+ dragTranslateY,
+ ],
+ );
+
+ useEffect(() => {
+ railHeightShared.value = railHeight;
+ maxScrollShared.value = maxScroll;
+ }, [railHeight, maxScroll, railHeightShared, maxScrollShared]);
+
+ useEffect(() => {
+ const id = scrollY.addListener(({ value }) => {
+ scrollYShared.value = value;
+ });
+ return () => scrollY.removeListener(id);
+ }, [scrollY, scrollYShared]);
+
+ const { yearAnchors, monthAnchors } = useMemo(() => buildTimelineDateIndex(boundaries), [boundaries]);
+
+ const monthAnchorsRef = useRef(monthAnchors);
+ monthAnchorsRef.current = monthAnchors;
+
+ const yearMarkers = useMemo(() => {
+ const markers: ScrubberYearMarker[] = [];
+ let lastKeptY = Number.NEGATIVE_INFINITY;
+
+ for (const anchor of yearAnchors) {
+ const offset = getOffsetForIndex({ index: anchor.startIndex, cellSize, contentTopInset, numColumns });
+ const y = clampFraction(offset / maxScroll) * railHeight;
+ if (y - lastKeptY < SCRUBBER_YEAR_LABEL_MIN_GAP) {
+ continue;
+ }
+ markers.push({ label: anchor.label, y });
+ lastKeptY = y;
+ }
+
+ return markers;
+ }, [yearAnchors, cellSize, contentTopInset, numColumns, maxScroll, railHeight]);
+
+ const monthLabelStoreRef = useRef(null);
+ if (monthLabelStoreRef.current === null) {
+ monthLabelStoreRef.current = createMonthLabelStore();
+ }
+ const monthLabelStore = monthLabelStoreRef.current;
+
+ const updateMonthLabelForOffset = useCallback(
+ (offset: number) => {
+ const { cellSize: rowHeight, contentTopInset: topInset, numColumns: columns } = scrubMetricsRef.current;
+ const index = getIndexForOffset({ offset, cellSize: rowHeight, contentTopInset: topInset, numColumns: columns });
+ const anchor = findAnchorForIndex(monthAnchorsRef.current, index);
+ monthLabelStore.set(anchor?.label ?? null);
+ },
+ [monthLabelStore],
+ );
+
+ const opacity = useRef(new Animated.Value(0)).current;
+ const yearMarkersOpacity = useRef(new Animated.Value(0)).current;
+
+ const hideTimeoutRef = useRef | null>(null);
+ const lastScrollAtRef = useRef(0);
+
+ const clearHideTimeout = useCallback(() => {
+ if (hideTimeoutRef.current !== null) {
+ clearTimeout(hideTimeoutRef.current);
+ hideTimeoutRef.current = null;
+ }
+ }, []);
+
+ const runHideTickRef = useRef<() => void>(null!);
+ runHideTickRef.current = () => {
+ hideTimeoutRef.current = null;
+ if (isScrubbingRef.current) {
+ return;
+ }
+ const idleFor = Date.now() - lastScrollAtRef.current;
+ if (idleFor < SCRUBBER_IDLE_HIDE_MS) {
+ hideTimeoutRef.current = setTimeout(() => runHideTickRef.current(), SCRUBBER_IDLE_HIDE_MS - idleFor);
+ return;
+ }
+ setIsVisible(false);
+ };
+
+ // The pending timer re-arms itself with the idle time left, so skipping here loses nothing.
+ const armHideTimer = useCallback((delay: number) => {
+ if (hideTimeoutRef.current !== null) {
+ return;
+ }
+ hideTimeoutRef.current = setTimeout(() => runHideTickRef.current(), delay);
+ }, []);
+
+ const notifyScroll = useCallback(() => {
+ if (!isAvailableRef.current || isScrubbingRef.current) {
+ return;
+ }
+ lastScrollAtRef.current = Date.now();
+ setIsVisible(true);
+ armHideTimer(SCRUBBER_IDLE_HIDE_MS);
+ }, [armHideTimer]);
+
+ const releaseTimeoutRef = useRef | null>(null);
+
+ const clearReleaseTimeout = useCallback(() => {
+ if (releaseTimeoutRef.current !== null) {
+ clearTimeout(releaseTimeoutRef.current);
+ releaseTimeoutRef.current = null;
+ }
+ }, []);
+
+ // Hands the handle back to the scroll only once the scroll agrees with where it is being held.
+ useAnimatedReaction(
+ () =>
+ getRailAnchorForScroll({
+ scrollY: scrollYShared.value,
+ maxScroll: maxScrollShared.value,
+ railHeight: railHeightShared.value,
+ }),
+ (scrollAnchor) => {
+ if (!dragIsReleasing.value) {
+ return;
+ }
+ if (Math.abs(scrollAnchor - dragStartCenterY.value) <= SCRUBBER_RELEASE_SETTLE_PX) {
+ dragIsReleasing.value = false;
+ dragIsDragging.value = false;
+ }
+ },
+ );
+
+ const onScrubStart = useCallback(() => {
+ // A release still settling from the previous drag would unpin this one mid-drag.
+ clearReleaseTimeout();
+ clearHideTimeout();
+ setIsVisible(true);
+ setIsScrubbing(true);
+ }, [clearHideTimeout, clearReleaseTimeout]);
+
+ const onScrubMove = useCallback(
+ (centerY: number) => {
+ const { railHeight: currentRailHeight, maxScroll: currentMaxScroll } = scrubMetricsRef.current;
+ if (currentRailHeight <= 0) {
+ return;
+ }
+ const offset = clampFraction(centerY / currentRailHeight) * currentMaxScroll;
+ scrollToOffsetRef.current(offset);
+ updateMonthLabelForOffset(offset);
+ },
+ [updateMonthLabelForOffset],
+ );
+
+ const onScrubEnd = useCallback(() => {
+ // The anchor stays pinned past the end of the drag until the list catches up; the reaction
+ // above unpins it on arrival, the timeout below unpins it if it never arrives.
+ dragIsReleasing.value = true;
+ clearReleaseTimeout();
+ releaseTimeoutRef.current = setTimeout(() => {
+ releaseTimeoutRef.current = null;
+ // A new drag clears isReleasing from its worklet, which lands before onScrubStart gets to
+ // cancel this timer.
+ if (!dragIsReleasing.value) {
+ return;
+ }
+ dragIsReleasing.value = false;
+ dragIsDragging.value = false;
+ }, SCRUBBER_RELEASE_TIMEOUT_MS);
+
+ setIsScrubbing(false);
+ monthLabelStore.set(null);
+ lastScrollAtRef.current = Date.now();
+ armHideTimer(SCRUBBER_IDLE_HIDE_MS);
+ }, [armHideTimer, monthLabelStore, dragIsDragging, dragIsReleasing, clearReleaseTimeout]);
+
+ useEffect(() => {
+ Animated.timing(opacity, {
+ toValue: isVisible && isAvailable ? 1 : 0,
+ duration: SCRUBBER_FADE_MS,
+ useNativeDriver: false,
+ }).start();
+ }, [isVisible, isAvailable, opacity]);
+
+ useEffect(() => {
+ Animated.timing(yearMarkersOpacity, {
+ toValue: isScrubbing ? 1 : 0,
+ duration: SCRUBBER_FADE_MS,
+ useNativeDriver: true,
+ }).start();
+ }, [isScrubbing, yearMarkersOpacity]);
+
+ useEffect(() => {
+ if (isAvailable) {
+ return;
+ }
+ clearHideTimeout();
+ clearReleaseTimeout();
+ dragIsDragging.value = false;
+ dragIsReleasing.value = false;
+ dragTranslateY.value = 0;
+ monthLabelStore.set(null);
+ setIsScrubbing(false);
+ setIsVisible(false);
+ }, [
+ isAvailable,
+ clearHideTimeout,
+ clearReleaseTimeout,
+ dragIsDragging,
+ dragIsReleasing,
+ dragTranslateY,
+ monthLabelStore,
+ ]);
+
+ useEffect(() => clearHideTimeout, [clearHideTimeout]);
+ useEffect(() => clearReleaseTimeout, [clearReleaseTimeout]);
+
+ return {
+ isAvailable,
+ opacity,
+ yearMarkersOpacity,
+ railTop: SCRUBBER_RAIL_TOP_INSET,
+ railHeight,
+ drag,
+ yearMarkers,
+ monthLabelStore,
+ onScrubStart,
+ onScrubMove,
+ onScrubEnd,
+ notifyScroll,
+ };
+};
diff --git a/src/screens/PhotosScreen/utils/photoTimelineGroups.ts b/src/screens/PhotosScreen/utils/photoTimelineGroups.ts
index d47168137..d2ad304f9 100644
--- a/src/screens/PhotosScreen/utils/photoTimelineGroups.ts
+++ b/src/screens/PhotosScreen/utils/photoTimelineGroups.ts
@@ -242,14 +242,30 @@ export const buildFlatTimeline = (groups: TimelineDateGroup[]): FlatTimeline =>
return { photos, boundaries };
};
-export const findGroupForIndex = (boundaries: GroupBoundary[], itemIndex: number): GroupBoundary | undefined => {
- let result: GroupBoundary | undefined;
- for (const boundary of boundaries) {
- if (boundary.startIndex <= itemIndex) {
- result = boundary;
+/**
+ * Last item at or before the given index, or undefined when the index precedes every item. Runs a
+ * binary search.
+ *
+ * @param items items ordered by `startIndex` ascending.
+ * @param index item index in the flat timeline.
+ */
+export const findLastAtOrBefore = (items: T[], index: number): T | undefined => {
+ let low = 0;
+ let high = items.length - 1;
+ let result: T | undefined;
+
+ while (low <= high) {
+ const middle = (low + high) >> 1;
+ if (items[middle].startIndex <= index) {
+ result = items[middle];
+ low = middle + 1;
} else {
- break;
+ high = middle - 1;
}
}
+
return result;
};
+
+export const findGroupForIndex = (boundaries: GroupBoundary[], itemIndex: number): GroupBoundary | undefined =>
+ findLastAtOrBefore(boundaries, itemIndex);
diff --git a/src/screens/PhotosScreen/utils/photoTimelineLayout.spec.ts b/src/screens/PhotosScreen/utils/photoTimelineLayout.spec.ts
new file mode 100644
index 000000000..833f1cc87
--- /dev/null
+++ b/src/screens/PhotosScreen/utils/photoTimelineLayout.spec.ts
@@ -0,0 +1,166 @@
+import { GroupBoundary } from './photoTimelineGroups';
+import {
+ buildTimelineDateIndex,
+ findAnchorForIndex,
+ getIndexForOffset,
+ getOffsetForIndex,
+ getRailAnchorForScroll,
+ getTimelineContentHeight,
+} from './photoTimelineLayout';
+
+const makeBoundary = (overrides: Partial = {}): GroupBoundary => ({
+ startIndex: 0,
+ id: new Date(2024, 0, 1).toDateString(),
+ label: '1 Jan 2024',
+ syncStatus: { type: 'none' },
+ ...overrides,
+});
+
+describe('building the year and month anchors of the timeline', () => {
+ test('when the timeline spans several years, then one anchor is produced per year', () => {
+ const boundaries: GroupBoundary[] = [
+ makeBoundary({ startIndex: 0, id: new Date(2026, 7, 1).toDateString() }),
+ makeBoundary({ startIndex: 30, id: new Date(2025, 5, 1).toDateString() }),
+ makeBoundary({ startIndex: 90, id: new Date(2024, 2, 1).toDateString() }),
+ ];
+
+ const { yearAnchors } = buildTimelineDateIndex(boundaries);
+
+ expect(yearAnchors.map((a) => a.label)).toEqual(['2026', '2025', '2024']);
+ expect(yearAnchors.map((a) => a.startIndex)).toEqual([0, 30, 90]);
+ });
+
+ test('when a year has photos in more than one month, then the year anchor lands on its oldest boundary', () => {
+ const boundaries: GroupBoundary[] = [
+ makeBoundary({ startIndex: 0, id: new Date(2023, 11, 20).toDateString() }),
+ makeBoundary({ startIndex: 12, id: new Date(2023, 5, 10).toDateString() }),
+ makeBoundary({ startIndex: 24, id: new Date(2023, 0, 5).toDateString() }),
+ makeBoundary({ startIndex: 36, id: new Date(2022, 11, 30).toDateString() }),
+ ];
+
+ const { yearAnchors } = buildTimelineDateIndex(boundaries);
+
+ expect(yearAnchors.map((a) => a.label)).toEqual(['2023', '2022']);
+ expect(yearAnchors.map((a) => a.startIndex)).toEqual([24, 36]);
+ });
+
+ test('when a month has photos in more than one day, then only the first day becomes the month anchor', () => {
+ const boundaries: GroupBoundary[] = [
+ makeBoundary({ startIndex: 0, id: new Date(2024, 5, 20).toDateString() }),
+ makeBoundary({ startIndex: 12, id: new Date(2024, 5, 10).toDateString() }),
+ makeBoundary({ startIndex: 24, id: new Date(2024, 5, 1).toDateString() }),
+ ];
+
+ const { monthAnchors } = buildTimelineDateIndex(boundaries);
+
+ expect(monthAnchors).toHaveLength(1);
+ expect(monthAnchors[0].startIndex).toBe(0);
+ });
+
+ test('when the skeleton group is present, then it is not turned into an anchor', () => {
+ const boundaries: GroupBoundary[] = [
+ makeBoundary({ startIndex: 0, id: new Date(2024, 5, 1).toDateString() }),
+ makeBoundary({ startIndex: 12, id: '__skeleton__', label: '' }),
+ ];
+
+ const { yearAnchors, monthAnchors } = buildTimelineDateIndex(boundaries);
+
+ expect(yearAnchors).toHaveLength(1);
+ expect(monthAnchors).toHaveLength(1);
+ });
+
+ test('when the timeline is empty, then no anchors are produced', () => {
+ const { yearAnchors, monthAnchors } = buildTimelineDateIndex([]);
+
+ expect(yearAnchors).toEqual([]);
+ expect(monthAnchors).toEqual([]);
+ });
+});
+
+describe('converting between an item position and a scroll offset', () => {
+ test('when an item index is given, then its pixel offset accounts for the header inset and the row height', () => {
+ const offset = getOffsetForIndex({ index: 7, cellSize: 120, contentTopInset: 64, numColumns: 3 });
+
+ // row = floor(7 / 3) = 2
+ expect(offset).toBe(64 + 2 * 120);
+ });
+
+ test('when a scroll offset falls inside a month, then that month is the one reported', () => {
+ const boundaries: GroupBoundary[] = [
+ makeBoundary({ startIndex: 0, id: new Date(2024, 7, 15).toDateString() }),
+ makeBoundary({ startIndex: 30, id: new Date(2024, 6, 10).toDateString() }),
+ makeBoundary({ startIndex: 60, id: new Date(2024, 5, 1).toDateString() }),
+ ];
+ const { monthAnchors } = buildTimelineDateIndex(boundaries);
+
+ const cellSize = 120;
+ const contentTopInset = 64;
+ const numColumns = 3;
+
+ // Offset that lands inside the second month's range (index 30..59)
+ const offset = getOffsetForIndex({ index: 40, cellSize, contentTopInset, numColumns });
+ const index = getIndexForOffset({ offset, cellSize, contentTopInset, numColumns });
+ const anchor = findAnchorForIndex(monthAnchors, index);
+
+ expect(anchor?.startIndex).toBe(30);
+ });
+});
+
+describe('placing the scrubber handle for a scroll position', () => {
+ test('when the list is scrolled halfway, then the handle sits halfway down the rail', () => {
+ expect(getRailAnchorForScroll({ scrollY: 500, maxScroll: 1000, railHeight: 400 })).toBe(200);
+ });
+
+ test('when the list is at the very top, then the handle sits at the top of the rail', () => {
+ expect(getRailAnchorForScroll({ scrollY: 0, maxScroll: 1000, railHeight: 400 })).toBe(0);
+ });
+
+ test('when the list is scrolled past its end, then the handle stops at the bottom of the rail', () => {
+ expect(getRailAnchorForScroll({ scrollY: 1800, maxScroll: 1000, railHeight: 400 })).toBe(400);
+ });
+
+ test('when the list is overscrolled above the top, then the handle stops at the top of the rail', () => {
+ expect(getRailAnchorForScroll({ scrollY: -120, maxScroll: 1000, railHeight: 400 })).toBe(0);
+ });
+
+ test('when there is nothing to scroll, then the handle sits at the top of the rail', () => {
+ expect(getRailAnchorForScroll({ scrollY: 300, maxScroll: 0, railHeight: 400 })).toBe(0);
+ });
+});
+
+describe('finding the anchor that owns a given item position', () => {
+ test('when the index precedes every anchor, then no anchor is returned', () => {
+ const anchors = [{ startIndex: 10, label: 'a' }];
+
+ expect(findAnchorForIndex(anchors, 5)).toBeUndefined();
+ });
+
+ test('when the index matches the last anchor exactly, then that anchor is returned', () => {
+ const anchors = [
+ { startIndex: 0, label: 'a' },
+ { startIndex: 10, label: 'b' },
+ { startIndex: 20, label: 'c' },
+ ];
+
+ expect(findAnchorForIndex(anchors, 20)?.label).toBe('c');
+ expect(findAnchorForIndex(anchors, 25)?.label).toBe('c');
+ expect(findAnchorForIndex(anchors, 15)?.label).toBe('b');
+ });
+});
+
+describe('measuring the total height of the timeline', () => {
+ test('when the content is shorter than the viewport, then the scroll range never collapses to zero', () => {
+ const contentHeight = getTimelineContentHeight({
+ itemCount: 3,
+ cellSize: 120,
+ contentTopInset: 64,
+ paddingBottom: 80,
+ numColumns: 3,
+ });
+
+ // 1 row of 3 items + header inset + bottom padding
+ expect(contentHeight).toBe(64 + 120 + 80);
+ const maxScroll = Math.max(1, contentHeight - 900);
+ expect(maxScroll).toBe(1);
+ });
+});
diff --git a/src/screens/PhotosScreen/utils/photoTimelineLayout.ts b/src/screens/PhotosScreen/utils/photoTimelineLayout.ts
new file mode 100644
index 000000000..37f8ee551
--- /dev/null
+++ b/src/screens/PhotosScreen/utils/photoTimelineLayout.ts
@@ -0,0 +1,154 @@
+import { GroupBoundary, findLastAtOrBefore } from './photoTimelineGroups';
+
+const SKELETON_GROUP_ID = '__skeleton__';
+
+export interface TimelineAnchor {
+ startIndex: number;
+ label: string;
+}
+
+export interface TimelineDateIndex {
+ yearAnchors: TimelineAnchor[];
+ monthAnchors: TimelineAnchor[];
+}
+
+export interface TimelineLayoutMetrics {
+ cellSize: number;
+ contentTopInset: number;
+ numColumns: number;
+}
+
+const formatMonthLabel = (date: Date): string => date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
+
+/**
+ * Derives the year and month anchors used to position the scrubber rail.
+ *
+ * Year anchors sit on the LAST (oldest) boundary of each year; month anchors sit on the FIRST
+ * (newest) boundary of each month instead. Both results are ordered by `startIndex` ascending and
+ * can be binary searched.
+ *
+ * @param boundaries day boundaries of the flat timeline, ordered by `startIndex` ascending.
+ */
+export const buildTimelineDateIndex = (boundaries: GroupBoundary[]): TimelineDateIndex => {
+ const validEntries = boundaries.reduce>((entries, boundary) => {
+ if (boundary.id === SKELETON_GROUP_ID) {
+ return entries;
+ }
+ const date = new Date(boundary.id);
+ if (Number.isNaN(date.getTime())) {
+ return entries;
+ }
+ entries.push({ boundary, date });
+ return entries;
+ }, []);
+
+ const yearAnchors: TimelineAnchor[] = [];
+ const monthAnchors: TimelineAnchor[] = [];
+ let lastMonthKey: string | null = null;
+
+ for (let i = 0; i < validEntries.length; i++) {
+ const { boundary, date } = validEntries[i];
+ const year = date.getFullYear();
+ const monthKey = `${year}-${date.getMonth()}`;
+
+ const nextYear = validEntries[i + 1]?.date.getFullYear() ?? null;
+ if (year !== nextYear) {
+ yearAnchors.push({ startIndex: boundary.startIndex, label: `${year}` });
+ }
+
+ if (monthKey !== lastMonthKey) {
+ monthAnchors.push({ startIndex: boundary.startIndex, label: formatMonthLabel(date) });
+ lastMonthKey = monthKey;
+ }
+ }
+
+ return { yearAnchors, monthAnchors };
+};
+
+/**
+ * Scroll offset in pixels at which the row containing the given item starts.
+ *
+ * @param params.index item index in the flat timeline.
+ * @param params.cellSize height of a grid row in pixels.
+ * @param params.contentTopInset pixels above the first row (list top padding plus list header).
+ * @param params.numColumns columns in the grid.
+ */
+export const getOffsetForIndex = ({
+ index,
+ cellSize,
+ contentTopInset,
+ numColumns,
+}: TimelineLayoutMetrics & { index: number }): number => contentTopInset + Math.floor(index / numColumns) * cellSize;
+
+/**
+ * Index of the first item on the row visible at the given scroll offset. Inverse of
+ * {@link getOffsetForIndex}.
+ *
+ * @param params.offset scroll offset in pixels.
+ * @param params.cellSize height of a grid row in pixels.
+ * @param params.contentTopInset pixels above the first row (list top padding plus list header).
+ * @param params.numColumns columns in the grid.
+ */
+export const getIndexForOffset = ({
+ offset,
+ cellSize,
+ contentTopInset,
+ numColumns,
+}: TimelineLayoutMetrics & { offset: number }): number => {
+ if (cellSize <= 0) {
+ return 0;
+ }
+ const row = Math.floor((offset - contentTopInset) / cellSize);
+ return Math.max(0, row) * numColumns;
+};
+
+/**
+ * Position along the scrubber rail, in pixels from its top, matching a scroll offset. Clamped to
+ * [0, railHeight]. Callable from a worklet and from the JS thread.
+ *
+ * @param params.scrollY scroll offset in pixels.
+ * @param params.maxScroll maximum scroll offset in pixels.
+ * @param params.railHeight rail length in pixels.
+ */
+export const getRailAnchorForScroll = ({
+ scrollY,
+ maxScroll,
+ railHeight,
+}: {
+ scrollY: number;
+ maxScroll: number;
+ railHeight: number;
+}): number => {
+ 'worklet';
+ const fraction = maxScroll > 0 ? Math.min(1, Math.max(0, scrollY / maxScroll)) : 0;
+ return fraction * railHeight;
+};
+
+/**
+ * Total scrollable content height of the timeline, derived from the item count instead of measured,
+ * so it is available on the first render.
+ *
+ * @param params.itemCount items in the flat timeline.
+ * @param params.cellSize height of a grid row in pixels.
+ * @param params.contentTopInset pixels above the first row (list top padding plus list header).
+ * @param params.paddingBottom list bottom padding in pixels.
+ * @param params.numColumns columns in the grid.
+ */
+export const getTimelineContentHeight = ({
+ itemCount,
+ cellSize,
+ contentTopInset,
+ paddingBottom,
+ numColumns,
+}: TimelineLayoutMetrics & { itemCount: number; paddingBottom: number }): number =>
+ contentTopInset + Math.ceil(itemCount / numColumns) * cellSize + paddingBottom;
+
+/**
+ * Last anchor starting at or before the given item index, or undefined when the index precedes
+ * every anchor. Runs a binary search.
+ *
+ * @param anchors anchors ordered by `startIndex` ascending.
+ * @param index item index in the flat timeline.
+ */
+export const findAnchorForIndex = (anchors: TimelineAnchor[], index: number): TimelineAnchor | undefined =>
+ findLastAtOrBefore(anchors, index);