FormidableLabs / react-swipeable

React swipe event handler hook
https://commerce.nearform.com/open-source/react-swipeable
MIT License
2k stars 147 forks source link

Swipe to delete/remove #299

Open strarsis opened 2 years ago

strarsis commented 2 years ago

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.

strarsis commented 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.

isaachinman commented 3 weeks ago

@strarsis Did you come up with a solution?

strarsis commented 3 days ago

@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.

isaachinman commented 3 days ago

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.

strarsis commented 2 days ago

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.

isaachinman commented 2 days ago

@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,
}