Skip to content

Instantly share code, notes, and snippets.

@JoshMock
Last active July 9, 2016 10:51
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save JoshMock/c674b15d2ba4856743e4 to your computer and use it in GitHub Desktop.
Save JoshMock/c674b15d2ba4856743e4 to your computer and use it in GitHub Desktop.
Infinite scrolling CompositeView
<!DOCTYPE HTML>
<html>
<head>
<style type="text/css" media="all">
#main {
width: 300px;
height: 400px;
overflow: scroll;
}
#main .some-item {
padding: 40px;
background: #CCC;
border: 1px solid #000;
}
</style>
</head>
<body>
<div id="main"></div>
<script src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/backbone.marionette/2.2.2/backbone.marionette.min.js"></script>
<script src="infinity-collection.js"></script>
</body>
</html>
/**
* Stolen lovingly from:
* https://github.com/MeoMix/StreamusChromeExtension/blob/master/src/js/foreground/view/behavior/slidingRender.js
* ...and slightly altered to meet our needs.
* Things to note:
* 1) it has to be a composite view, and we may need to make sure the DOM structure is right, or adjust the behavior accordingly
* 2) I can't get the CSS quite right so it tends to "jump" a little bit as items are pushed on the end and popped off the top. We'll need to fix that.
*/
var SlidingRender = Backbone.Marionette.Behavior.extend({
collectionEvents: {
'reset': '_onCollectionReset',
'remove': '_onCollectionRemove',
'add': '_onCollectionAdd',
'change:active': '_onCollectionChangeActive'
},
// Enables progressive rendering of children by keeping track of indices which are currently rendered.
minRenderIndex: -1,
maxRenderIndex: -1,
// The height of a rendered childView in px. Including padding/margin.
childViewHeight: 40,
viewportHeight: -1,
// The number of items to render outside of the viewport. Helps with flickering because if
// only views which would be visible are rendered then they'd be visible while loading.
threshold: 10,
// Keep track of where user is scrolling from to determine direction and amount changed.
lastScrollTop: 0,
initialize: function () {
// IMPORTANT: Stub out the view's implementation of addChild with the slidingRender version.
this.view.addChild = this._addChild.bind(this);
this.view.showCollection = this._showCollection.bind(this);
$(window).on('resize', this._onWindowResize);
},
onShow: function () {
// Allow N items to be rendered initially where N is how many items need to cover the viewport.
this.minRenderIndex = this._getMinRenderIndex(0);
this._setViewportHeight();
// If the collection implements getActiveItem - scroll to the active item.
if (this.view.collection.getActiveItem) {
if (this.view.collection.length > 0) {
this._scrollToItem(this.view.collection.getActiveItem());
}
}
var self = this;
// Throttle the scroll event because scrolls can happen a lot and don't need to re-calculate very often.
this.view.$el.parent().scroll(_.throttle(function () {
self._setRenderedElements(this.scrollTop);
}, 20));
},
// jQuery UI's sortable needs to be able to know the minimum rendered index. Whenever an external
// event requests the min render index -- return it!
onGetMinRenderIndex: function () {
this.view.triggerMethod('GetMinRenderIndexReponse', {
minRenderIndex: this.minRenderIndex
});
},
_onWindowResize: function () {
this._setViewportHeight();
},
// Whenever the viewport height is changed -- adjust the items which are currently rendered to match
_setViewportHeight: function () {
this.viewportHeight = this.$el.height();
// Unload or load N items where N is the difference in viewport height.
var currentMaxRenderIndex = this.maxRenderIndex;
var newMaxRenderIndex = this._getMaxRenderIndex(this.lastScrollTop);
var indexDifference = currentMaxRenderIndex - newMaxRenderIndex;
// Be sure to update before potentially adding items or else they won't render.
this.maxRenderIndex = newMaxRenderIndex;
if (indexDifference > 0) {
// Unload N Items.
// Only remove items if need be -- collection's length might be so small that the viewport's height isn't affecting rendered count.
if (this.view.collection.length > currentMaxRenderIndex) {
this._removeItemsByIndex(currentMaxRenderIndex, indexDifference);
}
}
else if (indexDifference < 0) {
// Load N items
for (var count = 0; count < Math.abs(indexDifference) ; count++) {
this._renderElementAtIndex(currentMaxRenderIndex + 1 + count);
}
}
this._setHeightPaddingTop();
},
// When deleting an element from a list it's important to render the next element (if any) since
// positions change when removing.
_renderElementAtIndex: function (index) {
var rendered = false;
if (this.view.collection.length > index) {
var item = this.view.collection.at(index);
var ChildView = this.view.getChildView(item);
// Adjust the childView's index to account for where it is actually being added in the list
this._addChild(item, ChildView, index);
rendered = true;
}
return rendered;
},
_setRenderedElements: function (scrollTop) {
// Figure out the range of items currently rendered:
var currentMinRenderIndex = this.minRenderIndex;
var currentMaxRenderIndex = this.maxRenderIndex;
// Figure out the range of items which need to be rendered:
var minRenderIndex = this._getMinRenderIndex(scrollTop);
var maxRenderIndex = this._getMaxRenderIndex(scrollTop);
var itemsToAdd = [];
var itemsToRemove = [];
// Append items in the direction being scrolled and remove items being scrolled away from.
var direction = scrollTop > this.lastScrollTop ? 'down' : 'up';
if (direction === 'down') {
// Need to remove items which are less than the new minRenderIndex
if (minRenderIndex > currentMinRenderIndex) {
itemsToRemove = this.view.collection.slice(currentMinRenderIndex, minRenderIndex);
}
// Need to add items which are greater than oldMaxRenderIndex and ltoe maxRenderIndex
if (maxRenderIndex > currentMaxRenderIndex) {
itemsToAdd = this.view.collection.slice(currentMaxRenderIndex + 1, maxRenderIndex + 1);
}
} else {
// Need to add items which are greater than currentMinRenderIndex and ltoe minRenderIndex
if (minRenderIndex < currentMinRenderIndex) {
itemsToAdd = this.view.collection.slice(minRenderIndex, currentMinRenderIndex);
}
// Need to remove items which are greater than the new maxRenderIndex
if (maxRenderIndex < currentMaxRenderIndex) {
itemsToRemove = this.view.collection.slice(maxRenderIndex + 1, currentMaxRenderIndex + 1);
}
}
if (itemsToAdd.length > 0 || itemsToRemove.length > 0) {
this.minRenderIndex = minRenderIndex;
this.maxRenderIndex = maxRenderIndex;
if (itemsToAdd.length > 0) {
var currentTotalRendered = (currentMaxRenderIndex - currentMinRenderIndex) + 1;
if (direction === 'down') {
// Items will be appended after oldMaxRenderIndex.
this._addItems(itemsToAdd, currentMaxRenderIndex + 1, currentTotalRendered, true);
} else {
this._addItems(itemsToAdd, minRenderIndex, currentTotalRendered, false);
}
}
if (itemsToRemove.length > 0) {
this._removeItems(itemsToRemove);
}
this._setHeightPaddingTop();
}
this.lastScrollTop = scrollTop;
},
_setHeightPaddingTop: function() {
this._setPaddingTop();
this._setHeight();
},
// Adjust padding-top to properly position relative items inside of list since not all items are rendered.
_setPaddingTop: function () {
this.view.ui.childContainer.css('padding-top', this._getPaddingTop());
},
_getPaddingTop: function () {
return this.minRenderIndex * this.childViewHeight;
},
// Set the elements height calculated from the number of potential items rendered into it.
// Necessary because items are lazy-appended for performance, but scrollbar size changing not desired.
_setHeight: function () {
// Subtracting minRenderIndex is important because of how CSS renders the element. If you don't subtract minRenderIndex
// then the rendered items will push up the height of the element by minRenderIndex * childViewHeight.
var height = (this.view.collection.length - this.minRenderIndex) * this.childViewHeight;
// Keep height set to at least the viewport height to allow for proper drag-and-drop target - can't drop if height is too small.
if (height < this.viewportHeight) {
height = this.viewportHeight;
}
this.view.ui.childContainer.height(height);
},
_addItems: function (models, indexOffset, currentTotalRendered, isAddingToEnd) {
var skippedCount = 0;
var ChildView;
_.each(models, function (model, index) {
ChildView = this.view.getChildView(model);
var shouldAdd = this._indexWithinRenderRange(index + indexOffset);
if (shouldAdd) {
if (isAddingToEnd) {
// Adjust the childView's index to account for where it is actually being added in the list
this._addChild(model, ChildView, index + currentTotalRendered - skippedCount, true);
} else {
// Adjust the childView's index to account for where it is actually being added in the list, but
// also provide the unmodified index because this is the location in the rendered childViewList in which it will be added.
this._addChild(model, ChildView, index, true);
}
} else {
skippedCount++;
}
}, this);
},
// Remove N items from the end of the render item list.
_removeItemsByIndex: function (startIndex, countToRemove) {
for (var index = 0; index < countToRemove; index++) {
var item = this.view.collection.at(startIndex - index);
var childView = this.view.children.findByModel(item);
this.view.removeChildView(childView);
}
},
_removeItems: function (models) {
_.each(models, function (model) {
var childView = this.view.children.findByModel(model);
this.view.removeChildView(childView);
}, this);
},
// Overridden Marionette's internal method to loop through collection and show each child view.
// BUG: https://github.com/marionettejs/backbone.marionette/issues/2021
_showCollection: function () {
var viewIndex = 0;
var ChildView;
this.view.collection.each(function (child, index) {
ChildView = this.view.getChildView(child);
if (this._indexWithinRenderRange(index)) {
this.view.addChild(child, ChildView, viewIndex, true);
viewIndex += 1;
}
}, this);
},
// The bypass flag is set when shouldAdd has already been determined elsewhere.
// This is necessary because sometimes the view's model's index in its collection is different than the view's index in the collectionview.
// In this scenario the index has already been corrected before _addChild is called so the index isn't a valid indicator of whether the view should be added.
_addChild: function (child, ChildView, index, bypass) {
var shouldAdd = false;
if (this.minRenderIndex > -1 && this.maxRenderIndex > -1) {
shouldAdd = bypass || this._indexWithinRenderRange(index);
}
if (shouldAdd) {
return Backbone.Marionette.CompositeView.prototype.addChild.apply(this.view, arguments);
}
},
_getMinRenderIndex: function (scrollTop) {
var minRenderIndex = Math.floor(scrollTop / this.childViewHeight) - this.threshold;
if (minRenderIndex < 0) {
minRenderIndex = 0;
}
return minRenderIndex;
},
_getMaxRenderIndex: function (scrollTop) {
// Subtract 1 to make math 'inclusive' instead of 'exclusive'
var maxRenderIndex = Math.ceil((scrollTop / this.childViewHeight) + (this.viewportHeight / this.childViewHeight)) - 1 + this.threshold;
return maxRenderIndex;
},
// Returns true if an childView at the given index would not be fully visible -- part of it rendering out of the top of the viewport.
_indexOverflowsTop: function (index) {
var position = index * this.childViewHeight;
var scrollPosition = this.$el.scrollTop();
var overflowsTop = position < scrollPosition;
return overflowsTop;
},
_indexOverflowsBottom: function (index) {
// Add one to index because want to get the bottom of the element and not the top.
var position = (index + 1) * this.childViewHeight;
var scrollPosition = this.$el.scrollTop() + this.viewportHeight;
var overflowsBottom = position > scrollPosition;
return overflowsBottom;
},
_indexWithinRenderRange: function (index) {
return index >= this.minRenderIndex && index <= this.maxRenderIndex;
},
// Ensure that the active item is visible by setting the container's scrollTop to a position which allows it to be seen.
_scrollToItem: function (item) {
var itemIndex = this.view.collection.indexOf(item);
var overflowsTop = this._indexOverflowsTop(itemIndex);
var overflowsBottom = this._indexOverflowsBottom(itemIndex);
// Only scroll to the item if it isn't in the viewport.
if (overflowsTop || overflowsBottom) {
var scrollTop = 0;
// If the item needs to be made visible from the bottom, offset the viewport's height:
if (overflowsBottom) {
// Add 1 to index because want the bottom of the element and not the top.
scrollTop = (itemIndex + 1) * this.childViewHeight - this.viewportHeight;
}
else if (overflowsTop) {
scrollTop = itemIndex * this.childViewHeight;
}
this.$el.scrollTop(scrollTop);
}
},
// TODO: I feel like it would be bad to call this if I reset with new values....? Maybe not?
// Reset min/max, scrollTop, paddingTop and height to their default values.
_onCollectionReset: function () {
this.$el.scrollTop(0);
this.lastScrollTop = 0;
this.minRenderIndex = this._getMinRenderIndex(0);
this.maxRenderIndex = this._getMaxRenderIndex(0);
this._setHeightPaddingTop();
},
_onCollectionRemove: function (item, collection, options) {
// When a rendered view is lost - render the next one since there's a spot in the viewport
if (this._indexWithinRenderRange(options.index)) {
var rendered = this._renderElementAtIndex(this.maxRenderIndex);
// If failed to render next item and there are previous items waiting to be rendered, slide view back 1 item
if (!rendered && this.minRenderIndex > 0) {
this.$el.scrollTop(this.lastScrollTop - this.childViewHeight);
}
}
this._setHeightPaddingTop();
},
_onCollectionAdd: function (item, collection) {
var index = collection.indexOf(item);
var indexWithinRenderRange = this._indexWithinRenderRange(index);
// Subtract 1 from collection.length because, for instance, if our collection has 8 items in it
// and min-max is 0-7, the 8th item in the collection has an index of 7.
// Use a > comparator not >= because we only want to run this logic when the viewport is overfilled and not just enough to be filled.
var viewportOverfull = collection.length - 1 > this.maxRenderIndex;
// If a view has been rendered and it pushes another view outside of maxRenderIndex, remove that view.
if (indexWithinRenderRange && viewportOverfull) {
// Adding one because I want to grab the item which is outside maxRenderIndex. maxRenderIndex is inclusive.
this._removeItemsByIndex(this.maxRenderIndex + 1, 1);
}
this._setHeightPaddingTop();
},
_onCollectionChangeActive: function (item, active) {
if (active) {
this._scrollToItem(item);
}
}
});
var MyChildView = Marionette.ItemView.extend({
className: 'some-item',
template: _.template('<p>this is an item. <%= random %></p>')
});
var MyColView = Marionette.CompositeView.extend({
childView: MyChildView,
template: _.template('<div class="container"></div>'),
childViewContainer: '.container',
ui: { childContainer: '.container' },
behaviors: { SlidingRender: { behaviorClass: SlidingRender } }
});
var Application = new Marionette.Application();
Application.addRegions({ main: '#main' });
Application.addInitializer(function () {
var data = [];
_.times(1000, function () {
data.push({ random: Math.floor((Math.random() * 650) + 1) });
});
var col = new Backbone.Collection(data);
Application.main.show(new MyColView({ collection: col }));
});
Application.start();
@rahulcn
Copy link

rahulcn commented Mar 29, 2015

@JoshMock, In the above example you're adding items to the collection during the initialize method. What if i want to fetch and add 20 items to the collection every time I scroll to the bottom of the page ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment