feat: add swipe mode settings and enhance swipeable row functionality

This commit is contained in:
Linus Rath
2026-05-02 17:22:05 +02:00
parent a8bb83ffe0
commit f8064d4544
5 changed files with 212 additions and 51 deletions
+153 -36
View File
@@ -1,6 +1,6 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import {
View, Text, StyleSheet, Animated, PanResponder, Dimensions,
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
@@ -8,7 +8,7 @@ import {
} from 'lucide-react-native';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import type { SwipeAction } from '../stores/settings-store';
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
interface SwipeableRowProps {
children: React.ReactNode;
@@ -19,14 +19,24 @@ interface SwipeableRowProps {
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean; pinned: boolean };
onAction: (action: SwipeAction) => void;
/**
* 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action.
* 'reveal': swipe past ACTIVATION_THRESHOLD reveals an action band that must be tapped.
*/
mode?: SwipeMode;
}
// Distance (px) of horizontal travel needed to commit the action on release.
// Drag less than this and the row snaps back without firing.
// Instant mode: distance the user must drag past on release to fire the action.
const COMMIT_THRESHOLD = 96;
// Reveal mode: drag past this on release to snap to the open (revealed) state.
const ACTIVATION_THRESHOLD = 32;
// Reveal mode: width of the action band shown when revealed.
const REVEAL_WIDTH = 88;
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
const MIN_DX_TO_CLAIM = 6;
const MAX_DRAG_OVERSHOOT = 240;
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
@@ -55,7 +65,7 @@ function exitsRow(action: SwipeAction): boolean {
}
export function SwipeableRow({
children, leftAction, rightAction, context, onAction,
children, leftAction, rightAction, context, onAction, mode = 'instant',
}: SwipeableRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -63,8 +73,21 @@ export function SwipeableRow({
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
const snapBack = () => {
// Reveal-mode-only state: which side (if any) is currently sitting open.
const openSideRef = useRef<'left' | 'right' | null>(null);
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
const close = () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = null;
setOpenSide(null);
};
const openTo = (side: 'left' | 'right') => {
const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = side;
setOpenSide(side);
};
const fly = (toValue: number, action: SwipeAction) => {
@@ -85,7 +108,19 @@ export function SwipeableRow({
});
} else {
onAction(action);
snapBack();
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
};
const fireFromBandTap = (action: SwipeAction) => {
close();
if (action === 'none') return;
if (exitsRow(action)) {
// Let the row collapse a frame, then fire so the parent's list update
// has a clean starting point.
requestAnimationFrame(() => onAction(action));
} else {
onAction(action);
}
};
@@ -95,40 +130,121 @@ export function SwipeableRow({
onMoveShouldSetPanResponder: (_, g) => {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
// Reveal mode: if a side is already open, allow the gesture so the
// user can drag it closed.
if (mode === 'reveal' && openSideRef.current === null) {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
} else if (mode !== 'reveal') {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
}
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
const clamped = Math.max(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, g.dx));
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
const base =
mode === 'reveal'
? openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0
: 0;
const next = base + g.dx;
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
claimed.current = false;
if (mode === 'reveal') {
const base =
openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0;
const finalDx = base + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
openTo('right');
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
openTo('left');
} else {
close();
}
return;
}
// Instant mode: fire on release-past-threshold, no second tap.
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, rightAction);
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
} else {
snapBack();
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
},
onPanResponderTerminate: () => {
snapBack();
if (mode === 'reveal') close();
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
claimed.current = false;
},
onPanResponderTerminationRequest: () => !claimed.current,
}),
).current;
const renderBand = (action: SwipeAction, side: 'left' | 'right') => {
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
widthRef.current = e.nativeEvent.layout.width;
};
if (mode === 'reveal') {
// Reveal mode: bands sit at the row edges with a fixed REVEAL_WIDTH and
// are tap targets. The row slides over them; releasing past the activation
// threshold snaps the row to the open position so the band is fully
// visible and tappable.
const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
return (
<Pressable
onPress={() => fireFromBandTap(action)}
style={[
styles.bandReveal,
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
]}
>
<View style={styles.bandInner}>
<Icon size={20} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</View>
</Pressable>
);
};
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderRevealBand(rightAction, 'left')}
{renderRevealBand(leftAction, 'right')}
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
{/* When an action is revealed, an overlay absorbs taps on the row so
a tap closes the swipe instead of opening the email. */}
{openSide ? (
<Pressable
style={StyleSheet.absoluteFill}
onPress={close}
/>
) : null}
</Animated.View>
</View>
);
}
// Instant mode: bands fill the gap behind the row as it's dragged.
const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
// Band stretches to fill the gap behind the row as it's dragged. We
// animate the icon a touch to signal commit-readiness.
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
const iconScale = dx.interpolate({
inputRange,
@@ -138,7 +254,7 @@ export function SwipeableRow({
return (
<View
style={[
styles.band,
styles.bandInstant,
{ backgroundColor: meta.bg },
side === 'left' ? { justifyContent: 'flex-start' } : { justifyContent: 'flex-end' },
]}
@@ -151,12 +267,6 @@ export function SwipeableRow({
);
};
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
widthRef.current = e.nativeEvent.layout.width;
};
// Show the matching band based on current drag direction. Both bands occupy
// the full row, so we fade the inactive one out to avoid bleed-through.
const rightBandOpacity = dx.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 0, 1],
@@ -171,10 +281,10 @@ export function SwipeableRow({
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: rightBandOpacity }]} pointerEvents="none">
{renderBand(rightAction, 'left')}
{renderInstantBand(rightAction, 'left')}
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: leftBandOpacity }]} pointerEvents="none">
{renderBand(leftAction, 'right')}
{renderInstantBand(leftAction, 'right')}
</Animated.View>
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
@@ -185,15 +295,22 @@ export function SwipeableRow({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
content: { backgroundColor: c.background },
band: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
content: { backgroundColor: c.background },
bandInstant: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
},
bandReveal: {
position: 'absolute',
top: 0,
bottom: 0,
justifyContent: 'center',
paddingHorizontal: 16,
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
});
}
+22 -6
View File
@@ -1,10 +1,10 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
import { SettingsSection, RadioGroup } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -17,6 +17,11 @@ const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'move', label: 'Move to folder' },
];
const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
{ value: 'instant', label: 'Instant (swipe = action)' },
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
];
export function LayoutSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -24,6 +29,8 @@ export function LayoutSettings() {
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -32,6 +39,19 @@ export function LayoutSettings() {
return (
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<View style={{ gap: spacing.sm }}>
<Text style={styles.rowLabel}>Swipe behavior</Text>
<Text style={styles.rowDescription}>
Pick instant (swipe past the threshold to fire the action) or reveal
(swipe to expose an action band, then tap to confirm).
</Text>
<RadioGroup
value={swipeMode}
onChange={(v) => setSwipeMode(v as SwipeMode)}
options={SWIPE_MODE_OPTIONS}
/>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<ArrowRight size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
@@ -61,10 +81,6 @@ export function LayoutSettings() {
/>
</View>
<SettingItem
label=""
description="Swipe past the threshold and release to fire the action; release before to cancel."
/>
</SettingsSection>
);
}
+27 -8
View File
@@ -1,9 +1,9 @@
import React from 'react';
import {
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
KeyboardAvoidingView, Platform, ActivityIndicator, Alert, Modal,
Keyboard, Platform, ActivityIndicator, Alert, Modal,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough,
@@ -209,6 +209,28 @@ export default function ComposeScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
// Track keyboard height so the format bar stays just above the IME.
// KeyboardAvoidingView is unreliable on Android edge-to-edge, so we
// listen to the Keyboard module directly on both platforms.
const [kbHeight, setKbHeight] = React.useState(0);
React.useEffect(() => {
const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSub = Keyboard.addListener(showEvt, (e) => {
setKbHeight(e.endCoordinates?.height ?? 0);
});
const hideSub = Keyboard.addListener(hideEvt, () => {
setKbHeight(0);
});
return () => {
showSub.remove();
hideSub.remove();
};
}, []);
// When the keyboard is up it covers the bottom safe area, so we don't
// need to add it on top — pad by whichever is larger.
const bottomPad = Math.max(kbHeight, insets.bottom);
const replyTo = route.params?.replyTo;
const mode = route.params?.mode ?? 'compose';
const prefillTo = route.params?.prefillTo;
@@ -703,7 +725,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
: 'email_composer.new_message';
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn}>
<X size={22} color={c.text} />
@@ -741,10 +763,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
</View>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
<ScrollView
style={styles.flex}
keyboardShouldPersistTaps="handled"
@@ -922,7 +941,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
icon={<Redo2 size={18} color={c.textSecondary} />} />
</ScrollView>
</KeyboardAvoidingView>
</View>
<Modal
visible={linkPromptVisible}
+3 -1
View File
@@ -177,6 +177,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const networkOnline = useNetworkStore((s) => s.online);
const archiveMailboxId = React.useMemo(
@@ -274,6 +275,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
rightAction={selectionMode ? 'none' : swipeRightAction}
mode={swipeMode}
context={{ unread: isUnread(item), starred: isStarred(item), pinned: isPinned(item) }}
onAction={(action) => handleSwipeAction(item.id, action)}
>
@@ -286,7 +288,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
/>
</SwipeableRow>
),
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction],
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleSwipeAction],
);
const clearSelection = React.useCallback(() => {
+7
View File
@@ -19,6 +19,7 @@ export type SwipeAction =
| 'star'
| 'pin'
| 'move';
export type SwipeMode = 'instant' | 'reveal';
export type ArchiveMode = 'single' | 'year' | 'month';
interface PersistedSettings {
@@ -40,6 +41,7 @@ interface PersistedSettings {
// Layout / list interactions
swipeLeftAction: SwipeAction;
swipeRightAction: SwipeAction;
swipeMode: SwipeMode;
// Archive
archiveMode: ArchiveMode;
}
@@ -60,6 +62,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
swipeLeftAction: 'archive',
swipeRightAction: 'read',
swipeMode: 'instant',
archiveMode: 'single',
};
@@ -85,6 +88,7 @@ export interface SettingsState extends PersistedSettings {
setAttachmentReminderKeywords: (keywords: string[]) => void;
setSwipeLeftAction: (action: SwipeAction) => void;
setSwipeRightAction: (action: SwipeAction) => void;
setSwipeMode: (mode: SwipeMode) => void;
setArchiveMode: (mode: ArchiveMode) => void;
addTrustedSender: (email: string) => void;
removeTrustedSender: (email: string) => void;
@@ -109,6 +113,7 @@ function snapshot(state: SettingsState): PersistedSettings {
attachmentReminderKeywords: state.attachmentReminderKeywords,
swipeLeftAction: state.swipeLeftAction,
swipeRightAction: state.swipeRightAction,
swipeMode: state.swipeMode,
archiveMode: state.archiveMode,
};
}
@@ -158,6 +163,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
attachmentReminderKeywords: Array.isArray(parsed.attachmentReminderKeywords) ? parsed.attachmentReminderKeywords : DEFAULT_PERSISTED.attachmentReminderKeywords,
swipeLeftAction: parsed.swipeLeftAction ?? DEFAULT_PERSISTED.swipeLeftAction,
swipeRightAction: parsed.swipeRightAction ?? DEFAULT_PERSISTED.swipeRightAction,
swipeMode: parsed.swipeMode ?? DEFAULT_PERSISTED.swipeMode,
archiveMode: parsed.archiveMode ?? DEFAULT_PERSISTED.archiveMode,
hydrated: true,
});
@@ -183,6 +189,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); },
setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); },
setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); },
setSwipeMode: (mode) => { set({ swipeMode: mode }); persist(snapshot(get())); },
setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); },
addTrustedSender: (email) => {