Untitled

 avatar
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