Prevent swipe rows from opening items
This commit is contained in:
@@ -2,7 +2,6 @@ import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -155,23 +154,22 @@ export default function EntitiesScreen() {
|
||||
onPress: () => confirmDelete(client.id, client.name),
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
|
||||
>
|
||||
<Pressable onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<Text style={styles.name}>{client.name}</Text>
|
||||
{client.email ? (
|
||||
<Text style={styles.meta}>{client.email}</Text>
|
||||
) : null}
|
||||
{client.defaultHourlyRate != null ? (
|
||||
<Text style={styles.meta}>
|
||||
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
||||
/hr
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</Pressable>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<Text style={styles.name}>{client.name}</Text>
|
||||
{client.email ? (
|
||||
<Text style={styles.meta}>{client.email}</Text>
|
||||
) : null}
|
||||
{client.defaultHourlyRate != null ? (
|
||||
<Text style={styles.meta}>
|
||||
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
||||
/hr
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</SwipeableRow>
|
||||
))
|
||||
)
|
||||
@@ -205,23 +203,22 @@ export default function EntitiesScreen() {
|
||||
onPress: () => confirmDelete(business.id, business.name),
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
|
||||
>
|
||||
<Pressable onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? (
|
||||
<Text style={styles.badge}>Default</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{business.nickname ? (
|
||||
<Text style={styles.meta}>{business.nickname}</Text>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? (
|
||||
<Text style={styles.badge}>Default</Text>
|
||||
) : null}
|
||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</Pressable>
|
||||
{business.nickname ? (
|
||||
<Text style={styles.meta}>{business.nickname}</Text>
|
||||
) : null}
|
||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</SwipeableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -205,31 +204,32 @@ export default function InvoicesScreen() {
|
||||
];
|
||||
|
||||
return (
|
||||
<SwipeableRow key={invoice.id} actions={actions} backgroundColor={colors.cardGlass}>
|
||||
<Pressable
|
||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||
onLongPress={() => promptStatusChange(invoice.id, status)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.cardTop}>
|
||||
<View style={styles.cardMeta}>
|
||||
<Text style={styles.invoiceNumber}>{label}</Text>
|
||||
<Text style={styles.clientName}>
|
||||
{invoice.client?.name ?? "Client"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.amount}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
<SwipeableRow
|
||||
key={invoice.id}
|
||||
actions={actions}
|
||||
backgroundColor={colors.cardGlass}
|
||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||
onLongPress={() => promptStatusChange(invoice.id, status)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.cardTop}>
|
||||
<View style={styles.cardMeta}>
|
||||
<Text style={styles.invoiceNumber}>{label}</Text>
|
||||
<Text style={styles.clientName}>
|
||||
{invoice.client?.name ?? "Client"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardBottom}>
|
||||
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
<Text style={styles.amount}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</Pressable>
|
||||
<View style={styles.cardBottom}>
|
||||
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</SwipeableRow>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -225,6 +224,12 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
||||
return (
|
||||
<SwipeableRow
|
||||
backgroundColor={colors.cardGlass}
|
||||
contentStyle={({ pressed }) => [
|
||||
styles.row,
|
||||
{ borderColor: colors.border },
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
onPress={() => router.push(`/(app)/more/expenses/${expense.id}` as never)}
|
||||
actions={[
|
||||
{
|
||||
key: "open",
|
||||
@@ -244,67 +249,57 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.push(`/(app)/more/expenses/${expense.id}` as never)}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
{ borderColor: colors.border },
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
|
||||
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
|
||||
{expense.description}
|
||||
</Text>
|
||||
{expense.receiptCount ? (
|
||||
<View
|
||||
style={[
|
||||
styles.receiptPill,
|
||||
{ borderColor: colors.border, backgroundColor: colors.background },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
|
||||
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
|
||||
{expense.receiptCount}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
|
||||
{expense.description}
|
||||
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
|
||||
{formatDate(expense.date)}
|
||||
{expense.category ? ` · ${expense.category}` : ""}
|
||||
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||
</Text>
|
||||
<View style={styles.tagRow}>
|
||||
{expense.billable ? (
|
||||
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
|
||||
Billable
|
||||
</Text>
|
||||
{expense.receiptCount ? (
|
||||
<View
|
||||
style={[
|
||||
styles.receiptPill,
|
||||
{ borderColor: colors.border, backgroundColor: colors.background },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
|
||||
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
|
||||
{expense.receiptCount}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
|
||||
{formatDate(expense.date)}
|
||||
{expense.category ? ` · ${expense.category}` : ""}
|
||||
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||
</Text>
|
||||
<View style={styles.tagRow}>
|
||||
{expense.billable ? (
|
||||
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
|
||||
Billable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.reimbursable ? (
|
||||
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
|
||||
Reimbursable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.taxDeductible ? (
|
||||
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
|
||||
Tax
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{expense.reimbursable ? (
|
||||
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
|
||||
Reimbursable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.taxDeductible ? (
|
||||
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
|
||||
Tax
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.amountStack}>
|
||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.amountStack}>
|
||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
|
||||
</View>
|
||||
</SwipeableRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Pressable, type PressableProps, StyleSheet, Text, View } from "react-native";
|
||||
import Swipeable, {
|
||||
type SwipeableMethods,
|
||||
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||
@@ -24,6 +24,9 @@ type SwipeableRowProps = {
|
||||
actions: SwipeAction[];
|
||||
enabled?: boolean;
|
||||
backgroundColor?: string;
|
||||
onPress?: () => void;
|
||||
onLongPress?: () => void;
|
||||
contentStyle?: PressableProps["style"];
|
||||
};
|
||||
|
||||
export function SwipeableRow({
|
||||
@@ -31,11 +34,63 @@ export function SwipeableRow({
|
||||
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 renderRightActions() {
|
||||
return (
|
||||
@@ -45,7 +100,9 @@ export function SwipeableRow({
|
||||
key={action.key}
|
||||
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
|
||||
onPress={() => {
|
||||
suppressContentPress();
|
||||
swipeRef.current?.close();
|
||||
rowOpenRef.current = false;
|
||||
action.onPress();
|
||||
}}
|
||||
>
|
||||
@@ -58,12 +115,26 @@ export function SwipeableRow({
|
||||
}
|
||||
|
||||
if (!enabled || actions.length === 0) {
|
||||
return <View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>;
|
||||
return renderContent();
|
||||
}
|
||||
|
||||
return (
|
||||
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
|
||||
<View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>
|
||||
<Swipeable
|
||||
ref={swipeRef}
|
||||
renderRightActions={renderRightActions}
|
||||
overshootRight={false}
|
||||
onSwipeableOpenStartDrag={suppressContentPress}
|
||||
onSwipeableCloseStartDrag={suppressContentPress}
|
||||
onSwipeableWillOpen={() => {
|
||||
suppressContentPress();
|
||||
rowOpenRef.current = true;
|
||||
}}
|
||||
onSwipeableWillClose={suppressContentPress}
|
||||
onSwipeableClose={() => {
|
||||
rowOpenRef.current = false;
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</Swipeable>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user