Open strarsis opened 2 years ago
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.
@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 existing react-swipe-to-delete-ios
and try to reuse the calculations (delta) to achieve the same with this react-swipeable
library. The collapsing effect and subsequent removal would be very app-specific.
I came up with an opinionated SwipeActions
component 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-swipeable
should 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 Action
components are extracted and used within Container
.
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,
}
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.