Building a pager component (Part 3)

Now that transitions are setup, let's take a look at handling panning and swipe gestures. We'll start with registering the gestures and translating our view based on how far the user has panned.

In order to do this, we'll bring in another package: react-use-gesture which, in combination with react-spring provides a pretty clean way for us to get this going.

yarn add react-use-gesture
import { useDrag } from 'react-use-gesture'

In the last post, we called useSpring to setup a translateX value with spring animations. We can use this same call to register an Animated.Value for dragging:

// dragX will represent the current drag value to animate
const [{ translateX, dragX }, set] = useSpring(() => ({
translateX: offset,
dragX: 0,
}))

Now we need to listen for drag events, and update this value whenever the user starts dragging. This is where useDrag() comes into play -- the function will return the listeners that we can attach to our container div:

// bind will be the listener fn we can attach to our animated.div container
const bind = useDrag(({ delta, last, vxvy }) => {

// this is the drag value we get through the magic of useDrag()
const [x] = delta

// we used set() function in our useEffect() call before, we'll use it again here to update
// some values
set({ dragX: x })
})

So, the bind() function will attach drag listeners to our animated div, and in that listener we are updating the dragX value. Then we'll also want to add that value to our translation in the interpolate() call, so that the animated.div translates with our drag events. Our transform will now look like this:

transform: interpolate(
[translateX, dragX],
(translateX, dragX) => `translateX(calc(${translateX}% + ${dragX}px))`,
)

...and to register the listeners, we call the bind() function as a prop to animated.div:

function Pager(...) {

// ...

// this is what we wrote above
const bind = useDrag(({ delta, last, vxvy }) => {

const [x] = delta
set({ dragX: x })
})

return (
<animated.div
{...bind()}
style=>
{React.Children.map(children, (element, index) => {
return (
<div
style=>
{element}
</div>
)
})}
</animated.div>
)
}

This is what our pager now looks like:

Dragging pager component

We now are properly panning with a user's drag. But you can see that there's a couple things here that still aren't quite right. The first issue is that when we release from dragging, nothing happens, it just stays where it was. We want to snap the translation to a specific location after a user releases instead.

This behaviour should snap back to the original offset if the drag was very small, or if it was a large drag,
snap left or right to the previous / next page depending on the direction of the drag. So let's update our drag listener with those things in mind:

const bind = useDrag(({ delta, last, vxvy }) => {
const [x] = delta

set({ dragX: x })

// last is an argument we get from useDrag() which is true when the user releases from dragging
if (last) {

const shouldTransition = Math.abs(x) >= 50 // some amount of change that you want to trigger a transition

if (!shouldTransition) {
// snap back to the original position
set({ dragX: 0 })
}

else {
// determine the next position based on the drag value (left or right)
let nextPosition

if (x > 100) {
// transition to previous page
nextPosition = offset + 100
}

if (x < -100) {
// transition to next page
nextPosition = offset - 100
}

// start spring transition to next position
// we want to spring the drag value back to 0 as we translate to the next position
set({
dragX: 0,
translateX: nextPosition,
})
}
}
})

Now when we drag just a little bit, it will snap back to the original position, but if we drag more than +/- 50px, it will trigger a transition:

Snap back and transition on drag

Almost there -- if you watch the gif you'll notice that our final transition isn't right. The activeIndex value isn't being updated, and the swipe transition goes from index 3 all the way to index 1. This is because we aren't correctly updating the activeIndex to keep in sync with our drag events, and so our translateX value isn't being updated. Let's fix that now.

In order to achieve this, we'll need to add an additional prop to our interface. This will be a callback function that can update the activeIndex prop for us, and thereby update our activeIndex value to be in sync with the drag events we've just enabled.

interface Props {
activeIndex: number
children: any
onChange: (index: number) => void
}

We just need to add the callback when we trigger a page transition:

const bind = useDrag(({ delta, last, vxvy }) => {
const [x] = delta

set({ dragX: x })

// last is an argument we get from useDrag() which is true when the user releases from dragging
if (last) {
// user has dragged beyond our threshold to transition (either left or right)
const shouldTransition = Math.abs(x) >= DRAG_THRESHOLD

if (!shouldTransition) {
// snap back to the original position
set({ dragX: 0 })
}

else {
// determine the next position based on the drag value (left or right)
let nextPosition

if (x > DRAG_THRESHOLD) {
// transition to previous page
nextPosition = offset + 100

onChange(activeIndex - 1)
}

if (x < -DRAG_THRESHOLD) {
// transition to next page
nextPosition = offset - 100

onChange(activeIndex + 1)
}

// start spring transition to next position
// we want to spring the drag value back to 0 as we translate to the next position
set({
dragX: 0,
translateX: nextPosition,
})
}
}
})

Keep an eye on the activeIndex now -- it should be in sync with the page transitions we're triggering:

Syncing transition with drag event

One last thing -- at the end of the above gif, you might notice there is a bit of jank when transitioning. I was able to do this by slowing the drag to a stop, then releasing. It's subtle in this small example, but what is happening is that the starting spring velocity is being set to 0 after we release, then ramping up to it's final value. Instead, we want it to be consistent and start at the correct velocity.

There's a simple fix for this -- we'll track the velocity of the drag event, and use it as our starting velocity when the user releases. This will get rid of that jank and have a more consistent animation:

const bind = useDrag(({ delta, last, vxvy }) => {
const [x] = delta
set({ dragX: x, immediate: true })

// the velocity of the drag -- important to track to prevent jank after user releases
const [vx] = vxvy

if (last) {
const shouldTransition = Math.abs(x) >= DRAG_THRESHOLD

if (!shouldTransition) {
set({ dragX: 0, immediate: false })
} else {
let nextPosition

if (x > DRAG_THRESHOLD) {
nextPosition = offset + 100
onChange(activeIndex - 1)
}

if (x < DRAG_THRESHOLD) {
nextPosition = offset - 100
onChange(activeIndex + 1)
}


// add velocity to our config and start it at the right spot:
set({
dragX: 0,
translateX: nextPosition,
immediate: false,
config: {
velocity: vx,
},
})
}
}
})

Our component now looks something like this:

Dragging pager component

Here is the sample code from above in full.

CodeSandbox

At this point our pager is fairly serviceable, but it can be improved. In the next post, we'll look into making things more configurable to increase the usability of the pager

Cheers!