diff --git a/README.md b/README.md index 11a7a0d..04011d8 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ import '@sandstreamdev/react-swipeable-list/dist/styles.css'; content:
Revealed content during swipe
, action: () => console.info('swipe action triggered') }} + onSwipeProgress={progress => console.info(`Swipe progress: ${progress}%`)} >
Item name
@@ -132,6 +133,24 @@ Type: `number` (default: `0.5`) It can be set for the whole list or for every item. See `threshold` for `SwipeableList`. Value from the `SwipeableListItem` takes precedence. +### onSwipeStart + +Type: `() => void` + +Fired after swipe has started (after drag gesture passes the `swipeStartThreshold` distance in pixels). + +### onSwipeEnd + +Type: `() => void` + +Fired after swipe has ended. + +### onSwipeProgress + +Type: `(progress: number) => void` + +Fired every time swipe progress changes. The reported `progress` value is always an integer in range 0 to 100 inclusive. + ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): diff --git a/examples/src/App.js b/examples/src/App.js index baddf30..b240c0c 100644 --- a/examples/src/App.js +++ b/examples/src/App.js @@ -11,8 +11,10 @@ import { MailIcon, ReplyIcon, DeleteIcon } from '../images/icons'; import styles from './app.module.css'; function App() { - const [triggeredSimpleItemAction, triggerSimpleItemAction] = useState(''); + const [triggeredSimpleItemAction, triggerSimpleItemAction] = useState('None'); const [triggeredComplexItemAction, triggerComplexItemAction] = useState(''); + const [swipeProgress, handleSwipeProgress] = useState(); + const [swipeAction, handleSwipeAction] = useState('None'); const swipeRightDataSimple = name => ({ content: ( @@ -64,27 +66,55 @@ function App() { triggerComplexItemAction(`Reply action triggered on "${name}" item`) }); + const handleSwipeStart = () => { + triggerSimpleItemAction('None'); + handleSwipeAction('Swipe started'); + }; + + const handleSwipeEnd = () => { + handleSwipeAction('Swipe ended'); + handleSwipeProgress(); + }; + return (

react-swipeable-list example

(try also mobile view in dev tools for touch events)

Simple example (with default 0.5 action trigger threshold)

