infinity.js | |
---|---|
| !function(window, Math, $) {
'use strict'; |
Welcome To Infinityinfinity.js is a UITableView for the web. Use it to speed up scroll performance of long- or infinitely-scrolling lists of items. infinity.js has several caveats:
If you're reading this, we probably want to hear from you. If the feeling is mutual: get in touch. | |
Initial Setup | |
Cached objects | var $window = $(window); |
Packaging: | var oldInfinity = window.infinity,
infinity = window.infinity = {},
config = infinity.config = {}; |
Constants: | var PAGE_ID_ATTRIBUTE = 'data-infinity-pageid',
NUM_BUFFER_PAGES = 1,
PAGES_ONSCREEN = NUM_BUFFER_PAGES * 2 + 1; |
Config: | config.PAGE_TO_SCREEN_RATIO = 3;
config.SCROLL_THROTTLE = 350; |
ListView Class | |
ConstructorCreates a new instance of a ListView. Takes:
| function ListView($el, options) {
options = options || {};
this.$el = blankDiv();
this.$shadow = blankDiv();
$el.append(this.$el); |
don't append the shadow element -- it's meant to only be used for finding elements outside of the DOM | this.lazy = !!options.lazy;
this.lazyFn = options.lazy || null;
initBuffer(this);
this.top = this.$el.offset().top;
this.width = 0;
this.height = 0;
this.pages = [];
this.startIndex = 0;
DOMEvent.attach(this);
} |
initBufferPrivate ListView method. Initializes the buffer element. | function initBuffer(listView) {
listView._$buffer = blankDiv()
.prependTo(listView.$el);
} |
updateBufferPrivate ListView method. Updates the buffer to correctly push forward the first page. | function updateBuffer(listView) {
var firstPage,
pages = listView.pages,
$buffer = listView._$buffer;
if(pages.length > 0) {
firstPage = pages[listView.startIndex];
$buffer.height(firstPage.top);
} else {
$buffer.height(0);
}
} |
ListView manipulation | |
appendAppends a jQuery element or a ListItem to the ListView. Takes:
TODO: optimized batch appends | ListView.prototype.append = function(obj) {
if(!obj || !obj.length) return null;
var lastPage,
item = convertToItem(this, obj),
pages = this.pages;
this.height += item.height;
this.$el.height(this.height);
lastPage = pages[pages.length - 1];
if(!lastPage || !lastPage.hasVacancy()) {
lastPage = new Page(this);
pages.push(lastPage);
}
lastPage.append(item);
insertPagesInView(this);
return item;
}; |
cacheCoordsForCaches the coordinates for a given ListItem within the given ListView. Takes:
| function cacheCoordsFor(listView, listItem) {
listItem.$el.remove(); |
WARNING: this will always break for prepends. Once support gets added for prepends, change this. | listView.$el.append(listItem.$el);
updateCoords(listItem, listView.height);
listItem.$el.remove();
} |
insertPagesInViewInserts any uninserted pages the given ListView owns. Takes:
| function insertPagesInView(listView) {
var index, length, curr,
pages = listView.pages,
inserted = false,
inOrder = true;
index = listView.startIndex;
length = Math.min(index + PAGES_ONSCREEN, pages.length);
for(index; index < length; index++) {
curr = pages[index];
if(listView.lazy) curr.lazyload(listView.lazyFn);
if(inserted && curr.onscreen) inOrder = false;
if(!inOrder) {
curr.stash(listView.$shadow);
curr.appendTo(listView.$el);
} else if(!curr.onscreen) {
inserted = true;
curr.appendTo(listView.$el);
}
}
} |
updateStartIndexUpdates a given ListView when the throttled scroll event fires. Attempts
to do as little work as possible: if the Takes:
| function updateStartIndex(listView) {
var index, length, pages, lastIndex, nextLastIndex,
startIndex = listView.startIndex,
viewTop = $window.scrollTop() - listView.top,
viewHeight = $window.height(),
viewBottom = viewTop + viewHeight,
nextIndex = startIndexWithinRange(listView, viewTop, viewBottom);
if( nextIndex < 0 || nextIndex === startIndex) return startIndex;
pages = listView.pages;
startIndex = listView.startIndex;
lastIndex = Math.min(startIndex + PAGES_ONSCREEN, pages.length);
nextLastIndex = Math.min(nextIndex + PAGES_ONSCREEN, pages.length); |
sweep any invalid old pages | for(index = startIndex, length = lastIndex; index < length; index++) {
if(index < nextIndex || index >= nextLastIndex)
pages[index].stash(listView.$shadow);
}
listView.startIndex = nextIndex;
insertPagesInView(listView);
updateBuffer(listView);
return nextIndex;
} |
removeRemoves the ListView from the DOM and cleans up after it. | ListView.prototype.remove = function() {
this.$el.remove();
this.cleanup();
}; |
convertToItemGiven an object that is either a ListItem instance, a jQuery element, or a string of valid HTML, makes sure to return either the ListItem itself or a new ListItem that wraps the element. Takes:
| function convertToItem(listView, possibleItem) {
var item;
if(possibleItem instanceof ListItem) return possibleItem;
if(typeof possibleItem === 'string') possibleItem = $(possibleItem);
item = new ListItem(possibleItem);
cacheCoordsFor(listView, item);
return item;
} |
tooSmallAlerts the given ListView that the given Page is too small. May result
in modifications to the | function tooSmall(listView, page) { |
Naive solution: repartition(listView); | } |
repartitionRepartitions the pages array. This can be used for either defragmenting the array, or recalculating everything on screen resize. | function repartition(listView) {
var currPage, newPage, index, length, itemIndex, pageLength, currItems, currItem,
nextItem,
pages = listView.pages,
newPages = [];
newPage = new Page(listView);
newPages.push(newPage);
for(index = 0, length = pages.length; index < length; index++) {
currPage = pages[index];
currItems = currPage.items;
for(itemIndex = 0, pageLength = currItems.length; itemIndex < pageLength; itemIndex++) {
currItem = currItems[itemIndex];
nextItem = currItem.clone();
if(newPage.hasVacancy()) {
newPage.append(nextItem);
} else {
newPage = new Page(listView);
newPages.push(newPage);
newPage.append(nextItem);
}
}
currPage.remove();
}
listView.pages = newPages;
insertPagesInView(listView);
} |
ListView querying | |
findGiven a selector string or jQuery element, return the items that hold the given or matching elements. Note: this is slower than an ordinary jQuery find. However, using jQuery to find elements will be bug-prone, since most of the elements won't be in the DOM tree. Caching elements is usually important, but it's even more important to do here. Arguments:
Returns a ListItem. | ListView.prototype.find = function(findObj) {
var items, $onscreen, $offscreen; |
If given a selector string, find everything matching onscreen and offscreen, and return both. | if(typeof findObj === 'string') {
$onscreen = this.$el.find(findObj);
$offscreen = this.$shadow.find(findObj);
return this.find($onscreen).concat(this.find($offscreen));
} |
Silly option, but might as well. | if(findObj instanceof ListItem) return [findObj]; |
jQuery element | items = [];
findObj.each(function() {
var pageId, page, pageItems, index, length, currItem,
$itemEl = $(this).parentsUntil('[' + PAGE_ID_ATTRIBUTE + ']').andSelf().first(),
$pageEl = $itemEl.parent();
pageId = $pageEl.attr(PAGE_ID_ATTRIBUTE);
page = PageRegistry.lookup(pageId);
if(page) {
pageItems = page.items;
for(index = 0, length = pageItems.length; index < length; index++) {
currItem = pageItems[index];
if(currItem.$el.is($itemEl)) {
items.push(currItem);
break;
}
}
}
});
return items;
}; |
startIndexWithinRangeFinds the starting index for a listView, given a range. Wraps indexWithinRange. Takes:
| function startIndexWithinRange(listView, top, bottom) {
var index = indexWithinRange(listView, top, bottom);
index = Math.max(index - NUM_BUFFER_PAGES, 0);
index = Math.min(index, listView.pages.length);
return index;
} |
indexWithinRangeFinds the index of the page closest to being within a given range. It's less useful than its wrapper function startIndexWithinRange, and you probably won't need to call this unwrapped version. Takes:
| function indexWithinRange(listView, top, bottom) {
var index, length, curr, startIndex, midpoint, diff, prevDiff,
pages = listView.pages,
rangeMidpoint = top + (bottom - top)/2; |
Start looking at the index of the page last contained by the screen -- not the first page in the onscreen pages | startIndex = Math.min(listView.startIndex + NUM_BUFFER_PAGES,
pages.length - 1);
if(pages.length <= 0) return -1;
curr = pages[startIndex];
midpoint = curr.top + curr.height/2;
prevDiff = rangeMidpoint - midpoint;
if(prevDiff < 0) { |
Search above | for(index = startIndex - 1; index >= 0; index--) {
curr = pages[index];
midpoint = curr.top + curr.height/2;
diff = rangeMidpoint - midpoint;
if(diff > 0) {
if(diff < -prevDiff) return index;
return index + 1;
}
prevDiff = diff;
}
return 0;
} else if (prevDiff > 0) { |
Search below | for(index = startIndex + 1, length = pages.length; index < length; index++) {
curr = pages[index];
midpoint = curr.top + curr.height/2;
diff = rangeMidpoint - midpoint;
if(diff < 0) {
if(-diff < prevDiff) return index;
return index - 1;
}
prevDiff = diff;
}
return pages.length - 1;
} |
Perfect hit! Return it. | return startIndex;
} |
ListView cleanup | ListView.prototype.cleanup = function() {
var pages = this.pages,
page;
DOMEvent.detach(this);
while(page = pages.pop()) {
page.cleanup();
}
}; |
ListView event bindingInternal scroll and resize binding and throttling. Allows ListViews to bind to a throttled scroll event (and debounced resize event), and updates them as it fires. | var DOMEvent = (function() {
var eventIsBound = false,
scrollScheduled = false,
resizeTimeout = null,
boundViews = []; |
scrollHandlerCallback called on scroll. Schedules a | function scrollHandler() {
if(!scrollScheduled) {
setTimeout(scrollAll, config.SCROLL_THROTTLE);
scrollScheduled = true;
}
} |
scrollAllCallback passed to the setTimeout throttle. Calls | function scrollAll() {
var index, length;
for(index = 0, length = boundViews.length; index < length; index++) {
updateStartIndex(boundViews[index]);
}
scrollScheduled = false;
} |
resizeHandlerCallback called on resize. Debounces a | function resizeHandler() {
if(resizeTimeout) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(resizeAll, 200);
} |
resizeAllHandles resizing all ListViews. Just calls | function resizeAll() {
var index, curr;
for(index = 0; curr = boundViews[index]; index++) {
repartition(curr);
}
}
return { |
attachBinds a given ListView to a throttled scroll event. Does not create multiple event handlers if called by multiple ListViews. Takes:
| attach: function(listView) {
if(!eventIsBound) {
$window.on('scroll', scrollHandler);
$window.on('resize', resizeHandler);
eventIsBound = true;
}
boundViews.push(listView);
}, |
detachDetaches a bound ListView from the throttled scroll event. If no ListViews remain bound to the throttled scroll, unbinds the scroll handler from the window's scroll event. Returns true if the listView was successfully detached, and false otherwise. Takes:
| detach: function(listView) {
var index, length;
for(index = 0, length = boundViews.length; index < length; index++) {
if(boundViews[index] === listView) {
boundViews.splice(index, 1);
if(boundViews.length === 0) {
$window.off('scroll', scrollHandler);
$window.off('resize', resizeHandler);
eventIsBound = false;
}
return true;
}
}
return false;
}
};
}()); |
Page classAn internal class used for ordering items into roughly screen-sized pages. Pages are removed and added to the DOM wholesale as they come in and out of view. | function Page(parent) {
this.parent = parent;
this.items = [];
this.$el = blankDiv();
this.id = PageRegistry.generatePageId(this);
this.$el.attr(PAGE_ID_ATTRIBUTE, this.id);
this.top = 0;
this.bottom = 0;
this.width = 0;
this.height = 0;
this.lazyloaded = false;
this.onscreen = false;
} |
appendAppends a ListItem to the Page. Takes:
| Page.prototype.append = function(item) {
var items = this.items; |
Recompute coords, sizing. | if(items.length === 0) this.top = item.top;
this.bottom = item.bottom;
this.width = this.width > item.width ? this.width : item.width;
this.height = this.bottom - this.top;
items.push(item);
item.parent = this;
this.$el.append(item.$el);
this.lazyloaded = false;
}; |
prependPrepends a ListItem to the Page. Takes:
| Page.prototype.prepend = function(item) {
var items = this.items; |
Recompute coords, sizing. | this.bottom += item.height;
this.width = this.width > item.width ? this.width : item.width;
this.height = this.bottom - this.top;
items.push(item);
item.parent = this;
this.$el.prepend(item.$el);
this.lazyloaded = false;
}; |
hasVacancyReturns false if the Page is at max capacity; false otherwise. | Page.prototype.hasVacancy = function() {
return this.height < $window.height() * config.PAGE_TO_SCREEN_RATIO;
}; |
appendToProxies to jQuery to append the Page to the given jQuery element. | Page.prototype.appendTo = function($el) {
if(!this.onscreen) {
this.$el.appendTo($el);
this.onscreen = true;
}
}; |
prependToProxies to jQuery to prepend the Page to the given jQuery element. | Page.prototype.prependTo = function($el) {
if(!this.onscreen) {
this.$el.prependTo($el);
this.onscreen = true;
}
}; |
stashTemporarily stash the onscreen page under a different element. | Page.prototype.stash = function($el) {
if(this.onscreen) {
this.$el.appendTo($el);
this.onscreen = false;
}
}; |
removeRemoves the Page from the DOM and cleans up after it. | Page.prototype.remove = function() {
if(this.onscreen) {
this.$el.remove();
this.onscreen = false;
}
this.cleanup();
}; |
cleanupCleans up the Page without removing it. | Page.prototype.cleanup = function() {
var items = this.items,
item;
this.parent = null;
PageRegistry.remove(this);
while (item = items.pop()) {
item.cleanup();
}
}; |
lazyloadRuns the given lazy-loading callback on all unloaded page content. Takes:
| Page.prototype.lazyload = function(callback) {
var $el = this.$el,
index, length;
if (!this.lazyloaded) {
for (index = 0, length = $el.length; index < length; index++) {
callback.call($el[index], $el[index]);
}
this.lazyloaded = true;
}
}; |
Page Registry | var PageRegistry = (function() {
var pages = [];
return {
generatePageId: function(page) {
return pages.push(page) - 1;
},
lookup: function(id) {
return pages[id] || null;
},
remove: function(page) {
var id = page.id;
if(!pages[id]) return false;
pages[id] = null;
return true;
}
};
}()); |
removeItemFromPageRemoves a given ListItem from the given Page. | function removeItemFromPage(item, page) {
var index, length, foundIndex,
items = page.items;
for(index = 0, length = items.length; index < length; index++) {
if(items[index] === item) {
foundIndex = index;
break;
}
}
if(foundIndex == null) return false;
items.splice(foundIndex, 1);
page.bottom -= item.height;
page.height = page.bottom - page.top;
if(!page.hasVacancy()) tooSmall(page.parent, page);
return true;
} |
ListItem classAn individual item in the ListView. Has cached top, bottom, width, and height properties, determined from jQuery. This positioning data will be determined when the ListItem is inserted into a ListView; it can't be determined ahead of time. All positioning data is relative to the containing ListView. | function ListItem($el) {
this.$el = $el;
this.parent = null;
this.top = 0;
this.bottom = 0;
this.width = 0;
this.height = 0;
} |
cloneClones the ListItem. | ListItem.prototype.clone = function() {
var item = new ListItem(this.$el);
item.top = this.top;
item.bottom = this.bottom;
item.width = this.width;
item.height = this.height;
return item;
}; |
removeRemoves the ListItem and its elements from the page, and cleans up after them. | ListItem.prototype.remove = function() {
this.$el.remove();
removeItemFromPage(this, this.parent);
this.cleanup();
}; |
cleanupCleans up after the ListItem without removing it from the page. | ListItem.prototype.cleanup = function() {
this.parent = null;
}; |
updateCoordsUpdates the coordinates of the given ListItem, assuming a given y-offset from the parent ListView. Takes:
| function updateCoords(listItem, yOffset) {
var $el = listItem.$el;
listItem.top = yOffset;
listItem.height = $el.outerHeight(true);
listItem.bottom = listItem.top + listItem.height;
listItem.width = $el.width();
} |
Helper functions | |
blankDivReturns a new, empty | function blankDiv() {
return $('<div>').css({
margin: 0,
padding: 0,
border: 'none'
});
} |
pxToIntConverts pixel values returned by jQuery to base-10 ints. Takes:
| |
function pxToInt(px) { return parseInt(px, 10); } | |
Export | |
Classes: | infinity.ListView = ListView;
infinity.Page = Page;
infinity.ListItem = ListItem; |
jQuery plugin | function registerPlugin(infinity) {
var ListView;
if(infinity) {
ListView = infinity.ListView;
$.fn.listView = function (options) {
return new ListView(this, options);
};
}
else {
delete $.fn.listView;
}
}
registerPlugin(infinity); |
Destroy own packaging: | infinity.noConflict = function() {
window.infinity = oldInfinity;
registerPlugin(oldInfinity);
return infinity;
};
}(window, Math, jQuery);
|