infinity.js

(c) 2012 Airbnb, Inc.

infinity.js may be freely distributed under the terms of the BSD
license. For all licensing information, details, and documention:
http://airbnb.github.com/infinity
!function(window, Math, $) {
  'use strict';

Welcome To Infinity

infinity.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:

  1. All DOM elements must either be visible or in the current layout. infinity.js does not support elements that will at some point affect the layout, but are currently hidden using display:none.

  2. ListViews can't be nested.

  3. Non-ListItem elements can't be the immediate children of ListView elements. Only ListItems can be immediate children of ListViews.

  4. ListView elements can't have heights set directly on them. In most cases it is also likely that min-heights and max-heights will break. However, setting heights on ListItems is ok.

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

Constructor

Creates a new instance of a ListView.

Takes:

  • $el: a jQuery element.
  • options: an optional hash of options
  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);
  }

initBuffer

Private ListView method. Initializes the buffer element.

  function initBuffer(listView) {
    listView._$buffer = blankDiv()
                        .prependTo(listView.$el);
  }

updateBuffer

Private 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

append

Appends a jQuery element or a ListItem to the ListView.

Takes:

  • obj: a jQuery element, a string of valid HTML, or a ListItem.

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;
  };

cacheCoordsFor

Caches the coordinates for a given ListItem within the given ListView.

Takes:

  • listView: a ListView.
  • listItem: the ListItem whose coordinates you want to cache.
  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();
  }

insertPagesInView

Inserts any uninserted pages the given ListView owns.

Takes:

  • listView: the ListView whose onscreen pages you'd like to insert.
  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);
      }
    }
  }

updateStartIndex

Updates a given ListView when the throttled scroll event fires. Attempts to do as little work as possible: if the startIndex doesn't change, it'll exit early. If the startIndex does change, it finds all pages that have been scrolled out of view and removes them, then inserts only pages that have been now been scrolled into view.

Takes:

  • listView: the ListView needing to be updated.
  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;
  }

remove

Removes the ListView from the DOM and cleans up after it.

  ListView.prototype.remove = function() {
    this.$el.remove();
    this.cleanup();
  };

convertToItem

Given 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:

  • listView: the ListView instance that wants the item.
  • possibleItem: an object that is either a ListItem, a jQuery element, or a string of valid HTML.
  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;
  }

tooSmall

Alerts the given ListView that the given Page is too small. May result in modifications to the pages array.

  function tooSmall(listView, page) {

Naive solution: repartition(listView);

  }

repartition

Repartitions 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

find

Given 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:

  • findObj: A selector string, or a jQuery element.

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;
  };

startIndexWithinRange

Finds the starting index for a listView, given a range. Wraps indexWithinRange.

Takes:

  • listView: the ListView whose startIndex you're calculating.
  • top: the top of the range.
  • bottom: the bottom of the range.
  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;
  }

indexWithinRange

Finds 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:

  • listView: the ListView instance whose pages you're looking at.
  • top: the top of the range.
  • bottom: the bottom of the range.
  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 binding

Internal 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 = [];

scrollHandler

Callback called on scroll. Schedules a scrollAll callback if needed, and disallows future scheduling.

    function scrollHandler() {
      if(!scrollScheduled) {
        setTimeout(scrollAll, config.SCROLL_THROTTLE);
        scrollScheduled = true;
      }
    }

scrollAll

Callback passed to the setTimeout throttle. Calls scrollListView on every bound ListView, and then allows new scroll events to be scheduled.

    function scrollAll() {
      var index, length;
      for(index = 0, length = boundViews.length; index < length; index++) {
        updateStartIndex(boundViews[index]);
      }
      scrollScheduled = false;
    }

resizeHandler

Callback called on resize. Debounces a resizeAll callback.

    function resizeHandler() {
      if(resizeTimeout) clearTimeout(resizeTimeout);
      resizeTimeout = setTimeout(resizeAll, 200);
    }

resizeAll

Handles resizing all ListViews. Just calls repartition on them for now.

    function resizeAll() {
      var index, curr;
      for(index = 0; curr = boundViews[index]; index++) {
        repartition(curr);
      }
    }

    return {

attach

Binds a given ListView to a throttled scroll event. Does not create multiple event handlers if called by multiple ListViews.

Takes:

  • listView: a ListView that is not currently bound to the scroll event.
      attach: function(listView) {
        if(!eventIsBound) {
          $window.on('scroll', scrollHandler);
          $window.on('resize', resizeHandler);
          eventIsBound = true;
        }
        boundViews.push(listView);
      },

detach

Detaches 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:

  • listView: a ListView that is currently bound to the scroll event.
      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 class

An 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;
  }

append

Appends a ListItem to the Page.

Takes:

  • item: a ListItem.
  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;
  };

prepend

Prepends a ListItem to the Page.

Takes:

  • item: a ListItem.
  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;
  };

hasVacancy

Returns false if the Page is at max capacity; false otherwise.

  Page.prototype.hasVacancy = function() {
    return this.height < $window.height() * config.PAGE_TO_SCREEN_RATIO;
  };

appendTo

Proxies 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;
    }
  };

prependTo

Proxies 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;
    }
  };

stash

Temporarily stash the onscreen page under a different element.

  Page.prototype.stash = function($el) {
    if(this.onscreen) {
      this.$el.appendTo($el);
      this.onscreen = false;
    }
  };

remove

Removes 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();
  };

cleanup

Cleans 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();
    }
  };

lazyload

Runs the given lazy-loading callback on all unloaded page content.

Takes:

  • callback: a function of the form function([$el]){}. Will run on each unloaded element, and will use the element as its calling context.
  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;
      }
    };
  }());

removeItemFromPage

Removes 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 class

An 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;
  }

clone

Clones 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;
  };

remove

Removes 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();
  };

cleanup

Cleans up after the ListItem without removing it from the page.

  ListItem.prototype.cleanup = function() {
    this.parent = null;
  };

updateCoords

Updates the coordinates of the given ListItem, assuming a given y-offset from the parent ListView.

Takes:

  • listItem: the ListItem whose cached coordinates you want to update.
  • yOffset: the y-offset of the ListItem from its ListView parent.
  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

blankDiv

Returns a new, empty <div> jQuery element. The <div> will have its border, margin, and padding set to zero or none, as appropriate.

  function blankDiv() {
    return $('<div>').css({
      margin: 0,
      padding: 0,
      border: 'none'
    });
  }

pxToInt

Converts pixel values returned by jQuery to base-10 ints.

Takes:

  • px: a string value, which starts with a number and is postfixed with the string 'px'.

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);