Skip to content

Instantly share code, notes, and snippets.

@willbailey
Created May 19, 2011 18:04
Show Gist options
  • Save willbailey/981352 to your computer and use it in GitHub Desktop.
Save willbailey/981352 to your computer and use it in GitHub Desktop.
BoltJS TableView
// ### TableView
// TableView provides an efficient mechanism for progressively
// rendering from a data source provided by the owner object.
// Cells are queued for reuse when they go offscreen and then
// translated back into position with updated content as they
// are reused.
var TableView = exports.TableView = core.createClass({
name: 'TableView',
extend: View,
mixins: [HasEventListeners],
declare: function(options) {
return {
eventListeners: {
'touchstart,touchend,touchcancel,touchmove .bt-table-view-cell': 'onCellTouch'
},
debug: false,
style: {height: '100%'},
flex: 1,
boxOrientation: 'vertical',
bufferSize: 0,
loading: false,
sectioned: true,
childViews: [{
view: 'ScrollView',
owner: this,
ref: 'scrollView',
flex: 1
}]
};
},
// after the setup method is invoked we cache a few references for
// later use.
ready: function() {
this.owner = this.getOwner();
this.scrollView = this.findRef('scrollView');
this.bufferSize = this.getBufferSize();
this.y = 0;
this.lastY = 0;
this.lastX = 0;
if (this.getFixedSectionHeaderHeight) {
this.fixedSectionHeaderHeight = this.getFixedSectionHeaderHeight();
}
if (this.getFixedRowHeight) {
this.fixedRowHeight = this.getFixedRowHeight();
}
},
destroy: function() {
this.superKlass.destroy.call(this);
},
// clear the currently selected cell
clearSelection: function() {
this.selectedCell && this.selectedCell.setSelected(false);
},
onDocumentInsertion: function() {
this.refresh();
},
// Fired when the user taps on a cell
onCellTouch: function(e, elem) {
var idx = elem.getAttribute('data-item-index');
this.log(e.type);
var item = this.itemList[idx];
var view = item && item.view;
var owner = this.getOwner();
if (!view) return;
switch(e.type) {
case 'touchstart':
this._cellSelectTimer = setTimeout(util.bind(function() {
this.clearSelection();
view.setSelected(true);
this.selectedCell = view;
}, this), 50);
break;
case 'touchend':
if (view.getSelected()) {
owner.cellSelectedAtRowInSection(this, item.row, item.section, view);
}
break;
case 'touchmove':
clearTimeout(this._cellSelectTimer);
this.clearSelection();
break;
case 'touchcancel':
clearTimeout(this._cellSelectTimer);
this.clearSelection();
break;
default:
break;
}
},
// Call refresh whenever you want to do a full refresh of the table.
// The table's size will be recalculated and it will be freshly buffered
// to the current scroll coordinates.
refresh: function() {
delete this.viewPort;
delete this.scrollHeight;
delete this.sectionCount;
delete this.rowCounts;
delete this.itemList;
delete this.lastHeadPtr;
delete this.lastTailPtr;
this.clear();
this.buffer();
},
// Delegate callback from the scrollview indicating that it scrolled to the
// current x, y coordinates.
scrollViewDidScrollTo: function(scrollView, x, y) {
this.x = -x;
this.y = -y;
if (this.y !== this.lastY) {
this.buffer();
this.lastY = y;
this.lastX = x;
}
},
// clear all childviews;
clear: function() {
this.clearSelection && this.clearSelection();
util.forEach(this.scrollView.getChildViews() || [], function(view) {
view.destroy();
});
},
// buffer the data by rendering the delta between the old and new scroll
// positions and cleaninup up anything unused.
buffer: function() {
this.calclulateBuffer(this.y);
if (this.lastHeadPtr !== this.headPtr || this.lastTailPtr !== this.tailPtr) {
this.renderBuffer(
this.headPtr,
this.tailPtr,
this.lastHeadPtr,
this.lastTailPtr);
}
},
// Determine the desired buffer for the given y coordinate. This buffer will
// be used to determine which unused cells can be cleaned up and which cells
// to use when populating the empty region after the scroll.
calclulateBuffer: function(y) {
if (!this.viewPort) this.calculateViewPort();
if (!this.scrollHeight) this.calculateScrollHeight();
this.viewPortHeadPtr = this.findItemIndex(Math.max(y, 0));
this.viewPortTailPtr = this.findItemIndex(
Math.min((y + this.viewPort.height), this.scrollHeight));
// The head and tail pointers provide an extra buffer around the view port
// allowing for some latency between rendering and scrolling. Increasing
// the buffer can be configured from the owner class and may help reduce
// flickering in some cases.
this.headPtr = Math.max(this.viewPortHeadPtr - this.bufferSize, 0);
this.tailPtr = Math.min(
this.viewPortTailPtr + this.bufferSize,
this.itemList.length - 1);
},
// Render the current buffer window by calculating the direction of
// movement, rendering items to fill the revealed portion of the buffer
// and reaping items from the portion of the prior buffer not included
// in the current buffer.
//
// For Example in a downward scroll:
//
// -------------
// <- last head ptr
// BUFFER <- to be reaped
// <- current head ptr
// -------------
//
// VIEW PORT
//
// -------------
// <- last tail ptr
// BUFFER <- to be rendered
// <- current tail ptr
// -------------
renderBuffer: function(headPtr, tailPtr, lastHeadPtr, lastTailPtr) {
var direction;
if (lastHeadPtr === undefined && lastTailPtr === undefined) {
direction = 'refresh';
} else if (tailPtr > lastTailPtr || headPtr > lastHeadPtr) {
direction = 'down';
} else if (tailPtr < lastTailPtr || headPtr < lastHeadPtr) {
direction = 'up';
}
var i;
switch (direction) {
// If we don't have a prior state, then just render the entire viewport.
case 'refresh':
for (i = headPtr; i <= tailPtr; i++) {
this.renderItem(this.itemList[i], i);
}
break;
// The user is scrolling down the table so reap from the head and render
// to the tail.
case 'down':
for (i = lastHeadPtr; i < headPtr; i++) {
this.reapItem(this.itemList[i]);
}
for (i = lastTailPtr + 1; i <= tailPtr; i++) {
this.renderItem(this.itemList[i], i);
}
break;
// the user is scrolling up the table so reap from the tail and render
// to the head.
case 'up':
for (i = lastTailPtr; i > tailPtr; i--) {
this.reapItem(this.itemList[i]);
}
for (i = lastHeadPtr - 1; i >= headPtr; i--) {
this.renderItem(this.itemList[i], i);
}
break;
default:
break;
}
this.lastHeadPtr = this.headPtr;
this.lastTailPtr = this.tailPtr;
},
// To render an item we ask our owner to provide the cell or section header.
// We then apply the appropriate transform so the item appears at the
// appropriate index.
renderItem: function(item, index) {
if (item.view) return;
if (item.hasOwnProperty('row')) {
view = this.owner.cellForRowInSection(this, item.row, item.section);
view.setMetadata({'item-index': index});
} else {
view = this.owner.viewForHeaderInSection(this, item.section);
view.setMetadata({'item-index': index});
}
view.setStyle({webkitTransform: 'translate3d(0,' + item.start + 'px,0)'});
item.view = view;
if (!view.getNode().parentNode) {
this.scrollView.appendChild(view);
}
},
// Items that are no longer within the buffer range can be reaped and queued
// for reuse. When an item is reaped we delete it from the item in the item
// cache to signal that it can no longer be rendered without refetching from
// the data source. We then push the item onto the appropriate section or
// cell queue and hide the dom representation offscreen.
reapItem: function(item) {
var view = item.view;
if (!view) return;
delete item.view;
if (item.hasOwnProperty('row')) {
this.enqueueReusableCellWithIdentifier(
view,
view.getReuseIdentifier());
} else {
this.enqueueReusableSectionHeaderWithIdentifier(
view,
view.getReuseIdentifier());
}
view.setStyle({webkitTransform: 'translate3d(-5000px,0,0)'});
view.setMetadata({'item-index': 'queued'});
},
// Use binary search to find the item in the itemList that wraps the
// requested coordinate.
findItemIndex: function(y) {
var high = itemList.length; low = 0;
while(low < high) {
mid = (low + high) >> 1;
var item = itemList[mid];
if (item.start <= y && item.end >= y) {
return mid;
} else if (y < item.end) {
high = mid;
} else {
low = mid + 1;
}
}
return null;
},
// Calculate the total scroll height of the view by iterating the data
// source and marking where each view begins and ends. This is cached until
// the invalidate method is called on the view.
calculateScrollHeight: function() {
window.itemList = this.itemList = [];
this.scrollHeight = 0;
if (this.getSectioned()) {
this.sectionCount = this.hasOwnProperty('sectionCount') ? this.sectionCount : this.owner.numberOfSections();
} else {
this.sectionCount = 1;
}
this.rowCounts = this.rowCounts || {};
var absoluteIndex = 0;
var item;
for (var i = 0; i < this.sectionCount; i++) {
var sectionHeight;
if (this.hasOwnProperty('fixedSectionHeaderHeight')) {
sectionHeight += this.fixedSectionHeaderHeight;
} else {
sectionHeight += this.owner.heightForSectionHeader && this.owner.heightForSectionHeader(i);
}
if (sectionHeight > 0) {
this.scrollHeight += sectionHeight;
item = {section: i, start: this.scrollHeight, end: this.scrollHeight};
this.itemList.push(item);
}
if (this.rowCounts[i] === undefined) {
this.rowCounts[i] = this.owner.numberOfRowsInSection(i);
}
for (var j = 0; j < this.rowCounts[i]; j++) {
item = {section: i, row: j, start: this.scrollHeight};
if (this.hasOwnProperty('fixedRowHeight')) {
this.scrollHeight += this.fixedRowHeight;
} else {
this.scrollHeight += this.owner.heightForRowInSection(j, i);
}
item.end = this.scrollHeight;
itemList.push(item);
}
this.absoluteIndex++;
}
this.scrollView.setContentHeight(this.scrollHeight);
this.scrollView.refresh();
},
// calculate the view port by grabbing the rectangle from the scroll view.
calculateViewPort: function() {
return this.viewPort = this.scrollView.getRect();
},
// pull a cell from the queue and return it. The cell's contents can
// then be modified for reuse.
dequeueReusableCellWithIdentifier: function(identifier) {
this.cells = this.cells || {};
this.cells[identifier] = this.cells[identifier] || [];
return this.cells[identifier].shift();
},
// pull a section header from the queue and return it. The section header's
// contents can then be modified for reuse.
dequeueReusableSectionHeaderWithIdentifier: function(identifier) {
this.sectionHeaders = this.sectionHeaders || {};
this.sectionHeaders[identifier] = this.sectionHeaders[identifier] || [];
return this.sectionHeaders[identifier].shift();
},
// enqueue a cell for reuse
enqueueReusableCellWithIdentifier: function(cell, identifier) {
this.cells = this.cells || {};
this.cells[identifier] = this.cells[identifier] || [];
return this.cells[identifier].push(cell);
},
// enqueue a section header for reuse
enqueueReusableSectionHeaderWithIdentifier: function(header, identifier) {
this.sections = this.sections || {};
this.sections[identifier] = this.sections[identifier] || [];
return this.sections[identifier].push(header);
},
// configurable log for debugging.
log: function(message) {
if (this.getDebug()) console.log.apply(console, arguments);
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment