Skip to content

Swipe to delete/remove #299

Description

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

Activity

  1. strarsis commented on Mar 5, 2022

    @strarsis
    Author

    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.

  2. isaachinman commented on Oct 7, 2024

    @isaachinman

    @strarsis Did you come up with a solution?

  3. strarsis commented on Oct 30, 2024

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

  4. isaachinman commented on Oct 30, 2024

    @isaachinman

    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.

  5. strarsis commented on Oct 30, 2024

    @strarsis
    Author

    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.

  6. isaachinman commented on Oct 31, 2024

    @isaachinman

    @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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions