Repository navigation
Swipe to delete/remove #299
Description
Activity
UX example video on that page https://www.npmjs.com/package/react-swipe-to-delete-ios
I would like to use a large, general purpose component/library like this one to achieve a similar effect.
Reacted by (Brian) Emil Hartz@strarsis Did you come up with a solution?
@isaachinman: I had to pause the implementation because of incompatibilities of an older react version used by the app.
But when for continuing I would take a look at the existingreact-swipe-to-delete-iosand try to reuse the calculations (delta) to achieve the same with thisreact-swipeablelibrary. The collapsing effect and subsequent removal would be very app-specific.I came up with an opinionated
SwipeActionscomponent that can be used as such:<SwipeActions.Container> <SwipeActions.Content> Your list row content </SwipeActions.Content> <SwipeActions.Action side='left' action={leftAction} > Left action content revealed on swipe </SwipeActions.Action> <SwipeActions.Action side='right' action={rightAction} destructive > Right action content revealed on swipe </SwipeActions.Action> </SwipeActions.Container>
It's rather opinionated/app-specific, as you say.
If it's useful for anyone, I can clean it up a bit and publish on npm. The only dependency is
react-swipeable.If it's useful for anyone, I can clean it up a bit and publish on npm.
That would be great! I want to use this in a react-admin app records list.
@strarsis Was thinking about this a bit more today – I don't really have the time or interest in maintaining an opinionated UI package at the moment. That said, I will share what I've written.
I think the styling of these kind of swipe actions will be different with every implementation. Some people will want two actions on each side, etc...
All that I think
react-swipeableshould do is expose the actual delta calculation logic, as it's really a pain to get right. #353 is also related to this – it made things a lot more difficult and required the use of even more refs.Bear in mind that I use PandaCSS for all styling and had to rip that stuff out, and replaced it with
<div>and inline styles.I wrote all of this delta calculation logic from scratch, and I wouldn't be surprised if it's flawed 😄
You can see the usage pattern above. This is a pretty weird way to write a React component, I will admit. Basically the props from the actual
Actioncomponents are extracted and used withinContainer.There will be a million ways to achieve the same thing, but hopefully this helps someone out a bit.
import React, { PropsWithChildren, useCallback, useRef, useState } from 'react' import { useSwipeable } from 'react-swipeable' type ActionProps = PropsWithChildren<{ action: () => Promise<void> destructive?: boolean side: 'left' | 'right' }> export const Action: React.FC<ActionProps> = ({ action: _action, children, destructive: _destructive, side: _side, }) => ( <div style={{ height: '100%', position: 'absolute', width: '100%', }} > {children} </div> ) export const Content: React.FC<PropsWithChildren> = ({ children }) => ( <div style={{ width: '100%', }} > {children} </div> ) export const Container: React.FC<PropsWithChildren> = ({ children }) => { const container = useRef<HTMLDivElement>(null) const leftActionContainer = useRef<HTMLDivElement>(null) const rightActionContainer = useRef<HTMLDivElement>(null) const xStart = useRef(0) const xDelta = useRef(0) const rem = 16 const animationDuration = 50 const [swipeInProgress, setSwipeInProgress] = useState(false) const [animateToSnap, setAnimateToSnap] = useState(true) const snapPoints = [ { point: 0, type: 'start' }, { point: 6 * rem, type: 'open' }, { point: 10 * rem, type: 'end' }, ] const setXTransform = () => { if (container.current) { container.current.style.transform = `translateX(${xDelta.current}px)` } } const childrenArray = React.Children.toArray(children) as React.ReactElement[] const actionChildren = childrenArray.filter( x => React.isValidElement(x) && x.type === Action, ) const contentChild = childrenArray.find( x => React.isValidElement(x) && x.type === Content, ) const leftActionContent = actionChildren.find(x => x.props.side === 'left') const rightActionContent = actionChildren.find(x => x.props.side === 'right') const leftAction = leftActionContent?.props.action const rightAction = rightActionContent?.props.action const callAction = useCallback((direction: 'left' | 'right') => { const action = direction === 'right' ? rightAction : leftAction const destructive = Boolean(direction === 'right' ? rightActionContent?.props.destructive : leftActionContent?.props.destructive) if (action) { if (destructive === true) { xDelta.current = direction === 'right' ? -window.innerWidth : window.innerWidth setXTransform() } setTimeout(async () => { await action() if (destructive === false) { xDelta.current = 0 setXTransform() } }, animationDuration) } }, [leftActionContent, rightActionContent]) const swipeHandler = useSwipeable({ delta: 0, onSwipeStart: () => { xStart.current = xDelta.current setAnimateToSnap(false) setSwipeInProgress(true) }, onSwiped: () => { const direction = xDelta.current < 0 ? 'right' : 'left' const absXDelta = Math.abs(xDelta.current) let [lastSnapPointPassed] = snapPoints for (const snapPoint of snapPoints) { if (absXDelta >= snapPoint.point) { lastSnapPointPassed = snapPoint } } setAnimateToSnap(true) if (lastSnapPointPassed.type === 'end') { callAction(direction) } else { xDelta.current = direction === 'right' ? -lastSnapPointPassed.point : lastSnapPointPassed.point setXTransform() } setSwipeInProgress(false) }, onSwiping: ({ deltaX, dir }) => { if (dir === 'Left' || dir === 'Right') { xDelta.current = Math.round(xStart.current + deltaX) if (!leftAction) { xDelta.current = Math.min(xDelta.current, 0) } if (!rightAction) { xDelta.current = Math.max(xDelta.current, 0) } setXTransform() if (rightActionContainer.current && leftActionContainer.current) { if (xDelta.current < 0) { rightActionContainer.current.style.display = 'flex' leftActionContainer.current.style.display = 'none' } else if (xDelta.current > 0) { leftActionContainer.current.style.display = 'flex' rightActionContainer.current.style.display = 'none' } } } }, }) return ( <div {...swipeHandler} style={{ maxWidth: '100%', touchAction: swipeInProgress ? 'pan-x' : 'pan-y', width: '100%', }} > <div ref={container} style={{ transitionDuration: animateToSnap ? `${animationDuration}ms` : undefined, transitionProperty: 'transform, background-color', transitionTimingFunction: 'ease-in-out', width: '100%', }} > {contentChild} </div> <div id='swipe-container' style={{ backgroundColor: 'grey', display: 'flex', flexDirection: 'row', height: '100%', left: '0', position: 'absolute', top: '0', width: '100%', zIndex: '-1', }} > {leftAction && ( <div ref={leftActionContainer} onClick={() => callAction('left')} style={{ display: 'none', height: '100%', marginRight: 'auto', width: 6 * rem, }} > {leftActionContent} </div> )} {rightAction && ( <div ref={rightActionContainer} onClick={() => callAction('right')} style={{ display: 'none', height: '100%', marginLeft: 'auto', width: 6 * rem, }} > {rightActionContent} </div> )} </div> </div> ) } export const SwipeActions = { Action, Container, Content, }
Reacted by strarsis
How can the swipe-to-delete gesture implemented with this? When the user swipes the item over a specific threshold, the delete action should be triggered and the item visually collapse to indicate that is was deleted.