UITableView in JavaScript, list view with re-usable cells using flexbox
If you’re familiar with iOS development you will know that a UITableView is very efficient when displaying a list of data. A simplification of what it does is display enough cells to fill the viewport plus a few more either side. As you scroll it re-uses cells that are now out of the viewport so a list with thousands of items will only ever use a fixed amount of cells. Highly recommend reading The fine art of UITableViews. Now this has certainly been done before in JavaScript, the best known project being infinity.js, but my approach takes an interesting turn, I avoid heavy DOM operations by using flexbox.
How does flexbox help?
If you’re familiar with flexbox you may have come across the order
property which allows you to reorder flex items in a flexbox container and this is the magic that allows us to create reusable list items without having to actually rip the element out of the DOM and then re-inject it in the right place as a user scrolls.
Check out the project on github and have a look at the live demo
How does it work
The whole technique boils down to the flexbox order
property which allows reordering of a flex item within a flexbox container, essentially I can say to the browser repaint the DOM node here instead of where it is in the actual DOM structure.
In the above image the top four cells are actually rendered beneath the last four as we’ve informed the browser to repaint them below using the order
property.
On scroll I check the scroll position. When the user has scrolled more than the threshold, (3 cells are out of the viewport) I then set the order property on the element that can now be painted at the bottom of the list. To then render the correct data the order number and the array index is passed off to renderCellFn()
.
First we check the user is actually scrolling and in which direction.
onScroll: function onScroll() {
scrollTop = body.scrollTop;
this.direction = scrollTop - lastScrollTop;
this.checkCells();
}
The scrolling direction is determined by comparing the last scroll top position to the current. The onScroll
method is what the scroll
event calls directly.
getCurrentCell: function getCurrentCell(count) {
return this.cells[count % this.cells.length];
}
In checkCells()
the getCurrentCell()
method is called to determine the current cell by using the modulus (%
) operator. Previously I had some over complicated maths to determine which of the re-useable cells I could use but all I really needed was modulus. What it does is divide the current count of “cells” that are out of view by the number of re-useable cells and returns the remainder which is the cell I can then reorder and render the data in.
Once we have the correct cell to use we then determine if it’s far enough out of the viewport to be able to use by calling isTopElementOutOfViewport()
.
isTopElementOutOfViewport: function(el) {
var elemPostion = el.getBoundingClientRect(),
bottomOffset = elemPostion.bottom - (this.container.offsetTop - body.scrollTop);
return !!elemPostion && bottomOffset <= -(this.cellReorderThreshold());
}
To determine if it’s out of viewport we use the getBoundingClientRect()
which will return a bunch of metrics about the cell we’re looking to use. This sort of operation is normally expensive and will cause a layout to trigger but since our actual DOM is so small it’s insignificant for the browser to do.
this.cellsOutOfViewportCount++;
this.cellIndex = this.cellsOutOfViewportCount;
this.elementStyle.paddingTop = parseInt(this.elementStyle.paddingTop || , 10) + this.CELLHEIGHT + 'px';
this.currentCell.style[orderProp] = this.cellIndex;
this.renderCell(this.currentCell, this.cellIndex-1);
If the top cell is far enough out of view then we can trigger the reorder, render the new data and increase the lists top padding to maintain the correct scroll position.
Why not use requestAnimationFrame?
You may be asking why I don’t use requestAnimationFrame
when calling checkCells()
. In Firefox the performance degraded massively and I’m not entirely sure why. So I’ve avoided it for the time being.
Performance and visualising the technique
In the screencast I show performance benefits of this technique when working with asynchronously loading data and comparing it with a regular technique for loading async data. I also cover and visualise how the cells are being reordered using the awesome chrome://tracing tool.
TL;DR Gif
Needs battle testing
The code base is fairly beta and quite usable but there is still some work to do, as I’ll list below:
Some of the issue and limitations are:
- It only works with fixed height cells.
- It has to be a flexbox supporting browser which is pretty good but no <IE10
- iOS won’t trigger scroll events until after the page has stopped scrolling, this will require the use of a scrolling library.
- Accessibility?
- Only reacts to scroll events, keyboard users won’t see list updated.
- Doesn’t render the cells if you refresh and the browser re-instates the scroll position.
We need this built into the Web Platform
If we want web apps to be performant then this elaborate hack should be built in to the Web Platform allowing the browser to handle the logic behind re-using cells without killing performance. We could introduce a property on ol/ul
or even have a new element that treats any children as a re-usable cell.
There are some early editor drafts of specs that may help with this, the two I know of are css-containment and the will-change property.
Some of the MV* frameworks also have components for working with large data sets but most have the restriction of fixed height cells due to heavy recalculation issues when using variable height cells. Ember has list-view, Angular has ng-scroller. Both of these libraries remove and re-inject DOM nodes.
Paul Irish has compiled a good list of libraries and list of Do’s and Dont’s to reduce jank when scrolling