diff --git a/apps/mobile/src/components/common/SwipeableItem.tsx b/apps/mobile/src/components/common/SwipeableItem.tsx new file mode 100644 index 0000000000..ac9963d253 --- /dev/null +++ b/apps/mobile/src/components/common/SwipeableItem.tsx @@ -0,0 +1,248 @@ +import { atom, useAtomValue, useSetAtom } from "jotai" +import { selectAtom } from "jotai/utils" +import * as React from "react" +import { Animated, StyleSheet, Text, View } from "react-native" +import { RectButton, Swipeable } from "react-native-gesture-handler" + +interface Action { + label: string + icon?: React.ReactNode + backgroundColor?: string + onPress?: () => void + color?: string +} + +interface SwipeableItemProps { + children: React.ReactNode + leftActions?: Action[] + rightActions?: Action[] + disabled?: boolean +} + +const styles = StyleSheet.create({ + absoluteFill: { + ...StyleSheet.absoluteFillObject, + }, + actionContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + animatedContainer: { + position: "absolute", + top: 0, + bottom: 0, + }, + actionsWrapper: { + flexDirection: "row", + }, + actionText: { + color: "#fff", + }, +}) + +export const SwipeableItem: React.FC = ({ + children, + leftActions, + rightActions, + disabled, +}) => { + const [leftHaptic, setLeftHaptic] = React.useState(false) + const [rightHaptic, setRightHaptic] = React.useState(false) + const itemRef = React.useRef(null) + + const renderLeftActions = (progress: Animated.AnimatedInterpolation) => { + const width = leftActions?.length ? leftActions.length * 74 : 74 + + return ( + <> + + + {leftActions?.map((action, index) => { + const trans = progress.interpolate({ + inputRange: [0, 1], + outputRange: [-74 * (leftHaptic ? (leftActions?.length ?? 1) : index + 1), 0], + }) + + if (index === 0) { + trans.addListener(({ value }) => { + if (value >= (leftActions?.length === 1 ? 40 : 20)) { + setLeftHaptic(true) + } else { + leftHaptic && setLeftHaptic(false) + } + }) + } + + return ( + + + {action.icon} + + {action.label} + + + + ) + })} + + + ) + } + + const renderRightActions = (progress: Animated.AnimatedInterpolation) => { + const width = rightActions?.length ? rightActions.length * 74 : 74 + + return ( + <> + + + {rightActions?.map((action, index) => { + const trans = progress.interpolate({ + inputRange: [0, 1], + outputRange: [74 * (rightHaptic ? (rightActions?.length ?? 1) : index + 1), 0], + }) + + if (index === 0) { + trans.addListener(({ value }) => { + if (value <= (rightActions?.length === 1 ? -40 : -20)) { + setRightHaptic(true) + } else { + rightHaptic && setRightHaptic(false) + } + }) + } + + return ( + + + {action.icon} + {action.label} + + + ) + })} + + + ) + } + + const id = React.useId() + const { swipeableOpenedId } = React.useContext(SwipeableGroupContext) + + const setAtom = useSetAtom(swipeableOpenedId) + const isOpened = useAtomValue( + React.useMemo( + () => selectAtom(swipeableOpenedId, (value) => value === id), + [id, swipeableOpenedId], + ), + ) + + React.useEffect(() => { + if (!isOpened) { + itemRef.current?.close() + } + }, [isOpened]) + + return ( + { + setAtom(id) + }} + leftThreshold={37} + rightThreshold={37} + enableTrackpadTwoFingerGesture + useNativeAnimations + onEnded={(e: any) => { + const { translationX } = e.nativeEvent + if ( + leftHaptic && + translationX >= (leftActions?.length === 1 ? 100 : 60) * (leftActions?.length ?? 1) + ) { + leftActions?.[0]?.onPress?.() + } + if ( + rightHaptic && + translationX <= (rightActions?.length === 1 ? -100 : -60) * (rightActions?.length ?? 1) + ) { + rightActions?.[0]?.onPress?.() + } + }} + renderLeftActions={leftActions?.length ? renderLeftActions : undefined} + renderRightActions={rightActions?.length ? renderRightActions : undefined} + overshootLeft={leftActions?.length ? leftActions?.length >= 1 : undefined} + overshootRight={rightActions?.length ? rightActions?.length >= 1 : undefined} + overshootFriction={3} + > + {children} + + ) +} + +const SwipeableGroupContext = React.createContext({ + swipeableOpenedId: atom(""), +}) + +export const SwipeableGroupProvider = ({ children }: { children: React.ReactNode }) => { + const ctx = React.useMemo( + () => ({ + swipeableOpenedId: atom(""), + }), + [], + ) + + return {children} +} diff --git a/apps/mobile/src/modules/settings/routes/Lists.tsx b/apps/mobile/src/modules/settings/routes/Lists.tsx index ec098bb3b1..19b44b48b5 100644 --- a/apps/mobile/src/modules/settings/routes/Lists.tsx +++ b/apps/mobile/src/modules/settings/routes/Lists.tsx @@ -26,6 +26,8 @@ import type { HonoApiClient } from "@/src/morph/types" import { listSyncServices } from "@/src/store/list/store" import { accentColor } from "@/src/theme/colors" +import { SwipeableGroupProvider, SwipeableItem } from "../../../components/common/SwipeableItem" + export const ListsScreen = () => { const { isLoading, data } = useQuery({ queryKey: ["owned", "lists"], @@ -54,22 +56,22 @@ export const ListsScreen = () => { /> - {data && ( - + + + )} - {isLoading && ( @@ -102,61 +104,73 @@ const keyExtractor = (item: HonoApiClient.List_List_Get) => item.id const ListItemCell: ListRenderItem = ({ item: list }) => { const { title, description } = list return ( - - - {list.image ? ( - - ) : ( - - )} - - - - {title} - - - {description} - - - {!!views[list.view]?.icon && - createElement(views[list.view]!.icon, { - color: views[list.view]!.activeColor, - height: 16, - width: 16, - })} - {views[list.view]?.name} + { + // TODO + }, + backgroundColor: "#0ea5e9", + }, + ]} + > + + + {list.image ? ( + + ) : ( + + )} - - - - - - - {list.fee} + + + {title} + + + {description} + + + {!!views[list.view]?.icon && + createElement(views[list.view]!.icon, { + color: views[list.view]!.activeColor, + height: 16, + width: 16, + })} + {views[list.view]?.name} + - - - {list.subscriptionCount || 0} - + + + + + {list.fee} + - {!!list.purchaseAmount && ( - - - {BigInt(list.purchaseAmount)} - + + {list.subscriptionCount || 0} - )} + + {!!list.purchaseAmount && ( + + + + {BigInt(list.purchaseAmount)} + + + )} + - + ) }