Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Swipe to delete/remove #299

Open
strarsis opened this issue Mar 4, 2022 · 6 comments
Open

Swipe to delete/remove #299

strarsis opened this issue Mar 4, 2022 · 6 comments

Comments

@strarsis
Copy link

strarsis commented Mar 4, 2022

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
Copy link
Author

strarsis commented Mar 5, 2022

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
Copy link

@strarsis Did you come up with a solution?

@strarsis
Copy link
Author

@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
Copy link

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
Copy link
Author

strarsis commented Oct 30, 2024

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
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants