205 lines
5.2 KiB
TypeScript
205 lines
5.2 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { ReactNode, useRef } from "react";
|
|
import { Pressable, type PressableProps, StyleSheet, Text, View } from "react-native";
|
|
import Swipeable, {
|
|
type SwipeableMethods,
|
|
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
|
import Animated, {
|
|
interpolate,
|
|
useAnimatedStyle,
|
|
type SharedValue,
|
|
} from "react-native-reanimated";
|
|
|
|
import { fonts, radii, spacing } from "@/constants/theme";
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import { resolveActionForeground } from "@/lib/action-contrast";
|
|
import type { ThemeColors } from "@/lib/theme-palette";
|
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
|
|
|
export type SwipeAction = {
|
|
key: string;
|
|
label: string;
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
color: string;
|
|
backgroundColor: string;
|
|
onPress: () => void;
|
|
};
|
|
|
|
type SwipeableRowProps = {
|
|
children: ReactNode;
|
|
actions: SwipeAction[];
|
|
enabled?: boolean;
|
|
backgroundColor?: string;
|
|
onPress?: () => void;
|
|
onLongPress?: () => void;
|
|
contentStyle?: PressableProps["style"];
|
|
};
|
|
|
|
type SwipeActionsProps = {
|
|
actions: SwipeAction[];
|
|
progress: SharedValue<number>;
|
|
onActionPress: (action: SwipeAction) => void;
|
|
};
|
|
|
|
function SwipeActions({ actions, progress, onActionPress }: SwipeActionsProps) {
|
|
const styles = useThemedStyles(createSwipeableRowStyles);
|
|
const revealStyle = useAnimatedStyle(() => ({
|
|
opacity: interpolate(progress.value, [0, 0.02, 0.15], [0, 0, 1], "clamp"),
|
|
}));
|
|
|
|
return (
|
|
<Animated.View style={[styles.actions, revealStyle]}>
|
|
{actions.map((action) => {
|
|
const foreground = resolveActionForeground(action.backgroundColor, action.color);
|
|
|
|
return (
|
|
<Pressable
|
|
key={action.key}
|
|
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
|
|
onPress={() => onActionPress(action)}
|
|
>
|
|
<Ionicons name={action.icon} size={20} color={foreground} />
|
|
<Text style={[styles.actionLabel, { color: foreground }]}>{action.label}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
export function SwipeableRow({
|
|
children,
|
|
actions,
|
|
enabled = true,
|
|
backgroundColor,
|
|
onPress,
|
|
onLongPress,
|
|
contentStyle,
|
|
}: SwipeableRowProps) {
|
|
const { colors } = useAppTheme();
|
|
const styles = useThemedStyles(createSwipeableRowStyles);
|
|
const rowBackground = backgroundColor ?? colors.background;
|
|
const swipeRef = useRef<SwipeableMethods>(null);
|
|
const rowOpenRef = useRef(false);
|
|
const suppressPressUntilRef = useRef(0);
|
|
|
|
function suppressContentPress() {
|
|
suppressPressUntilRef.current = Date.now() + 350;
|
|
}
|
|
|
|
function handleContentPress() {
|
|
if (!onPress) return;
|
|
|
|
if (rowOpenRef.current || Date.now() < suppressPressUntilRef.current) {
|
|
swipeRef.current?.close();
|
|
rowOpenRef.current = false;
|
|
return;
|
|
}
|
|
|
|
onPress();
|
|
}
|
|
|
|
function renderContent() {
|
|
if (!onPress && !onLongPress) {
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.row,
|
|
{ backgroundColor: rowBackground },
|
|
typeof contentStyle === "function" ? undefined : contentStyle,
|
|
]}
|
|
>
|
|
{children}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
onPress={handleContentPress}
|
|
onLongPress={onLongPress}
|
|
style={(state) => [
|
|
styles.row,
|
|
{ backgroundColor: rowBackground },
|
|
typeof contentStyle === "function" ? contentStyle(state) : contentStyle,
|
|
]}
|
|
>
|
|
{children}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function handleActionPress(action: SwipeAction) {
|
|
suppressContentPress();
|
|
swipeRef.current?.close();
|
|
rowOpenRef.current = false;
|
|
action.onPress();
|
|
}
|
|
|
|
function renderRightActions(progress: SharedValue<number>) {
|
|
return (
|
|
<SwipeActions
|
|
actions={actions}
|
|
progress={progress}
|
|
onActionPress={handleActionPress}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!enabled || actions.length === 0) {
|
|
return renderContent();
|
|
}
|
|
|
|
return (
|
|
<Swipeable
|
|
ref={swipeRef}
|
|
containerStyle={styles.container}
|
|
renderRightActions={renderRightActions}
|
|
overshootRight={false}
|
|
onSwipeableOpenStartDrag={suppressContentPress}
|
|
onSwipeableCloseStartDrag={suppressContentPress}
|
|
onSwipeableWillOpen={() => {
|
|
suppressContentPress();
|
|
rowOpenRef.current = true;
|
|
}}
|
|
onSwipeableWillClose={suppressContentPress}
|
|
onSwipeableClose={() => {
|
|
rowOpenRef.current = false;
|
|
}}
|
|
>
|
|
{renderContent()}
|
|
</Swipeable>
|
|
);
|
|
}
|
|
|
|
const createSwipeableRowStyles = (colors: ThemeColors) =>
|
|
StyleSheet.create({
|
|
container: {
|
|
borderRadius: radii.lg,
|
|
overflow: "hidden",
|
|
marginBottom: spacing.xs,
|
|
},
|
|
row: {
|
|
backgroundColor: colors.background,
|
|
borderRadius: radii.lg,
|
|
overflow: "hidden",
|
|
},
|
|
actions: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
},
|
|
actionButton: {
|
|
width: 80,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: spacing.xs,
|
|
borderRadius: radii.md,
|
|
marginLeft: spacing.xs,
|
|
},
|
|
actionLabel: {
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 11,
|
|
},
|
|
});
|