rahulcn
3/16/2015 - 7:21 PM

index.html

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