Binary Search Scrolling Gallery
I recently got embarassed enough by the old v1 Angular scripts on this site that I decided to re-write some of that logic. As part of that, I decided to update the logic behind my family photo gallery. Previously, I was lazy and just left it as a horizontal scrolling div. That works fine on mobile, but leaves users without a touchpad (or touchscreen) no easy way to interact with the gallery!
So, as any sofware engineer with some headspace to think about a problem will do, I decided to write some overly complicated code to solve the problem. 😅
I’m going to include some code snippets here to walk through my thinking, but I’ve also broken off that logic into a standalone demo that you can find on github here.
I’m going to copy/paste from the readme here, so if you’ve already looked at the demo repo you can skip to the next major subheading.
Problem Statement
Horizontal scrolling content is common on mobile. “Sliders” are similarly a common web pattern for desktop designs. Modern tablets (iPads et all) often render a desktop layout but limit interaction with the page to the desktop paradigm. I’m looking for a way to render one component that works for both desktop and mobile, supporting touchscreen scrolling along with buttons that you can use to navigate an arbitrary list of elements.
Why is this still a problem?
Most paginated sliders need to know the exact width of all child content so that pages can be pre-measured. That usually means uniform sizes, and disabling native scrolling so that the slider can’t get into an “unknown” state.
How does this solution solve for that problem?
By registering the width and offset of all child elements, we can do “just in time” evaluation to determine what’s actually on the screen now, and then jump to the next offscreen elements. Doing this effectively means making that evaluation fast, so [I’ve] implimented a binary search to find elements that fit within the boundary of the current viewport.
Bro, that sounds complicated
Yeah it is! I’ve never been happy with prior solutions and I had some time to think this problem through so I decided to go for it. I’m not sure that this could be repackaged into a usable third-party module, but maybe looking at the implimentation details put forth here will give you some ideas to do this kind of thing for yourself. 😅
How it works
Some of the key setup means registering the viewport size and position on initialization, as well as the size and position of each child element. Because this is an image gallery, we can just go ahead and register those child sizes when images load.
Here are some snippits for how I’ve done that on this website using MithrilJS. (You can find a react example in the github demo library.)
const Gallery = {
//...
oncreate: function(vnode) {
let dom = vnode.dom;
setWidth(dom.offsetWidth);
setPosition(dom.scrollLeft);
// get the json data to actually load all the images
loadItems();
// check the load position after a few ms because some browsers like to reset it
setTimeout(() => {
if (getState().loadPosition === null) {
setLoadPosition(dom.scrollLeft);
}
}, 100);
},
onimgload: function(e, i) {
if (!e && !e.target) {
return;
}
setItem(e.target.offsetWidth, e.target.x, i);
},
//...
}
An important part of that setItem
function on the state handler is that it accounts for the loadPosition
.
I should note, as we step into some of this state logic, that I’ve used zustand (as a vanilla store), and immer to simplify state changes. With Mithril this wasn’t strictly necessary and I could’ve used used a POJO to track state values. The truth is that I did have it written that way before, but re-wrote it for the React demo and it was easier to copy back to my personal site if I left it all mostly the same. 😅
export const useGalleryStore = createStore<GalleryStore>((set, get) => ({
//...
setItem: (width: number, x: number, index: number) => set(
state => {
const { widths, offsets } = state;
widths[index] = width;
offsets[index] = x + (state.loadPosition || 0);
return ({
offsets,
widths,
});
}
),
//...
}));
This means that we don’t have to worry about negative offset values, and can just treat the starting offset as 0. setLoadPosition
will adjust any values that have already been registered like so:
export const useGalleryStore = createStore<GalleryStore>((set, get) => ({
//...
setLoadPosition: (position: number) => set(produce(
state => {
state.loadPosition = position;
state.offsets.forEach((offset, i) => {
if (isNaN(offset)) {
return;
}
state.offsets[i] = offset + position;
});
}
)),
//...
}));
With this setup done, we can finally get to the interesting part. I used a binary search to step through all of the registered items in the gallery and (hopefully quickly) find something in the viewport. We then step left and right to find all of the adjacent items that are also visible. This will allow us to return a list of visible items by index.
export function getVisibleWindow(state: GalleryState): Boundary {
return [state.position, state.position + state.width, state.width];
};
export function isFullyVisible(limits: Boundary, bounds: Boundary) {
return limits[0] < bounds[0] && limits[1] > bounds[1];
}
export function isPartiallyVisibleLeft(limits: Boundary, bounds: Boundary) {
return Math.max(0, limits[0] - bounds[2]) < bounds[0] && (limits[1] > bounds[1] || limits[2] < bounds[2]);
}
export function isPartiallyVisibleRight(limits: Boundary, bounds: Boundary) {
var maxWidth = limits[0] + limits[1];
return Math.min(limits[1] + bounds[2], maxWidth) > bounds[1] && (limits[0] < bounds[0] || limits[2] < bounds[2]);
}
export function findVisibleItems(state: GalleryState) {
let offsets = state.offsets;
let widths = state.widths;
let limits = getVisibleWindow(state);
let visible = [];
let start = 0;
let end = offsets.length - 1;
let maxWidth = limits[0] + limits[1];
while (start <= end) {
let middle = Math.floor((start + end) / 2);
let bounds: Boundary = [offsets[middle], offsets[middle] + widths[middle], widths[middle]];
let isFull = isFullyVisible(limits, bounds);
let isPartialLeft = !isFull && isPartiallyVisibleLeft(limits, bounds);
let isPartialRight = !isFull && isPartiallyVisibleRight(limits, bounds);
if (isFull || isPartialLeft || isPartialRight) {
// found a visible position!
log('✅ Match found: ', middle, isFull, isPartialLeft, isPartialRight);
// now we just need to walk to find the start, starting left
if (isFull || limits[2] <= bounds[2]) {
log('📌 pushing initial match: ', middle);
visible.push(middle);
}
// reset atart/stop so that we don't exit too early
start = 0;
end = offsets.length;
let step = middle;
while (step > start && !isPartialLeft) {
step = step - 1;
log('🚶 left stepping: ', step);
// check how close to the left edge it is
let positionDiff = Math.max(0, offsets[step] - limits[0]);
if (positionDiff < offsets[step] + widths[step]) {
// less distance than the given width
// just check to see if the previous item is smaller too
visible.push(step);
if (positionDiff < widths[step - 1]) {
// starting position is one item before the established visible item
start = step - 1;
visible.push(start);
break;
}
}
}
step = middle;
// now walk right to make sure we have both sides
while (step < end && !isPartialRight) {
step = step + 1;
log('🚶 right stepping: ', step);
if (limits[1] > offsets[step] + widths[step]) {
visible.push(step);
} else {
end = step;
break;
}
}
break;
} else if (limits[1] <= bounds[1]) {
log('🔎 searching left: ', start, middle, end, limits[1], bounds);
// offscreen right, search left
end = middle - 1;
} else {
log('🔎 searching right: ', start, middle, end, limits[1], bounds);
// searched too far right, go back and search left
if (start === end) {
// we probably skipped over a match, let's extend the search a bit
end = Math.max(offsets.length - 1, end + 2);
start = middle - 1;
continue;
}
start = middle + 1;
}
}
return visible.sort((a, b) => {
return a - b;
});
};
Doing it this way means we can:
- Use one function to figure out of the next/previous buttons should be visible
- Use that same function to find the next offscreen index and scroll to it
- Not worry if the user scrolls themselves via touch or dragging the scrollbar 🤷
- Look super l33t because we’re working with an “advanced” algorithm.
Let’s see it
Funnily enough, including the gallery inside the post here exposed some styling issues. It looks better spanning the full screen, but this works too with some adjustments!
I decided to just do a quick-n-dirty style variant:
.gallery-wrapper {
position: relative;
// new styles here for this post
&--nested {
overflow: hidden;
& > .gallery-strip > .wrapper {
padding-left: 0;
}
.gallery-strip__item {
scroll-margin-left: 0;
&:first-child {
margin-left: 0;
}
}
}
}
That’s a wrap
This was a fun little project to work on. If you have suggestions then shoot me an email or comment on the github project.
Thanks!