- {triggeredSimpleItemAction} + + Triggered action: {triggeredSimpleItemAction} + + + Callback swipe action: {swipeAction} + + + Callback swipe progress:{' '} + {swipeProgress !== undefined ? swipeProgress : '-'}% +
{itemContentSimple('Item with swipe right')} {itemContentSimple('Item with swipe left')} {itemContentSimple('Item with both swipes')} diff --git a/examples/src/app.module.css b/examples/src/app.module.css index 4d5a5cf..17bdcbc 100644 --- a/examples/src/app.module.css +++ b/examples/src/app.module.css @@ -3,7 +3,7 @@ html { } h3 { - margin-bottom: 0; + margin-bottom: 8px; text-align: center; } @@ -95,8 +95,8 @@ footer a:hover { } .actionInfo { - padding: 16px; - font-weight: 600; + padding: 0 8px 4px 8px; + font-weight: 400; font-size: 14px; } diff --git a/src/SwipeableListItem.js b/src/SwipeableListItem.js index 67c1a26..9efa472 100644 --- a/src/SwipeableListItem.js +++ b/src/SwipeableListItem.js @@ -25,10 +25,13 @@ class SwipeableListItem extends PureComponent { this.contentLeft = null; this.contentRight = null; this.listElement = null; + this.requestedAnimationFrame = null; this.wrapper = null; this.startTime = null; + this.previousSwipeDistancePercent = 0; + this.resetState(); } @@ -36,6 +39,7 @@ class SwipeableListItem extends PureComponent { this.dragStartPoint = { x: -1, y: -1 }; this.dragDirection = DragDirection.UNKNOWN; this.left = 0; + this.previousSwipeDistancePercent = 0; }; get dragHorizontalDirectionThreshold() { @@ -58,6 +62,12 @@ class SwipeableListItem extends PureComponent { } componentWillUnmount() { + if (this.requestedAnimationFrame) { + cancelAnimationFrame(this.requestedAnimationFrame); + + this.requestedAnimationFrame = null; + } + this.wrapper.removeEventListener('mousedown', this.handleDragStartMouse); this.wrapper.removeEventListener('touchstart', this.handleDragStartTouch); @@ -90,16 +100,16 @@ class SwipeableListItem extends PureComponent { this.dragStartPoint = { x: clientX, y: clientY }; this.listElement.className = styles.content; - if (this.contentLeft) { + if (this.contentLeft !== null) { this.contentLeft.className = styles.contentLeft; } - if (this.contentRight) { + if (this.contentRight !== null) { this.contentRight.className = styles.contentRight; } this.startTime = Date.now(); - requestAnimationFrame(this.updatePosition); + this.scheduleUpdatePosition(); }; handleMouseMove = event => { @@ -112,11 +122,8 @@ class SwipeableListItem extends PureComponent { event.stopPropagation(); event.preventDefault(); - const delta = clientX - this.dragStartPoint.x; - - if (this.shouldMoveItem(delta)) { - this.left = delta; - } + this.left = clientX - this.dragStartPoint.x; + this.scheduleUpdatePosition(); } } }; @@ -135,11 +142,8 @@ class SwipeableListItem extends PureComponent { event.stopPropagation(); event.preventDefault(); - const delta = clientX - this.dragStartPoint.x; - - if (this.shouldMoveItem(delta)) { - this.left = delta; - } + this.left = clientX - this.dragStartPoint.x; + this.scheduleUpdatePosition(); } } }; @@ -148,8 +152,10 @@ class SwipeableListItem extends PureComponent { window.removeEventListener('mouseup', this.handleDragEndMouse); window.removeEventListener('mousemove', this.handleMouseMove); - this.wrapper.removeEventListener('mouseup', this.handleDragEndMouse); - this.wrapper.removeEventListener('mousemove', this.handleMouseMove); + if (this.wrapper) { + this.wrapper.removeEventListener('mouseup', this.handleDragEndMouse); + this.wrapper.removeEventListener('mousemove', this.handleMouseMove); + } this.handleDragEnd(); }; @@ -169,39 +175,31 @@ class SwipeableListItem extends PureComponent { } else if (this.left > this.listElement.offsetWidth * threshold) { this.handleSwipedRight(); } + + if (this.props.onSwipeEnd) { + this.props.onSwipeEnd(); + } } this.resetState(); - this.listElement.className = styles.contentReturn; - this.listElement.style.transform = `translateX(${this.left}px)`; + + if (this.listElement) { + this.listElement.className = styles.contentReturn; + this.listElement.style.transform = `translateX(${this.left}px)`; + } // hide backgrounds - if (this.contentLeft) { + if (this.contentLeft !== null) { this.contentLeft.style.opacity = 0; this.contentLeft.className = styles.contentLeftReturn; } - if (this.contentRight) { + if (this.contentRight !== null) { this.contentRight.style.opacity = 0; this.contentRight.className = styles.contentRightReturn; } }; - shouldMoveItem = delta => { - const { - swipeLeft: { content: contentLeft } = {}, - swipeRight: { content: contentRight } = {}, - blockSwipe - } = this.props; - const swipingLeft = delta < 0; - const swipingRight = delta > 0; - - return ( - !blockSwipe && - ((swipingLeft && contentLeft) || (swipingRight && contentRight)) - ); - }; - dragStartedWithinItem = () => { const { x, y } = this.dragStartPoint; @@ -226,7 +224,10 @@ class SwipeableListItem extends PureComponent { switch (octant) { case 0: - if (horizontalDistance > this.dragHorizontalDirectionThreshold) { + if ( + this.contentRight !== null && + horizontalDistance > this.dragHorizontalDirectionThreshold + ) { this.dragDirection = DragDirection.RIGHT; } break; @@ -238,7 +239,10 @@ class SwipeableListItem extends PureComponent { } break; case 4: - if (horizontalDistance > this.dragHorizontalDirectionThreshold) { + if ( + this.contentLeft !== null && + horizontalDistance > this.dragHorizontalDirectionThreshold + ) { this.dragDirection = DragDirection.LEFT; } break; @@ -250,21 +254,35 @@ class SwipeableListItem extends PureComponent { } break; } + + if (this.props.onSwipeStart && this.isSwiping()) { + this.props.onSwipeStart(); + } } }; - isSwiping = () => - this.dragStartedWithinItem() && - (this.dragDirection === DragDirection.LEFT || - this.dragDirection === DragDirection.RIGHT); - - updatePosition = () => { + isSwiping = () => { const { blockSwipe } = this.props; + const horizontalDrag = + this.dragDirection === DragDirection.LEFT || + this.dragDirection === DragDirection.RIGHT; + + return !blockSwipe && this.dragStartedWithinItem() && horizontalDrag; + }; - if (this.dragStartedWithinItem() && !blockSwipe) { - requestAnimationFrame(this.updatePosition); + scheduleUpdatePosition = () => { + if (this.requestedAnimationFrame) { + return; } + this.requestedAnimationFrame = requestAnimationFrame(() => { + this.requestedAnimationFrame = null; + + this.updatePosition(); + }); + }; + + updatePosition = () => { const now = Date.now(); const elapsed = now - this.startTime; @@ -279,6 +297,26 @@ class SwipeableListItem extends PureComponent { const opacity = (Math.abs(this.left) / 100).toFixed(2); + if (this.props.onSwipeProgress && this.listElement !== null) { + const listElementWidth = this.listElement.offsetWidth; + let swipeDistancePercent = this.previousSwipeDistancePercent; + + if (listElementWidth !== 0) { + const swipeDistance = Math.max( + 0, + listElementWidth - Math.abs(this.left) + ); + + swipeDistancePercent = + 100 - Math.round((100 * swipeDistance) / listElementWidth); + } + + if (this.previousSwipeDistancePercent !== swipeDistancePercent) { + this.props.onSwipeProgress(swipeDistancePercent); + this.previousSwipeDistancePercent = swipeDistancePercent; + } + } + if (opacity < 1 && opacity.toString() !== contentToShow.style.opacity) { contentToShow.style.opacity = opacity.toString(); @@ -361,7 +399,11 @@ SwipeableListItem.propTypes = { swipeRight: SwipeActionPropType, scrollStartThreshold: PropTypes.number, swipeStartThreshold: PropTypes.number, - threshold: PropTypes.number + threshold: PropTypes.number, + + onSwipeEnd: PropTypes.func, + onSwipeProgress: PropTypes.func, + onSwipeStart: PropTypes.func }; export default SwipeableListItem; diff --git a/src/__tests__/SwipeableListItem.test.js b/src/__tests__/SwipeableListItem.test.js index 9d4d9d9..c4b23c0 100644 --- a/src/__tests__/SwipeableListItem.test.js +++ b/src/__tests__/SwipeableListItem.test.js @@ -242,3 +242,88 @@ test('swipe actions triggering if block swipe prop is set', () => { expect(callbackLeft).toHaveBeenCalledTimes(0); expect(callbackRight).toHaveBeenCalledTimes(0); }); + +test('start and end callbacks not triggered if swipe content not defined', () => { + const callbackSwipeStart = jest.fn(); + const callbackSwipeEnd = jest.fn(); + + const { getByTestId } = render( + + Item content + + ); + + const contentContainer = getByTestId('content'); + swipeLeftMouse(contentContainer); + swipeLeftTouch(contentContainer); + swipeRightMouse(contentContainer); + swipeRightTouch(contentContainer); + + expect(callbackSwipeStart).toHaveBeenCalledTimes(0); + expect(callbackSwipeEnd).toHaveBeenCalledTimes(0); +}); + +test('start and end callbacks not triggered if blockSwipe is set', () => { + const callbackSwipeStart = jest.fn(); + const callbackSwipeEnd = jest.fn(); + const callbackLeft = jest.fn(); + + const { getByTestId } = render( + Left swipe content, + action: callbackLeft + }} + onSwipeStart={callbackSwipeStart} + onSwipeEnd={callbackSwipeEnd} + > + Item content + + ); + + const contentContainer = getByTestId('content'); + swipeLeftMouse(contentContainer); + swipeLeftTouch(contentContainer); + swipeRightMouse(contentContainer); + swipeRightTouch(contentContainer); + + expect(callbackSwipeStart).toHaveBeenCalledTimes(0); + expect(callbackSwipeEnd).toHaveBeenCalledTimes(0); +}); + +test('start and end callbacks triggered if swipe content is defined', () => { + const callbackSwipeStart = jest.fn(); + const callbackSwipeEnd = jest.fn(); + const callbackLeft = jest.fn(); + const callbackRight = jest.fn(); + + const { getByTestId } = render( + Left swipe content, + action: callbackLeft + }} + swipeRight={{ + content: Right swipe content, + action: callbackRight + }} + onSwipeStart={callbackSwipeStart} + onSwipeEnd={callbackSwipeEnd} + > + Item content + + ); + + const contentContainer = getByTestId('content'); + swipeLeftMouse(contentContainer); + swipeLeftTouch(contentContainer); + swipeRightMouse(contentContainer); + swipeRightTouch(contentContainer); + + expect(callbackSwipeStart).toHaveBeenCalledTimes(4); + expect(callbackSwipeEnd).toHaveBeenCalledTimes(4); +}); diff --git a/src/module.d.ts b/src/module.d.ts index e0284c5..18b57c9 100644 --- a/src/module.d.ts +++ b/src/module.d.ts @@ -20,6 +20,9 @@ interface ISwipeableListItemProps { scrollStartThreshold?: number; swipeStartThreshold?: number; threshold?: number; + onSwipeStart?: () => void; + onSwipeEnd?: () => void; + onSwipeProgress?: (progress: number) => void; } export class SwipeableListItem extends React.Component<