Untitled
unknown
plain_text
2 days ago
8.2 kB
35
Indexable
import React, { memo, useCallback, useEffect, useMemo, useState } from "react";
import { SafeAreaView, StyleSheet, Text } from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import Animated, {
runOnJS,
scrollTo,
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
type Row = { id: string; label: string };
const ITEM_H = 56;
const GAP = 10;
const SLOT_H = ITEM_H + GAP;
const EDGE = 90; // edge threshold for auto-scroll
const SCROLL_STEP = 18; // px per update while autoscrolling
const DRAG_PRESS_MS = 140; // press & hold to activate drag
const clampW = (v: number, lo: number, hi: number) => {
"worklet";
return v < lo ? lo : v > hi ? hi : v;
};
function makeData(count = 28): Row[] {
return Array.from({ length: count }, (_, i) => ({
id: String(i + 1),
label: `Item ${i + 1}`,
}));
}
function positionsFromOrder(order: string[]) {
const map: Record<string, number> = {};
for (let i = 0; i < order.length; i++) map[order[i]] = i;
return map;
}
function orderFromPositions(pos: Record<string, number>) {
return Object.keys(pos).sort((a, b) => pos[a] - pos[b]);
}
function moveInPositions(positions: Record<string, number>, movingId: string, toIndex: number) {
"worklet";
const fromIndex = positions[movingId];
if (fromIndex === toIndex) return;
if (toIndex > fromIndex) {
for (const id in positions) {
const idx = positions[id];
if (idx > fromIndex && idx <= toIndex) positions[id] = idx - 1;
}
} else {
for (const id in positions) {
const idx = positions[id];
if (idx >= toIndex && idx < fromIndex) positions[id] = idx + 1;
}
}
positions[movingId] = toIndex;
}
const DraggableRow = memo(function DraggableRow({
row,
count,
positionsSV,
onCommitPositions,
scrollRef,
scrollY,
viewportH,
contentH,
setScrollEnabled,
}: {
row: Row;
count: number;
positionsSV: Animated.SharedValue<Record<string, number>>;
onCommitPositions: (pos: Record<string, number>) => void;
// ✅ keep this as the Animated ref (don’t wrap/mutate it)
scrollRef: any;
scrollY: Animated.SharedValue<number>;
viewportH: Animated.SharedValue<number>;
contentH: Animated.SharedValue<number>;
setScrollEnabled: (v: boolean) => void;
}) {
const isDragging = useSharedValue(false);
const dragDY = useSharedValue(0);
const startY = useSharedValue(0);
const startScrollY = useSharedValue(0);
const pan = useMemo(() => {
return Gesture.Pan()
.activateAfterLongPress(DRAG_PRESS_MS) // ✅ this is the “press to drag, otherwise scroll”
.onBegin(() => {
isDragging.value = true;
dragDY.value = 0;
startY.value = positionsSV.value[row.id] * SLOT_H;
startScrollY.value = scrollY.value;
// while dragging, stop ScrollView from also scrolling
runOnJS(setScrollEnabled)(false);
})
.onUpdate((e) => {
// --- auto-scroll ---
const maxScroll = clampW(contentH.value - viewportH.value, 0, 99999999);
const scrollDelta = scrollY.value - startScrollY.value;
const draggedContentY = startY.value + e.translationY + scrollDelta;
const yInView = draggedContentY - scrollY.value;
let nextScroll = scrollY.value;
if (yInView < EDGE) {
nextScroll = clampW(scrollY.value - SCROLL_STEP, 0, maxScroll);
} else if (yInView > viewportH.value - EDGE - ITEM_H) {
nextScroll = clampW(scrollY.value + SCROLL_STEP, 0, maxScroll);
}
if (nextScroll !== scrollY.value) {
scrollTo(scrollRef, 0, nextScroll, false);
scrollY.value = nextScroll;
}
// --- reorder math ---
dragDY.value = e.translationY;
const y = startY.value + dragDY.value + (scrollY.value - startScrollY.value);
const rawIndex = Math.round(y / SLOT_H);
const nextIndex = clampW(rawIndex, 0, count - 1);
if (nextIndex !== positionsSV.value[row.id]) {
const next = { ...positionsSV.value };
moveInPositions(next, row.id, nextIndex);
positionsSV.value = next;
}
})
.onFinalize(() => {
isDragging.value = false;
dragDY.value = withTiming(0, { duration: 140 });
runOnJS(setScrollEnabled)(true);
runOnJS(onCommitPositions)({ ...positionsSV.value });
});
}, [count, contentH, onCommitPositions, positionsSV, row.id, scrollRef, scrollY, viewportH, setScrollEnabled]);
const style = useAnimatedStyle(() => {
const idx = positionsSV.value[row.id];
const baseY = idx * SLOT_H;
const y = isDragging.value
? startY.value + dragDY.value + (scrollY.value - startScrollY.value)
: baseY;
return {
position: "absolute",
left: 16,
right: 16,
height: ITEM_H,
transform: [{ translateY: withSpring(y, { damping: 20, stiffness: 220 }) }],
zIndex: isDragging.value ? 100 : 0,
opacity: isDragging.value ? 0.98 : 1,
};
});
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.row, style]}>
<Text style={styles.text}>{row.label}</Text>
<Text style={styles.hint}>Press & hold to drag</Text>
</Animated.View>
</GestureDetector>
);
});
export default function SortableStackScrollPressDrag() {
const data = useMemo(() => makeData(28), []);
const [order, setOrder] = useState(() => data.map((d) => d.id));
const [scrollEnabled, setScrollEnabled] = useState(true);
const positionsSV = useSharedValue<Record<string, number>>(positionsFromOrder(order));
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollY = useSharedValue(0);
const viewportH = useSharedValue(0);
const contentH = useSharedValue(order.length * SLOT_H + 16);
useEffect(() => {
positionsSV.value = positionsFromOrder(order);
contentH.value = order.length * SLOT_H + 16;
}, [order, positionsSV, contentH]);
const onCommitPositions = useCallback((pos: Record<string, number>) => {
setOrder(orderFromPositions(pos));
}, []);
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => {
scrollY.value = e.contentOffset.y;
},
});
const containerH = useMemo(() => order.length * SLOT_H + 16, [order.length]);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaView style={styles.container}>
<Text style={styles.title}>Draggable scrolling component</Text>
<Animated.ScrollView
ref={scrollRef}
onScroll={onScroll}
scrollEventThrottle={16}
bounces={false}
scrollEnabled={scrollEnabled}
onLayout={(e) => {
viewportH.value = e.nativeEvent.layout.height;
}}
contentContainerStyle={{ height: containerH }}
style={{ flex: 1 }}
>
<Animated.View style={{ height: containerH }}>
{data.map((row) => (
<DraggableRow
key={row.id}
row={row}
count={order.length}
positionsSV={positionsSV}
onCommitPositions={onCommitPositions}
scrollRef={scrollRef}
scrollY={scrollY}
viewportH={viewportH}
contentH={contentH}
setScrollEnabled={setScrollEnabled}
/>
))}
</Animated.View>
</Animated.ScrollView>
</SafeAreaView>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#0E0E11", paddingTop: 20 },
title: { color: "white", fontSize: 18, fontWeight: "700", paddingHorizontal: 16, marginTop: 8 },
sub: { color: "rgba(255,255,255,0.6)", fontSize: 13, paddingHorizontal: 16, marginTop: 6 },
row: {
borderRadius: 12,
backgroundColor: "#1C1C22",
justifyContent: "center",
paddingHorizontal: 16,
},
text: { color: "white", fontSize: 15, marginBottom: 2 },
hint: { color: "rgba(255,255,255,0.45)", fontSize: 11 },
});
Editor is loading...
Leave a Comment