Building a pager component (Part 5)

In the last part, we improved our pager component quite a bit in terms of performance and usability. At this point we have a fairly serviceable component, so we'll be finishing up by recreating a simplified version of our original goal:

Apple Podcasts Pager Demo

Here's the CodeSandbox as well: CodeSandbox

Just glancing at this screenshot, it's clear that we'll still need to add some features. Our current implementation is focused on full screen transitions of individual children, but we should also support partial screen transitions. For example, in the second row we have a 2x2 grid that can page either 50% or 100% of the total width. To make this possible, let's start by redefining our page offsets -- as it stands now, we set our offset to be 100% of the width of our component:

  const offset = activeIndex * 100 * -1

Instead, let's remove that 100% value and make it configurable via a prop:

function Pager({
...
pageSize = 1,
}
) {
const offset = activeIndex * -1 * 100 * pageSize
}

Now our translateX value will be determined by a percentage of the total width of our pager. Next, we'll want to wrap our component in a separate div and bind the drag listeners to it. This is so that our drag listener will always be centered in our pager, instead of potentially translating off of the screen:

 return (
<animated.div
{...bind()}
style=
>
{...rest of pager component}
</animated.div>
)

This configuration makes our pager a lot more flexible because we can control how far a pager can translate:

// 50% translation per page:
<Pager pageSize={0.5} >
<ThumbnailContainer>
{thumbnails.map(t => (
<Thumbnail {...t} size={100} basis={50} />
))}
</ThumbnailContainer>
</Pager>

Another thing that we might want to add is the min and max indices that the pager can transition to. As it stands, a user can transition past our child views / content, and it's likely we want to keep them within a specified range. In order to do so, we'll add minIndex and maxIndex props that will give us the ability to clamp these values:

function Pager({ ...,   minIndex = 0, maxIndex: parentMax = -1, }) {

...

// define the maxIndex if its not supplied via props:
const maxIndex = parentMax === -1 ? React.Children.count(children) - 1 : parentMax;

// update the useDrag event handler we defined before to use these props:

... useDrag() {

// determine the next position based on the drag value (left or right)
let nextOffset = offset;

if (x > dragThreshold) {

// clamp change to minimum index value
const clampedMin = Math.max(minIndex, activeIndex - 1);

// offset will be the opposite value of the next index
nextOffset = -clampedMin;

// update our controller component w/ the previous index
onChange(clampedMin);
}

if (x < dragThreshold) {
// clamp change to maximum index value
const clampedMax = Math.min(maxIndex, activeIndex + 1);

// offset will be the opposite value of the next index
nextOffset = -clampedMax;

// update our controller component w/ the next index
onChange(clampedMax);
}
...
}
}
}

We can now scroll beyond the edge of our last or first child, and it will snap back to the minimum or maximum index!

Finally, let's add the ability to transition by more than one page on a single drag -- if you scroll far enough we want to snap to two or three indexes away from the current activeIndex depending on the drag distance. We can achieve this by measuring the total drag when the user releases and compare it to some configrable props so that we can compute how many indexes to change:

// add threshold prop that we'll compare to our drag values to determine if we should
// snap to the next index:
function Pager({ ..., threshold = 0.3, })

const bind = useDrag(({ delta, last, vxvy, currentTarget }) => {
...

// last is true when the user releases from dragging
if (last) {
const absChange = Math.abs(x);

const target: any = currentTarget as any;

// the total width of our pager container div
const containerWidth =
target && target.clientWidth ? target.clientWidth : 0;

// the total value that should be surpassed in order to update an index:
const dragThreshold = containerWidth * threshold * pageSize;

// the absolute change in index
const indexChange = Math.round(absChange / (containerWidth * pageSize));

const shouldTransition = absChange >= dragThreshold;

if (!shouldTransition) {
// restore to initial position when user started dragging:
set({ dragX: 0, immediate: false });
} else {
// determine the next position based on the drag value (left or right)
let nextOffset = offset;

if (x > dragThreshold) {
// clamp change to minimum index value
// we'll move back by the computed indexChange, or to the minIndex if its too small
const clampedMin = Math.max(minIndex, activeIndex - indexChange);

// offset will be the opposite value of the clamped value
nextOffset = -clampedMin;

// update our controller component w/ the previous index
onChange(clampedMin);
}

if (x < dragThreshold) {
// clamp change to maximum index value
// we'll move forward by the computed indexChange, or to the maxIndex if its too big
const clampedMax = Math.min(maxIndex, activeIndex + indexChange);

// offset will be the opposite value of the clamped value
nextOffset = -clampedMax;

// update our controller component w/ the clamped value
onChange(clampedMax);
}

// 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: nextOffset * 100 * pageSize,
immediate: false,
config: {
velocity: vx,
},
});
}
}
});
}

Now we can configure this pager with a lot of different functionality. We can control how far a page transitions, the minimum and maximum indices to page to, how far the user should drag until we snap to a new index, and calculating multiple index changes in one drag. Here's a gist

At this point, our pager is just about complete. The source code can be found here and the package can be installed via yarn add @crowdlinker/react-pager.

Cheers!