forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {useLayoutEffect, useRef} from 'react'
2import {Gesture, GestureDetector} from 'react-native-gesture-handler'
3import Animated, {
4 type AnimatedRef,
5 measure,
6 runOnJS,
7 scrollTo,
8 type SharedValue,
9 useAnimatedRef,
10 useAnimatedStyle,
11 useFrameCallback,
12 useSharedValue,
13 withSpring,
14 withTiming,
15} from 'react-native-reanimated'
16
17import {useHaptics} from '#/lib/haptics'
18import {atoms as a, useTheme, web} from '#/alf'
19import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
20import {IS_IOS} from '#/env'
21
22/**
23 * Drag-to-reorder list. Items are absolutely positioned in a fixed-height
24 * container and animated via Reanimated shared values on the UI thread.
25 *
26 * All positioning is driven by a `slots` map (key → index) and translateY
27 * (no discrete `top` changes). On drag end the new slot assignment is
28 * computed on the UI thread first, then React state is updated via runOnJS.
29 *
30 * See SortableList.web.tsx for the web implementation using pointer events.
31 */
32
33interface SortableListProps<T> {
34 data: T[]
35 keyExtractor: (item: T) => string
36 renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
37 onReorder: (data: T[]) => void
38 onDragStart?: () => void
39 onDragEnd?: () => void
40 /** Fixed row height used for position math. */
41 itemHeight: number
42 /** Ref to the parent Animated.ScrollView for auto-scroll. */
43 scrollRef?: AnimatedRef<Animated.ScrollView>
44 /** Scroll offset shared value from useScrollViewOffset. */
45 scrollOffset?: SharedValue<number>
46}
47
48const AUTO_SCROLL_THRESHOLD = 50
49const AUTO_SCROLL_SPEED = 4
50
51/**
52 * Bundled into a single shared value so all fields update atomically
53 * in one set() call on the UI thread.
54 */
55interface DragState {
56 /** Maps each item key to its current slot index. */
57 slots: Record<string, number>
58 /** Key of the item being dragged, or '' when idle. */
59 activeKey: string
60 /** Slot the active item started in. */
61 dragStartSlot: number
62}
63
64export function SortableList<T>({
65 data,
66 keyExtractor,
67 renderItem,
68 onReorder,
69 onDragStart,
70 onDragEnd,
71 itemHeight,
72 scrollRef,
73 scrollOffset,
74}: SortableListProps<T>) {
75 const t = useTheme()
76 const state = useSharedValue<DragState>({
77 slots: Object.fromEntries(data.map((item, i) => [keyExtractor(item), i])),
78 activeKey: '',
79 dragStartSlot: -1,
80 })
81 const dragY = useSharedValue(0)
82
83 // Auto-scroll shared values
84 const scrollCompensation = useSharedValue(0)
85 const isGestureActive = useSharedValue(false)
86 // We track scroll position ourselves because scrollOffset.get() lags
87 // by one frame after scrollTo(), causing a feedback loop where the
88 // frame callback keeps thinking the item is at the edge.
89 const trackedScrollY = useSharedValue(0)
90
91 // For measuring list position within scroll content
92 const listRef = useAnimatedRef<Animated.View>()
93 const listContentOffset = useSharedValue(0)
94 const viewportHeight = useSharedValue(0)
95 const measureDone = useSharedValue(false)
96
97 // Sync slots when data changes externally (e.g. pin/unpin).
98 // Skip after our own reorder — the worklet already set correct slots
99 // on the UI thread, and a redundant JS-side set() would be wasteful.
100 const skipNextSync = useRef(false)
101 const currentKeys = data.map(item => keyExtractor(item)).join(',')
102 useLayoutEffect(() => {
103 if (skipNextSync.current) {
104 skipNextSync.current = false
105 return
106 }
107 const nextSlots: Record<string, number> = {}
108 data.forEach((item, i) => {
109 nextSlots[keyExtractor(item)] = i
110 })
111 state.set({slots: nextSlots, activeKey: '', dragStartSlot: -1})
112 dragY.set(0)
113 }, [currentKeys, data, keyExtractor, state, dragY])
114
115 const handleReorder = (sortedKeys: string[]) => {
116 skipNextSync.current = true
117 const byKey = new Map(data.map(item => [keyExtractor(item), item]))
118 onReorder(sortedKeys.map(key => byKey.get(key)!))
119 onDragEnd?.()
120 }
121
122 // Auto-scroll: runs every frame while a gesture is active.
123 useFrameCallback(() => {
124 if (!isGestureActive.get()) return
125 if (!scrollRef || !scrollOffset) return
126
127 const s = state.get()
128 if (s.activeKey === '') return
129
130 // Measure list and scroll view on first frame of drag.
131 // Use scrollOffset here (only once) since no lag has occurred yet.
132 if (!measureDone.get()) {
133 const scrollM = measure(
134 scrollRef as unknown as AnimatedRef<Animated.View>,
135 )
136 const listM = measure(listRef)
137 if (!scrollM || !listM) return
138 trackedScrollY.set(scrollOffset.get())
139 listContentOffset.set(listM.pageY - scrollM.pageY + trackedScrollY.get())
140 viewportHeight.set(scrollM.height)
141 measureDone.set(true)
142 }
143
144 const startSlot = s.dragStartSlot
145 const currentDragY = dragY.get()
146
147 // Use trackedScrollY (not scrollOffset) to avoid the one-frame lag
148 // after scrollTo() that causes a feedback loop.
149 const scrollY = trackedScrollY.get()
150
151 // Item position relative to scroll viewport top.
152 const itemContentY =
153 listContentOffset.get() + startSlot * itemHeight + currentDragY
154 const itemViewportY = itemContentY - scrollY
155 const itemBottomViewportY = itemViewportY + itemHeight
156
157 let scrollDelta = 0
158 if (itemViewportY < AUTO_SCROLL_THRESHOLD) {
159 scrollDelta = -AUTO_SCROLL_SPEED
160 } else if (
161 itemBottomViewportY >
162 viewportHeight.get() - AUTO_SCROLL_THRESHOLD
163 ) {
164 scrollDelta = AUTO_SCROLL_SPEED
165 }
166
167 if (scrollDelta === 0) return
168
169 // Don't scroll if the item is already at a list boundary.
170 const effectiveSlotPos =
171 (startSlot * itemHeight + currentDragY) / itemHeight
172 if (scrollDelta < 0 && effectiveSlotPos <= 0) return
173 if (scrollDelta > 0 && effectiveSlotPos >= data.length - 1) return
174
175 // Don't scroll past the top.
176 if (scrollDelta < 0 && scrollY <= 0) return
177
178 const newScrollY = Math.max(0, scrollY + scrollDelta)
179 scrollTo(scrollRef, 0, newScrollY, false)
180 trackedScrollY.set(newScrollY)
181 scrollCompensation.set(scrollCompensation.get() + (newScrollY - scrollY))
182 })
183
184 // Render in stable key order so React never reorders native views.
185 // On Android, native ViewGroup child reordering causes a visual flash.
186 const sortedData = [...data].sort((a, b) => {
187 const ka = keyExtractor(a)
188 const kb = keyExtractor(b)
189 return ka < kb ? -1 : ka > kb ? 1 : 0
190 })
191
192 return (
193 <Animated.View
194 ref={listRef}
195 style={[{height: data.length * itemHeight}, t.atoms.bg_contrast_25]}>
196 {sortedData.map(item => {
197 const key = keyExtractor(item)
198 return (
199 <SortableItem
200 key={key}
201 item={item}
202 itemKey={key}
203 itemCount={data.length}
204 itemHeight={itemHeight}
205 state={state}
206 dragY={dragY}
207 scrollCompensation={scrollCompensation}
208 isGestureActive={isGestureActive}
209 measureDone={measureDone}
210 renderItem={renderItem}
211 onCommitReorder={handleReorder}
212 onDragStart={onDragStart}
213 onDragEnd={onDragEnd}
214 />
215 )
216 })}
217 </Animated.View>
218 )
219}
220
221function SortableItem<T>({
222 item,
223 itemKey,
224 itemCount,
225 itemHeight,
226 state,
227 dragY,
228 scrollCompensation,
229 isGestureActive,
230 measureDone,
231 renderItem,
232 onCommitReorder,
233 onDragStart,
234 onDragEnd,
235}: {
236 item: T
237 itemKey: string
238 itemCount: number
239 itemHeight: number
240 state: Animated.SharedValue<DragState>
241 dragY: Animated.SharedValue<number>
242 scrollCompensation: SharedValue<number>
243 isGestureActive: SharedValue<boolean>
244 measureDone: SharedValue<boolean>
245 renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
246 onCommitReorder: (sortedKeys: string[]) => void
247 onDragStart?: () => void
248 onDragEnd?: () => void
249}) {
250 const t = useTheme()
251 const playHaptic = useHaptics()
252
253 const lastHapticSlot = useSharedValue(-1)
254
255 const gesture = Gesture.Pan()
256 .onStart(() => {
257 'worklet'
258 const s = state.get()
259 const mySlot = s.slots[itemKey]
260 state.set({...s, activeKey: itemKey, dragStartSlot: mySlot})
261 dragY.set(0)
262 scrollCompensation.set(0)
263 isGestureActive.set(true)
264 measureDone.set(false)
265 lastHapticSlot.set(mySlot)
266 if (onDragStart) {
267 runOnJS(onDragStart)()
268 }
269 runOnJS(playHaptic)()
270 })
271 .onChange(e => {
272 'worklet'
273 const startSlot = state.get().dragStartSlot
274 const minY = -startSlot * itemHeight
275 const maxY = (itemCount - 1 - startSlot) * itemHeight
276 // Include scroll compensation so the item tracks with auto-scroll.
277 const effectiveY = e.translationY + scrollCompensation.get()
278 const clampedY = Math.max(minY, Math.min(effectiveY, maxY))
279 dragY.set(clampedY)
280
281 const currentSlot = Math.round(
282 (startSlot * itemHeight + clampedY) / itemHeight,
283 )
284 const clampedSlot = Math.max(0, Math.min(currentSlot, itemCount - 1))
285 if (IS_IOS && clampedSlot !== lastHapticSlot.get()) {
286 lastHapticSlot.set(clampedSlot)
287 runOnJS(playHaptic)('Light')
288 }
289 })
290 .onEnd(() => {
291 'worklet'
292 // Stop auto-scroll BEFORE the snap animation.
293 isGestureActive.set(false)
294 const startSlot = state.get().dragStartSlot
295 const rawNewSlot = Math.round(
296 (startSlot * itemHeight + dragY.get()) / itemHeight,
297 )
298 const newSlot = Math.max(0, Math.min(rawNewSlot, itemCount - 1))
299 const snapOffset = (newSlot - startSlot) * itemHeight
300
301 // Animate to the target slot, then commit.
302 dragY.set(
303 withTiming(snapOffset, {duration: 200}, finished => {
304 if (finished) {
305 if (newSlot !== startSlot) {
306 // Compute new slots on the UI thread so animated styles
307 // reflect final positions before React re-renders.
308 const cur = state.get()
309 const sorted: string[] = new Array(itemCount)
310 for (const key in cur.slots) {
311 sorted[cur.slots[key]] = key
312 }
313 const movedKey = sorted[startSlot]
314 sorted.splice(startSlot, 1)
315 sorted.splice(newSlot, 0, movedKey)
316
317 const nextSlots: Record<string, number> = {}
318 for (let i = 0; i < sorted.length; i++) {
319 nextSlots[sorted[i]] = i
320 }
321
322 state.set({
323 slots: nextSlots,
324 activeKey: '',
325 dragStartSlot: -1,
326 })
327 dragY.set(0)
328 runOnJS(onCommitReorder)(sorted)
329 } else {
330 const s = state.get()
331 state.set({...s, activeKey: '', dragStartSlot: -1})
332 dragY.set(0)
333 if (onDragEnd) {
334 runOnJS(onDragEnd)()
335 }
336 }
337 }
338 }),
339 )
340 })
341 // Reset if the gesture is cancelled without onEnd firing.
342 .onFinalize(() => {
343 'worklet'
344 isGestureActive.set(false)
345 if (state.get().activeKey === itemKey && dragY.get() === 0) {
346 const s = state.get()
347 state.set({...s, activeKey: '', dragStartSlot: -1})
348 if (onDragEnd) {
349 runOnJS(onDragEnd)()
350 }
351 }
352 })
353
354 // All vertical positioning is via translateY (no `top`). This avoids
355 // discrete jumps when slots change — Reanimated smoothly animates from
356 // the current translateY to the new target on every state transition.
357 // On first mount we skip the animation so items appear instantly.
358 const isFirstRender = useSharedValue(true)
359
360 const animatedStyle = useAnimatedStyle(() => {
361 const s = state.get()
362 const mySlot = s.slots[itemKey]
363 if (mySlot === undefined) {
364 return {}
365 }
366 const baseY = mySlot * itemHeight
367
368 // Active item: follow the finger with a slight scale-up and shadow.
369 if (s.activeKey === itemKey) {
370 return {
371 transform: [
372 {translateY: s.dragStartSlot * itemHeight + dragY.get()},
373 {scale: withSpring(1.03)},
374 ],
375 zIndex: 999,
376 ...(IS_IOS
377 ? {
378 shadowColor: '#000',
379 shadowOffset: {width: 0, height: 1},
380 shadowOpacity: withSpring(0.08),
381 shadowRadius: withSpring(4),
382 }
383 : {
384 elevation: withSpring(3),
385 }),
386 }
387 }
388
389 // Reset for non-active states. Without this, shadow props
390 // set during dragging linger on the native view.
391 const inactive = {
392 ...(IS_IOS
393 ? {
394 shadowOpacity: withSpring(0),
395 shadowRadius: withSpring(0),
396 }
397 : {
398 elevation: withSpring(0),
399 }),
400 }
401
402 // Another item is being dragged — shift to make room.
403 if (s.activeKey !== '') {
404 isFirstRender.set(false)
405 const currentDragPos = Math.round(
406 (s.dragStartSlot * itemHeight + dragY.get()) / itemHeight,
407 )
408 const clampedPos = Math.max(0, Math.min(currentDragPos, itemCount - 1))
409
410 let offset = 0
411 if (
412 s.dragStartSlot < clampedPos &&
413 mySlot > s.dragStartSlot &&
414 mySlot <= clampedPos
415 ) {
416 offset = -itemHeight
417 } else if (
418 s.dragStartSlot > clampedPos &&
419 mySlot < s.dragStartSlot &&
420 mySlot >= clampedPos
421 ) {
422 offset = itemHeight
423 }
424
425 return {
426 transform: [
427 {translateY: withTiming(baseY + offset, {duration: 200})},
428 {scale: withSpring(1)},
429 ],
430 zIndex: 0,
431 ...inactive,
432 }
433 }
434
435 // Idle: sit at our slot. On first render use a direct value so items
436 // don't animate from y=0. After any drag, use withTiming so the
437 // shift→idle transition is smooth (no discrete jump).
438 if (isFirstRender.get()) {
439 isFirstRender.set(false)
440 return {
441 transform: [{translateY: baseY}, {scale: 1}],
442 zIndex: 0,
443 ...inactive,
444 }
445 }
446
447 return {
448 transform: [{translateY: withTiming(baseY, {duration: 200})}, {scale: 1}],
449 zIndex: 0,
450 ...inactive,
451 }
452 })
453
454 const dragHandle = (
455 <GestureDetector gesture={gesture}>
456 <Animated.View
457 testID="feed-drag-handle"
458 style={[
459 a.justify_center,
460 a.align_center,
461 a.px_sm,
462 a.py_md,
463 web({cursor: 'grab'}),
464 ]}
465 hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
466 <GripIcon
467 size="lg"
468 fill={t.atoms.text_contrast_medium.color}
469 style={web({pointerEvents: 'none'})}
470 />
471 </Animated.View>
472 </GestureDetector>
473 )
474
475 return (
476 <Animated.View
477 style={[
478 {
479 position: 'absolute',
480 top: 0,
481 left: 0,
482 right: 0,
483 height: itemHeight,
484 },
485 animatedStyle,
486 ]}>
487 {renderItem(item, dragHandle)}
488 </Animated.View>
489 )
490}