mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
164 lines
5.0 KiB
TypeScript
164 lines
5.0 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
|
import {
|
|
StyleSheet,
|
|
View,
|
|
type LayoutChangeEvent
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import {
|
|
Gesture,
|
|
GestureDetector,
|
|
type GestureType
|
|
} from 'react-native-gesture-handler';
|
|
import Animated, {
|
|
runOnJS,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withTiming
|
|
} from 'react-native-reanimated';
|
|
import { useColors } from '@/theme/themed';
|
|
import { motion } from '@/theme/motion';
|
|
import { playHaptic } from '@/lib/haptics';
|
|
|
|
type IconName = keyof typeof Ionicons.glyphMap;
|
|
|
|
const SWIPE_ACTIVE_OFFSET_X = 10;
|
|
// Scroll-slop-sized: at 30 every vertical drag starting on a row had to travel
|
|
// 30px before the pan failed and the surrounding scrollable could win.
|
|
const SWIPE_FAIL_OFFSET_Y = 12;
|
|
|
|
export interface SwipeAction {
|
|
icon: IconName;
|
|
/** Background of the revealed action lane. */
|
|
color: string;
|
|
iconColor?: string;
|
|
onCommit: () => void;
|
|
}
|
|
|
|
interface SwipeableRowProps {
|
|
/** Revealed on the LEFT as the row is dragged right (a rightward swipe). */
|
|
swipeRight?: SwipeAction;
|
|
/** Revealed on the RIGHT as the row is dragged left (a leftward swipe). */
|
|
swipeLeft?: SwipeAction;
|
|
/**
|
|
* Optional vertical gesture (e.g. a hold-to-drag reorder) raced against the
|
|
* horizontal swipe in the SAME detector — composing here avoids the nested
|
|
* GestureDetectors that broke continuous hold-and-drag.
|
|
*/
|
|
dragGesture?: GestureType;
|
|
enabled?: boolean;
|
|
children: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Horizontally swipeable row. Translation is clamped to ±width/2; a haptic tick
|
|
* fires when crossing the ±width/4 "arm" point in either direction (arming to
|
|
* commit, or backing off). Releasing past the arm point runs the matching
|
|
* action; otherwise the row springs back. Vertical drags fall through so the row
|
|
* still scrolls / lets a parent sheet pan.
|
|
*/
|
|
export function SwipeableRow({
|
|
swipeRight,
|
|
swipeLeft,
|
|
dragGesture,
|
|
enabled = true,
|
|
children,
|
|
}: SwipeableRowProps) {
|
|
const colors = useColors();
|
|
const tx = useSharedValue(0);
|
|
const armed = useSharedValue(false);
|
|
const [rowWidth, setRowWidth] = useState(0);
|
|
|
|
const max = rowWidth / 2;
|
|
const arm = rowWidth / 4;
|
|
const hasRight = !!swipeRight;
|
|
const hasLeft = !!swipeLeft;
|
|
|
|
const onLayout = (e: LayoutChangeEvent) => setRowWidth(e.nativeEvent.layout.width);
|
|
|
|
const onCommit = (direction: 'right' | 'left') => {
|
|
if (direction === 'right') swipeRight?.onCommit();
|
|
else swipeLeft?.onCommit();
|
|
playHaptic('confirm');
|
|
};
|
|
|
|
const pan = Gesture.Pan()
|
|
.enabled(enabled && rowWidth > 0 && (hasRight || hasLeft))
|
|
.activeOffsetX([-SWIPE_ACTIVE_OFFSET_X, SWIPE_ACTIVE_OFFSET_X])
|
|
.failOffsetY([-SWIPE_FAIL_OFFSET_Y, SWIPE_FAIL_OFFSET_Y])
|
|
.onUpdate((e) => {
|
|
let t = e.translationX;
|
|
if (t > 0 && !hasRight) t = 0;
|
|
if (t < 0 && !hasLeft) t = 0;
|
|
t = Math.max(-max, Math.min(max, t));
|
|
tx.value = t;
|
|
const nowArmed = Math.abs(t) >= arm;
|
|
if (nowArmed !== armed.value) {
|
|
armed.value = nowArmed;
|
|
runOnJS(playHaptic)('threshold');
|
|
}
|
|
})
|
|
.onEnd(() => {
|
|
const t = tx.value;
|
|
if (t >= arm && hasRight) runOnJS(onCommit)('right');
|
|
else if (t <= -arm && hasLeft) runOnJS(onCommit)('left');
|
|
armed.value = false;
|
|
tx.value = withTiming(0, motion.quick);
|
|
});
|
|
|
|
// Race so a horizontal swipe and a (long-press) vertical drag never fight:
|
|
// whichever activates first wins and cancels the other.
|
|
const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan;
|
|
|
|
const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }));
|
|
const leftLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value > 1 ? 1 : 0 }));
|
|
const rightLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value < -1 ? 1 : 0 }));
|
|
|
|
return (
|
|
<View style={styles.wrap} onLayout={onLayout}>
|
|
{swipeRight ? (
|
|
<Animated.View
|
|
pointerEvents="none"
|
|
style={[styles.lane, styles.laneLeft, { backgroundColor: swipeRight.color }, leftLaneStyle]}
|
|
>
|
|
<Ionicons name={swipeRight.icon} size={22} color={swipeRight.iconColor ?? colors.bgPrimary} />
|
|
</Animated.View>
|
|
) : null}
|
|
{swipeLeft ? (
|
|
<Animated.View
|
|
pointerEvents="none"
|
|
style={[styles.lane, styles.laneRight, { backgroundColor: swipeLeft.color }, rightLaneStyle]}
|
|
>
|
|
<Ionicons name={swipeLeft.icon} size={22} color={swipeLeft.iconColor ?? colors.bgPrimary} />
|
|
</Animated.View>
|
|
) : null}
|
|
<GestureDetector gesture={gesture}>
|
|
<Animated.View style={contentStyle}>{children}</Animated.View>
|
|
</GestureDetector>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
wrap: {
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
},
|
|
lane: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 24,
|
|
},
|
|
laneLeft: {
|
|
justifyContent: 'flex-start',
|
|
},
|
|
laneRight: {
|
|
justifyContent: 'flex-end',
|
|
},
|
|
});
|