Skip to content

Instantly share code, notes, and snippets.

@infyloop
Created September 27, 2011 08:09
Show Gist options
  • Save infyloop/1244583 to your computer and use it in GitHub Desktop.
Save infyloop/1244583 to your computer and use it in GitHub Desktop.
/*
*
* Author: Scott Borduin, Lioarlan, LLC
* License: GPL (http://www.gnu.org/licenses/gpl.html) -or- MIT (http://www.opensource.org/licenses/mit-license.php)
*
* Release: 0.15
*
* Acknowledgement: Based partly on public contributions from members of the Sencha.com bulletin board.
*
*/
Ext.namespace('Ext.ux');
Ext.ux.BufferedList = Ext.extend(Ext.List, {
// minimum number of items to be rendered at all times.
minimumItems: 15,
// number of items to render incrementally when scrolling past
// top or bottom of currently rendered items.
batchSize: 15,
// maximum number of items to be rendered before cleanup is
// triggered on scrollStop. Must be > batchSize.
cleanupBoundary: 20,
// if this is true, block item selection while the list is still scrolling
blockScrollSelect: false,
// this is a reasonable default, but still better to define it in config parameters
maxItemHeight: 20,
// override
initComponent: function() {
this.itemTplDelayed = new Ext.XTemplate('<div class="x-list-item"><div class="x-list-item-body">' + this.itemTpl + '</div></div>').compile();
// line to replace (35):
//this.itemTplDelayed = new Ext.XTemplate('<div class="x-list-item"><div class="x-list-item-body">' + this.itemTpl + '</div></div>').compile();
// onItemDisclosure support
this.itemTplDelayed = '<tpl for="."><div class="x-list-item"><div class="x-list-item-body">' + this.itemTpl + '</div>';
if (this.onItemDisclosure) {
this.itemTplDelayed += '<div class="x-list-disclosure"></div>';
}
this.itemTplDelayed += '</div></tpl>';
this.itemTplDelayed = new Ext.XTemplate(this.itemTplDelayed).compile();
// end onItemDisclosure support
Ext.ux.BufferedList.superclass.initComponent.call(this);
// new template which will only be used for our proxies
this.tpl = new Ext.XTemplate([
'<tpl for=".">',
'<div class="{id}"></div>',
'</tpl>'
]);
// Member variables to hold indicies of first and last items rendered.
this.topItemRendered = 0;
this.bottomItemRendered = 0;
// cleanup task to be invoked on scroll stop.
this.cleanupTask = new Ext.util.DelayedTask(this.itemCleanup,this);
// flag used to make sure we don't collide with the cleanup thread
this.isUpdating = false;
// variables used to store state for group header display
this.headerText = '';
this.groupHeaders = [];
// make sure grouping flags consistently initialized
if ( this.useGroupHeaders === undefined ) {
this.useGroupHeaders = this.grouped;
}
else {
this.grouped = this.grouped || this.useGroupHeaders;
}
},
// don't handle all records, but only return three: top proxy, container, bottom proxy
// actual content will be rendered to the container element in the scroll event handler
collectData: function(records, startIndex) {
return [{
id: 'ux-list-top-proxy'
},{
id: 'ux-list-container'
},{
id: 'ux-list-bottom-proxy'
}];
},
// @private - override so we can remove base class scroll event handlers
initEvents: function() {
Ext.ux.BufferedList.superclass.initEvents.call(this);
// Remove listeners added by base class, these are all overridden
// in this implementation.
this.mun(this.scroller, {
scrollstart: this.onScrollStart,
scroll: this.onScroll,
scope: this
});
// monitor for hide events, to stop scrolling when hide is called
this.mon(this,
{beforehide:this.onBeforeHide}
);
},
// @private - override of refresh from DataView.
refresh: function() {
// DataView.refresh renders our proxies and list container
Ext.ux.BufferedList.superclass.refresh.apply(this,arguments);
// if the store is unbound then the el appears to be null - once bound this is re-called and the el not null
if(this.getTargetEl()) {
// locate our proxy and list container nodes
this.topProxy = this.getTargetEl().down('.ux-list-top-proxy');
this.bottomProxy = this.getTargetEl().down('.ux-list-bottom-proxy');
this.listContainer = this.getTargetEl().down('.ux-list-container');
// if our store is not yet filled out, do nothing more
if ( this.store.getCount() === 0 ) {
return;
}
// if this is a grouped list, initialize group index map
if (this.grouped) {
this.initGroupIndexMap();
this.groupHeaders = [];
}
// show & buffer first items in the list
this.topProxy.setHeight(0);
this.bottomProxy.setHeight(this.store.getCount() * this.maxItemHeight);
this.renderOnScroll(0); // renders first this.minimumItems nodes in store
}
},
// @private - override
afterRender: function() {
Ext.ux.BufferedList.superclass.afterRender.apply(this,arguments);
// set up listeners which will trigger rendering/cleanup of our sliding window of items
this.mon(this.scroller,{
scroll: this.renderOnScroll,
scrollend: this.onScrollStop,
scope: this
});
},
// @private - queue up tasks to perform on scroll end
onScrollStop: function() {
// prevents the list from selecting an item if the user just taps to stop the scroll
if ( this.blockScrollSelect ) {
this.selModel.setLocked(true);
Ext.defer(this.unblockSelect,100,this);
}
// Queue cleanup task.
// The reason this is a delayed task, rather a direct execution, is that
// scrollend fires when the user merely flicks the list for further scrolling.
this.cleanupTask.delay(250);
},
// @private - delayed task function to resume selection after scroll end
unblockSelect: function() {
this.selModel.setLocked(false);
},
// check if index of store record corresponds to a currently rendered item
isItemRendered: function(index) {
// Trivial check after first render
return this.all.elements.length > 0 ?
index >= this.topItemRendered && index <= this.bottomItemRendered : false;
},
// return array of list item nodes actually visible. If returnAsIndexes is true,
// this will be an array of record indexes, otherwise it will be an
// array of nodes.
getVisibleItems: function(returnAsIndexes) {
var startPos = this.scroller.getOffset().y;
var elems = this.all.elements,
nElems = elems.length,
returnArray = [],
thisHeight = this.getHeight(),
node,
offTop,
i;
for ( i = 0; i < nElems; i++ ){
node = elems[i];
offTop = node.offsetTop + node.offsetHeight;
if ( offTop > startPos ) {
returnArray.push(returnAsIndexes ? node.viewIndex : node);
if ( offTop - startPos > thisHeight ) {
break;
}
}
}
return returnArray;
},
// @private - render items into sliding window
renderOnScroll: function(startRecord) { // startRecord optional
// cancel any cleanups pending from a scrollstop
this.cleanupTask.cancel();
// if we're still executing a cleanup task, or add/remove/replace, wait
// for the next call
if ( this.isUpdating ) {
return 0;
}
if ( this.debugFlag ) {
this.isUpdating = false;
}
var scrollPos = this.scroller.getOffset().y;
var newTop = null,
newBottom = null,
previousTop = this.topItemRendered,
previousBottom = this.bottomItemRendered,
scrollDown = false,
incrementalRender = false,
maxIndex = this.store.getCount() - 1;
if ( Ext.isNumber(startRecord) ) {
if ( startRecord < 0 || startRecord > maxIndex ) {
return 0; // error
}
newTop = startRecord;
newBottom = Math.min((startRecord + this.minimumItems) - 1,maxIndex);
scrollDown = true;
incrementalRender = false;
}
else {
var thisHeight = this.getHeight();
// position of top of list relative to top of visible area (+above, -below)
var listTopMargin = scrollPos - this.topProxy.getHeight();
// position of bottom of list relative to bottom of visible area (+above, -below)
var listBottomMargin = (scrollPos + thisHeight) - (this.topProxy.getHeight() + this.listContainer.getHeight());
// scrolled into "white space"
if ( listTopMargin <= -thisHeight || listBottomMargin >= thisHeight ) {
incrementalRender = false;
scrollDown = true;
newTop = Math.max( (Math.floor(scrollPos/this.maxItemHeight)-1), 0 );
newBottom = Math.min((newTop + this.minimumItems) - 1,maxIndex);
}
// about to scroll off top of list
else if ( listTopMargin < 50 && this.topItemRendered > 0 ) {
newTop = Math.max(this.topItemRendered - this.batchSize,0);
newBottom = previousBottom;
scrollDown = false;
incrementalRender = true;
}
// about to scroll off bottom of list
else if ( listBottomMargin > -50 ) {
newTop = previousTop;
newBottom = Math.min(previousBottom + this.batchSize,maxIndex);
scrollDown = true;
incrementalRender = true;
}
}
// no need to render anything?
if ( (newTop === null || newBottom === null) ||
(incrementalRender && newTop >= previousTop && newBottom <= previousBottom) ) {
// still need to update list header appropriately
if ( this.useGroupHeaders && this.pinHeaders ) {
this.updateListHeader(scrollPos);
}
return 0;
}
var startIdx, nItems = 0;
// Jumped past boundaries of currently rendered items? Replace entire item list.
if (this.bottomItemRendered === 0 || !incrementalRender) {
// new item list starting with newTop
nItems = this.replaceItemList(newTop,this.minimumItems);
}
// incremental - scrolling down
else if(scrollDown) {
startIdx = previousBottom + 1;
nItems = this.appendItems(startIdx,this.batchSize);
}
// incremental - scrolling up
else {
startIdx = Math.max(previousTop - 1,0);
nItems = this.insertItems(startIdx,this.batchSize);
// collapse top proxy to zero if we're actually at the top.
// This causes a minor behavioral glitch when the top proxy has
// non-zero height - the list stops momentum at the top instead of
// bouncing. But this only occurs when navigating into the middle
// of the list, then scrolling all the way back to the top, and
// doesn't prevent any other functionality from working. It could
// probably be worked around with enough creativity ...
if ( newTop === 0 ) {
this.topProxy.setHeight(0);
this.scroller.updateBoundary();
this.scroller.suspendEvents();
this.scroller.scrollTo({x:0,y:0});
this.scroller.resumeEvents();
}
}
// zero out bottom proxy if we're at the bottom ...
if ( newBottom === maxIndex ) {
var bottomPadding = this.getHeight() - this.listContainer.getHeight();
this.bottomProxy.setHeight(bottomPadding > 0 ? bottomPadding : 0);
}
// update list header appropriately
if ( this.useGroupHeaders && this.pinHeaders ) {
this.updateListHeader(this.scroller.getOffset().y);
}
return nItems;
},
// @private
updateListHeader: function(scrollPos) {
scrollPos = scrollPos || this.scroller.getOffset().y;
// List being "pulled down" at top of list. Hide header.
if ( scrollPos <= 0 && this.headerText ) {
this.updateHeaderText(false);
return;
}
// work backwards through groupHeaders until we find the
// first one at or above the top of the viewable items.
this.headerHeight = this.headerHeight || this.header.getHeight();
var i,
headerNode,
nHeaders = this.groupHeaders.length,
headerMoveTop = scrollPos + this.headerHeight,
groupTop,
transform,
headerText;
for ( i = nHeaders - 1; i >= 0; i-- ) {
headerNode = this.groupHeaders[i];
groupTop = headerNode.offsetTop;
if ( groupTop < headerMoveTop ) {
// group header "pushing up" or "pulling down" on list header
if (groupTop > scrollPos) {
this.transformedHeader = true;
transform = (scrollPos + this.headerHeight) - groupTop;
Ext.Element.cssTranslate(this.header, {x: 0, y: -transform});
// make sure list header text displaying previous group
this.updateHeaderText(this.getPreviousGroup(headerNode.innerHTML).toUpperCase());
}
else {
this.updateHeaderText(headerNode.innerHTML);
if ( this.transformedHeader ) {
this.header.setStyle('-webkit-transform', null);
this.transformedHeader = false;
}
}
break;
}
}
// if we never got a group header above the top of the list, make sure
// list header represents previous group text
if ( i < 0 && headerNode ) {
this.updateHeaderText(this.getPreviousGroup(headerNode.innerHTML).toUpperCase());
if ( this.transformedHeader ) {
this.header.setStyle('-webkit-transform', null);
this.transformedHeader = false;
}
}
},
// @private
updateHeaderText: function(groupString) {
if ( !groupString ) {
this.header.hide();
this.headerText = groupString;
}
else if ( groupString !== this.headerText ){
this.header.update(groupString);
this.header.show();
this.headerText = groupString;
}
},
// @private
itemCleanup: function() {
// item cleanup just replaces the current item list with a new, shortened
// item list. This is much faster than actually removing existing item nodes
// one by one.
if ( this.all.elements.length > this.cleanupBoundary ) {
this.updateItemList();
}
},
// used by insertItems, appendItems, replaceItems. Builds HTML to add
// to list container. Inserts group headers as appropriate.
// @private
buildItemHtml: function(firstItem,lastItem) {
// loop over records, building up html string
var i,
htm = '',
store = this.store,
tpl = this.itemTplDelayed,
grpHeads = this.useGroupHeaders,
record,
groupId;
for ( i = firstItem; i <= lastItem; i++ ) {
record = store.getAt(i);
if ( grpHeads ) {
groupId = store.getGroupString(record);
if ( i === this.groupStartIndex(groupId) ) {
htm += ('<h3 class="x-list-header">' + groupId.toUpperCase() + '</h3>');
}
}
htm += tpl.applyTemplate(record.data);
}
return htm;
},
// @private - Replace current contents of list container with new item list
replaceItemList: function(firstNew,nItems) {
var sc = this.store.getCount();
if ( firstNew >= sc ) {
return 0;
}
else if ( firstNew + nItems > sc ) {
nItems = sc - firstNew;
}
// See if the first item is currently rendered. If so, save the
// exact offset top position so we can recreate it. Otherwise, calculate
// new proxy size.
var topProxyHeight,
firstNode = this.getNode(firstNew);
if ( firstNode ) {
topProxyHeight = firstNew === 0 ? 0 : firstNode.offsetTop;
}
else {
topProxyHeight = firstNew * this.maxItemHeight;
}
var bottomProxyHeight = (sc - firstNew) * this.maxItemHeight;
// build html string
var lastNew = (firstNew + nItems) - 1;
var htm = this.buildItemHtml(firstNew,lastNew);
// replace listContainer internals with new html
this.all.elements.splice(0);
this.groupHeaders.splice(0);
this.listContainer.update(htm);
// append our new nodes to the elements array
var nodes = this.listContainer.dom.childNodes,
nodelen = nodes.length,
firstIndex = firstNew,
newNode,
tagName;
for ( var i = 0; i < nodelen; i++ ) {
newNode = nodes[i];
tagName = newNode.tagName;
if ( tagName === 'DIV') {
newNode.viewIndex = firstIndex++;
this.all.elements.push(newNode);
}
else if ( tagName === 'H3') {
this.groupHeaders.push(newNode);
}
}
// reset proxy heights, and save indicies of first and last items rendered
this.topProxy.setHeight(topProxyHeight);
this.bottomProxy.setHeight(bottomProxyHeight - this.listContainer.getHeight());
this.topItemRendered = firstNew;
this.bottomItemRendered = lastNew;
return nItems;
},
// Append a chunk of items to list container. Return number of items appended.
// @private
appendItems: function(firstNew,nItems) {
// check to make sure parameters in bounds
var sc = this.store.getCount();
if ( firstNew >= sc ) {
return 0;
}
else if ( firstNew + nItems > sc ) {
nItems = sc - firstNew;
}
// save current bottom of list, so we know where to start
// to find our new nodes.
var oldLastChild = this.listContainer.dom.lastChild;
// save current list container height
var oldListHeight = this.listContainer.getHeight();
// build html string
var lastNew = (firstNew + nItems) - 1;
var htm = this.buildItemHtml(firstNew,lastNew);
// append new nodes
Ext.DomHelper.insertHtml('beforeEnd',this.listContainer.dom,htm);
// append our new nodes to the elements array
var tagName, newNode = oldLastChild ? oldLastChild.nextSibling : this.listContainer.dom.firstChild;
while ( newNode ) {
tagName = newNode.tagName;
if ( tagName === 'DIV') {
newNode.viewIndex = firstNew++;
this.all.elements.push(newNode);
}
else if ( tagName === 'H3') {
this.groupHeaders.push(newNode);
}
newNode = newNode.nextSibling;
}
// recalculate bottom proxy height, and save index of last item rendered
this.bottomProxy.setHeight(this.bottomProxy.getHeight() - (this.listContainer.getHeight() - oldListHeight));
this.bottomItemRendered = lastNew;
return nItems;
},
// Insert a chunk of items at top of list container. Return number of items inserted.
insertItems: function(firstNew,nItems) {
// check to make sure parameters in bounds
if ( firstNew < 0 ) {
return 0;
}
else if ( firstNew - nItems < 0 ) {
nItems = firstNew + 1;
}
// save current top of list, so we know where to start
// to find our new nodes.
var oldFirstChild = this.listContainer.dom.firstChild;
// save current list container height
var oldListHeight = this.listContainer.getHeight();
// build html string
var lastNew = (firstNew - nItems) + 1;
var htm = this.buildItemHtml(lastNew,firstNew);
// insert new nodes
Ext.DomHelper.insertHtml('afterBegin',this.listContainer.dom,htm);
// insert our new nodes into the elements array
var tagName, newNode = oldFirstChild ? oldFirstChild.previousSibling : this.listContainer.dom.lastChild;
while ( newNode ) {
tagName = newNode.tagName;
if ( tagName === 'DIV') {
newNode.viewIndex = firstNew--;
this.all.elements.unshift(newNode);
}
else if ( tagName === 'H3') {
this.groupHeaders.unshift(newNode);
}
newNode = newNode.previousSibling;
}
// recalculate top proxy height, and save index of first item rendered
var newHeight = this.topProxy.getHeight() - (this.listContainer.getHeight() - oldListHeight);
this.topProxy.setHeight(lastNew === 0 ? 0 : Math.max(newHeight,0) );
this.topItemRendered = lastNew;
return nItems;
},
// @private - create a map of grouping strings to start index of the groups
initGroupIndexMap: function() {
this.groupIndexMap = {};
var i,
key,
firstKey,
store = this.store,
recmap = {},
groupMap = this.groupIndexMap,
prevGroup = '',
sc = store.getCount();
// build temporary map of group string to store index from store records
for ( i = 0; i < sc; i++ ) {
key = escape(store.getGroupString(store.getAt(i)).toLowerCase());
if ( recmap[key] === undefined ) {
recmap[key] = { index: i, closest: key, prev: prevGroup } ;
prevGroup = key;
}
if ( !firstKey ) {
firstKey = key;
}
}
// now make sure our saved map has entries for every index string
// in our index bar, if we have a bar.
if (!!this.indexBar) {
var barStore = this.indexBar.store,
bc = barStore.getCount(),
grpid,
idx = 0,
recobj;
prevGroup = '',
key = '';
for ( i = 0; i < bc; i++ ) {
grpid = barStore.getAt(i).get('key').toLowerCase();
recobj = recmap[grpid];
if ( recobj ) {
idx = recobj.index;
key = recobj.closest;
prevGroup = recobj.prev;
}
else if ( !key ) {
key = firstKey;
}
groupMap[grpid] = { index: idx, closest: key, prev: prevGroup };
}
}
else {
this.groupIndexMap = recmap;
}
},
// @private - get an encoded version of the string for use as a key in the hash
getKeyFromId: function (groupId){
return escape(groupId.toLowerCase());
},
// @private - get the group object corresponding to the given id
getGroupObj:function (groupId){
return this.groupIndexMap[this.getKeyFromId(groupId)];
},
// @private - get starting index of a group by group string
groupStartIndex: function(groupId) {
return this.getGroupObj(groupId).index;
},
// @private - get group preceding the one in groupId
getPreviousGroup: function(groupId) {
return this.getGroupObj(groupId).prev;
},
// @private - get closest non-empty group to specified groupId from indexBar
getClosestGroupId: function(groupId) {
return this.getGroupObj(groupId).closest;
},
// @private
indexOfRecord: function(rec) {
// take advantage of group map to speed up search for record index. Speeds up
// selection slightly.
var idx = -1, store = this.store, sc = store.getCount();
if ( this.grouped ) {
for ( idx = this.groupStartIndex(store.getGroupString(rec)); idx < sc; idx++ ) {
if ( store.getAt(idx) === rec ) {
break;
}
}
}
else {
idx = this.store.indexOf(rec)
}
return idx;
},
// @private - respond to indexBar touch.
onIndex: function(record, target, index) {
// get first item of group from map
var grpId = record.get('key').toLowerCase();
var firstItem = this.groupStartIndex(grpId);
// render new list of items into list container
if ( Ext.isNumber(firstItem) && this.renderOnScroll(firstItem) > 0 ) {
// Set list header text to reflect new group.
if ( this.useGroupHeaders && this.pinHeaders ) {
this.updateHeaderText(this.getClosestGroupId(grpId).toUpperCase());
}
// scroll list container into view. Temporarily suspend scroll events
// so as not to invoke another call to renderOnScroll. Must update
// scroller boundary to make sure scroll position in bounds.
this.scroller.updateBoundary();
this.scroller.suspendEvents();
this.scroller.scrollTo({x: 0, y: this.topProxy.getHeight()}, false);
this.scroller.resumeEvents();
}
},
// @private - override
onItemDeselect: function(record) {
var node = this.getNode(record);
if ( node ) {
Ext.fly(node).removeCls(this.selectedItemCls);
}
},
// getNode just compensates for the offset between the record index of
// our first rendered item and zero.
// @private - override
getNode : function(nodeInfo) {
nodeInfo = nodeInfo instanceof Ext.data.Model ? this.indexOfRecord(nodeInfo) : nodeInfo;
if ( Ext.isNumber(nodeInfo) ) {
return this.isItemRendered(nodeInfo) ?
this.all.elements[nodeInfo - this.topItemRendered] : null;
}
return Ext.ux.BufferedList.superclass.getNode.call(this, nodeInfo);
},
// @private - called on Add, Remove, Update, and cleanup.
updateItemList: function() {
// Update simply re-renders this.minimumItems item nodes, starting with the first visible
// item, and then restores any item selections. The current scroll position
// of the first visible item will be maintained.
this.isUpdating = true;
var visItems = this.getVisibleItems(true);
var startItem = visItems.length ? visItems[0] : 0;
// save selections
var selectedRecords = this.getSelectedRecords();
// replace items
this.replaceItemList(startItem,this.minimumItems);
// restore selections
var i, node;
for ( var i = 0; i < selectedRecords.length; i++ ) {
node = this.getNode(selectedRecords[i]);
if ( node ) {
Ext.fly(node).addCls(this.selectedItemCls);
}
}
this.isUpdating = false;
},
// each of the data store modifications is handled by the updateItemList
// function, which will ensure that the currently visible items reflect
// the latest state of the store.
// @private - override
onUpdate : function(store, record) {
if (this.grouped) {
this.initGroupIndexMap();
}
this.updateItemList();
},
// @private - override
onAdd : function(ds, records, index) {
if (this.grouped) {
this.initGroupIndexMap();
}
this.updateItemList();
},
// @private - override
onRemove : function(ds, record, index) {
if (this.grouped) {
this.initGroupIndexMap();
}
this.updateItemList();
},
onBeforeHide: function() {
// Stop the scroller when this component is hidden, e.g. when switching
// tabs in a tab panel.
var sc = this.scroller;
sc.suspendEvents();
sc.scrollTo({x:0,y:sc.getOffset().y});
sc.resumeEvents();
return true;
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment