Prevent swipe rows from opening items

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