Skip to content

Instantly share code, notes, and snippets.

@rahulcn
Forked from JoshMock/index.html
Last active August 29, 2015 14:17
Show Gist options
  • Save rahulcn/32e55b4f6e9e403cf068 to your computer and use it in GitHub Desktop.
Save rahulcn/32e55b4f6e9e403cf068 to your computer and use it in GitHub Desktop.

Revisions

  1. @JoshMock JoshMock revised this gist Oct 30, 2014. 1 changed file with 3 additions and 0 deletions.
    3 changes: 3 additions & 0 deletions infinity-collection.js
    Original file line number Diff line number Diff line change
    @@ -2,6 +2,9 @@
    * 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: {
  2. @JoshMock JoshMock created this gist Oct 30, 2014.
    27 changes: 27 additions & 0 deletions index.html
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,27 @@
    <!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>
    413 changes: 413 additions & 0 deletions infinity-collection.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,413 @@
    /**
    * Stolen lovingly from:
    * https://github.com/MeoMix/StreamusChromeExtension/blob/master/src/js/foreground/view/behavior/slidingRender.js
    * ...and slightly altered to meet our needs.
    */
    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();