Ext.define('Ext.scroll.VirtualScroller', {
    extend: 'Ext.scroll.Scroller',
    alias: 'scroller.virtual',
 
    isVirtualScroller: true,
 
    requires: [
        'Ext.scroll.indicator.*'
    ],
 
    config: {
        /**
         * @cfg {Boolean} autoRefresh
         * `true` to refresh the scroller automatically when the element size or content
         * size changes
         * @private
         */
        autoRefresh: false,
 
        /**
         * @cfg {Ext.scroll.indicator.Bar} barIndicator
         * Default configuration object to use for constructing
         * {@link Ext.scroll.indicator.Bar bar} {@link #indicators}.
         */
        barIndicator: {
            lazy: true,
            $value: {
                type: 'bar'
            }
        },
 
        /**
         * @cfg {Ext.fx.easing.Bounce} bounceEasing
         * @private
         */
        bounceEasing: {
            duration: 400
        },
 
        /**
         * @cfg {Boolean} directionLock
         * `true` to lock the direction of the scroller when the user starts scrolling.
         * Only applicable when interacting with the Scroller via touch-screen.
         * @accessor
         */
        directionLock: false,
 
        /**
         * @cfg {Boolean} disabled
         * `true` to disable this scroller.
         */
        disabled: null,
 
        /**
         * @cfg {Object} clientSize
         * @private
         */
        clientSize: undefined,
 
        /**
         * @cfg {Object} scrollbarSize
         * @private
         */
        scrollbarSize: {
            width: 0,
            height: 0
        },
 
        /**
         * @cfg {Boolean/Object/'overlay'/'bar'} [indicators=true]
         * `false` to hide scroll indicators while scrolling, `true` to show scroll indicators,
         * or a config object for {@link Ext.scroll.indicator.Indicator} to configure the
         * scroll indicators.
         *
         * May also be an object with 'x' and 'y' properties for configuring the vertical
         * and horizontal indicators separately. For example, to show only the vertical
         * indicator, but not the horizontal indicator:
         *
         *     {
         *         x: false,
         *         y: true
         *     }
         *
         * The Virtual Scroller will automatically choose an appropriate Indicator type
         * for the current platform:
         *
         * - {@link Ext.scroll.indicator.Bar} on devices that display native scrollbars.
         * - {@link Ext.scroll.indicator.Overlay} on devices with no scrollbars (iOS and
         * android devices, and MacOS when configured to not display scrollbars via "settings")
         *
         * The default selection of indicator type can be overridden by specifying the type
         * of indicator as a string, for example:
         *
         *     indicators: 'overlay'
         *
         * Overlay indicators can be used on any device, but "bar" indicators are only available
         * on devices that have scrollbars natively.
         */
        indicators: {
            x: true,
            y: true
        },
 
        /**
         * @cfg {Boolean} infinite
         * `true` to enable "infinite" scrolling.  In infinite scrolling mode when
         * {@link #pageSize} is reached the translation position of the innerElement will
         * be reset to 0, 0. The user of this scroller must adjust the positioning
         * of the content by the {@link #offsetY} and {@link #offsetX} amounts.
         */
        infinite: true,
 
        /**
         * @cfg {Ext.dom.Element} innerElement
         * @private
         * The element that wraps the content of {@link #element} and is translated in
         * response to user interaction.  If not configured, one will be automatically
         * generated.
         */
        innerElement: null,
 
        /**
         * @cfg {Number} maxAbsoluteVelocity
         * @private
         */
        maxAbsoluteVelocity: 6,
 
        /**
         * @cfg {Object} maxPosition
         * The max scroll position
         * @private
         */
        maxPosition: {
            x: 0,
            y: 0
        },
 
        /**
         * @cfg {Object} maxUserPosition
         * The max scroll position that can be achieved via user interaction.
         * @private
         */
        maxUserPosition: {
            x: 0,
            y: 0
        },
 
        /**
         * @cfg {Object} minPosition
         * The min scroll position.
         * @private
         */
        minPosition: {
            x: 0,
            y: 0
        },
 
        /**
         * @cfg {Object} minUserPosition
         * The min scroll position that can be achieved via user interaction.
         * @private
         */
        minUserPosition: {
            x: 0,
            y: 0
        },
 
        /**
         * @cfg {Ext.fx.easing.BoundMomentum} momentumEasing
         *
         * Momentum easing for touch-screen scrolling
         * @private
         */
        momentumEasing: {
            momentum: {
                acceleration: 30,
                friction: 0.5
            },
 
            bounce: {
                acceleration: 30,
                springTension: 0.3
            },
 
            minVelocity: 1
        },
 
        /**
         * @cfg outOfBoundRestrictFactor
         * @private
         */
        outOfBoundRestrictFactor: 0.5,
 
        /**
         * @cfg {Ext.scroll.indicator.Overlay} overlayIndicator
         * Default configuration object to use for constructing
         * {@link Ext.scroll.indicator.Overlay overlay} {@link #indicators}.
         */
        overlayIndicator: {
            lazy: true,
            $value: {
                type: 'overlay'
            }
        },
 
        /**
         * @cfg {Object} pageSize
         * @private
         * The size of each "virtual page" when {@link #infinite}
         * A page size of 0 means no infinite scrolling in that dimension.
         */
        pageSize: {
            // These numbers ought to be less than 1 million since that's the point at
            // which CSS transforms lose precision.
            x: 500000,
            y: 500000
        },
 
        /**
         * @cfg {Object} slotSnapEasing
         * @private
         */
        slotSnapEasing: {
            duration: 150
        },
 
        /**
         * @cfg {Object} slotSnapOffset
         * @private
         */
        slotSnapOffset: {
            x: 0,
            y: 0
        },
 
        /**
         * @cfg {Number/Object} slotSnapSize
         * The size of each slot to snap to in 'px', can be either an object with `x` and `y`
         * values, i.e:
         *
         *      {
         *          x: 50,
         *          y: 100
         *      }
         *
         * or a number value to be used for both directions. For example, a value of `50`
         * will be treated as:
         *
         *      {
         *          x: 50,
         *          y: 50
         *      }
         */
        slotSnapSize: {
            x: 0,
            y: 0
        },
 
        /**
         * @cfg {Number} startMomentumResetTime
         * @private
         */
        startMomentumResetTime: 300
    },
 
    eventedConfig: {
        /**
         * @cfg {Number} offsetX
         * @private
         * The x offset for {@link #infinite} scrolling
         */
        offsetX: 0,
 
        /**
         * @cfg {Number} offsetY
         * @private
         * The y offset for {@link #infinite} scrolling
         */
        offsetY: 0
    },
 
    translatable: 'csstransform',
 
    dragStartTime: 0,
 
    dragEndTime: 0,
 
    isDragging: false,
 
    isWheeling: false,
 
    isAnimating: false,
 
    /**
         * This propery is used when touch Scrolling is in process to notify the last scroll at
         * scroll end so that the scroll start and scroll end is in order.For each scroll start
         * we increment the value to count number of scrolls and at each subsequent scroll end
         *  the value is decremented
         */
 
    touchScrollCount: 0,
 
    isMouseEvent: {
        mousedown: 1,
        mousemove: 1,
        mouseup: 1
    },
 
    listenerMap: {
        touchstart: 'onTouchStart',
        touchmove: 'onTouchMove',
        dragstart: 'onDragStart',
        drag: 'onDrag',
        dragend: 'onDragEnd'
    },
 
    constructor: function(config) {
        var me = this,
            onEvent = 'onEvent',
            autoRefresh;
 
        me.elementListeners = {
            touchstart: onEvent,
            touchmove: onEvent,
            dragstart: onEvent,
            drag: onEvent,
            dragend: onEvent,
            wheel: 'onWheel',
            scope: me
        };
 
        me.minPosition = { x: 0, y: 0 };
 
        me.startPosition = { x: 0, y: 0 };
 
        me.velocity = { x: 0, y: 0 };
 
        me.isAxisEnabledFlags = { x: false, y: false };
 
        me.flickStartPosition = { x: 0, y: 0 };
 
        me.flickStartTime = { x: 0, y: 0 };
 
        me.lastDragPosition = { x: 0, y: 0 };
 
        me.dragDirection = { x: 0, y: 0 };
 
        // This object contains the potential height and width of scrollbars for size/clientSize
        // calculations.  To get the actual scrollbarSize after calculations use getScrollbarSize
        me.scrollbarSize = { width: 0, height: 0 };
 
        me.callParent([config]);
 
        autoRefresh = me.getAutoRefresh();
 
        me.refreshScrollbarSize();
 
        if (autoRefresh) {
            me.refresh(true);
        }
        else {
            me.refreshAxes();
            me.callIndicators('onRefresh');
        }
 
        me.scheduleRefresh = {
            idle: me.doRefresh,
            scope: me,
            single: true,
            destroyable: true
        };
    },
 
    doDestroy: function() {
        var me = this,
            element = me.getElement(),
            innerElement = me.getInnerElement(),
            sizeMonitors = me.sizeMonitors;
 
        if (sizeMonitors) {
            sizeMonitors.element.destroy();
            sizeMonitors.container.destroy();
        }
 
        if (element && !element.destroyed) {
            element.removeCls(me.cls);
        }
 
        if (innerElement && !innerElement.destroyed) {
            innerElement.removeCls(me.innerCls);
        }
 
        if (me._isWrapped) {
            if (!element.destroyed) {
                me.unwrapContent();
            }
 
            innerElement.destroy();
        }
 
        me.setElement(null);
        me.setInnerElement(null);
        me.setIndicators(null);
 
        Ext.destroyMembers(me, 'scrollbarCorner');
 
        me.callParent();
    },
 
    refresh: function(immediate) {
        var me = this;
 
        if (immediate) {
            me.doRefresh();
        }
        // Schedule a refresh at the next transition to idle.
        else if (!me.refreshScheduled) {
            me.refreshScheduled = Ext.on(me.scheduleRefresh);
        }
 
        return me;
    },
 
    //--------------------------------------------------------
    // Public Config Properties
 
    // disabled
 
    updateDisabled: function(disabled) {
        // attachment of listeners is handled by updateElement during initial config
        if (!this.isConfiguring) {
            if (disabled) {
                this.detachListeners();
            }
            else {
                this.attachListeners();
            }
        }
    },
 
    // element
 
    updateElement: function(element, oldElement) {
        var me = this,
            virtualScrollerCls = me.virtualScrollerCls,
            innerElement = me.getInnerElement(),
            autoRefresh;
 
        me.callParent([element, oldElement]);
 
        if (oldElement && !oldElement.destroyed) {
            oldElement.removeCls(virtualScrollerCls);
        }
 
        if (element) {
            element.addCls(virtualScrollerCls);
 
            if (!innerElement) {
                innerElement = me.wrapContent(element);
 
                me.setInnerElement(innerElement);
            }
 
            if (!me.getDisabled()) {
                me.attachListeners();
            }
 
            if (!me.isConfiguring) {
                // setting element after initial construction of Scroller
                // sync up configs that depend on element
                autoRefresh = me.getAutoRefresh();
 
                if (autoRefresh) {
                    me.toggleResizeListeners(autoRefresh);
                    me.refresh();
                }
            }
        }
    },
 
    // indicators
 
    applyIndicators: function(indicators, oldIndicators) {
        var me = this,
            type, defaultsConfig, xIndicator, yIndicator, oldXIndicator, oldYIndicator;
 
        if (indicators) {
            if (indicators === true || typeof indicators === 'string') {
                xIndicator = yIndicator = indicators;
            }
            else {
                type = indicators.type;
                xIndicator = indicators.x;
                yIndicator = indicators.y;
            }
        }
 
        if (oldIndicators) {
            indicators = oldIndicators; // reuse the old object
        }
        else {
            indicators = { x: null, y: null };
        }
 
        if (xIndicator || yIndicator) {
            defaultsConfig = Ext.scrollbar.width() && type !== 'overlay'
                ? 'barIndicator'
                : 'overlayIndicator';
        }
 
        oldXIndicator = indicators.x;
        oldYIndicator = indicators.y;
 
        if (xIndicator) {
            indicators.x = Ext.Factory.scrollindicator.update(oldXIndicator, xIndicator, me,
                                                              'createXIndicator', defaultsConfig);
        }
        else if (oldXIndicator) {
            oldXIndicator.destroy();
            indicators.x = null;
        }
 
        if (yIndicator) {
            indicators.y = Ext.Factory.scrollindicator.update(oldYIndicator, yIndicator, me,
                                                              'createYIndicator', defaultsConfig);
        }
        else if (oldYIndicator) {
            oldYIndicator.destroy();
            indicators.y = null;
        }
 
        return indicators;
    },
 
    updateIndicators: function(indicators) {
        if (!this.isConfiguring) {
            this.refreshScrollbarSize();
        }
    },
 
    createXIndicator: function(defaults) {
        return Ext.apply({
            axis: 'x',
            scroller: this
        }, defaults);
    },
 
    createYIndicator: function(defaults) {
        return Ext.apply({
            axis: 'y',
            scroller: this
        }, defaults);
    },
 
    // slotSnapSize
 
    applySlotSnapSize: function(snapSize) {
        if (typeof snapSize === 'number') {
            snapSize = {
                x: snapSize,
                y: snapSize
            };
        }
 
        return snapSize;
    },
 
    // x
 
    updateX: function() {
        if (!this.isConfiguring) {
            this.refreshAxes();
            this.refreshScrollbarSize();
        }
    },
 
    // y
 
    updateY: function() {
        if (!this.isConfiguring) {
            this.refreshAxes();
            this.refreshScrollbarSize();
        }
    },
 
    privates: {
        virtualScrollerCls: Ext.baseCSSPrefix + 'virtualscroller',
 
        innerCls: Ext.baseCSSPrefix + 'scroller-inner',
 
        scrollbarCornerCls: Ext.baseCSSPrefix + 'scrollbar-corner',
 
        attachListeners: function() {
            this.getElement().on(this.elementListeners);
        },
 
        callIndicators: function(methodName, force) {
            var me = this,
                indicators = me.getIndicators(),
                xIndicator, yIndicator;
 
            if (indicators) {
                if (force || me.isAxisEnabled('x')) {
                    xIndicator = indicators.x;
 
                    if (xIndicator && xIndicator[methodName]) {
                        xIndicator[methodName]();
                    }
                }
 
                if (force || me.isAxisEnabled('y')) {
                    yIndicator = indicators.y;
 
                    if (yIndicator && yIndicator[methodName]) {
                        yIndicator[methodName]();
                    }
                }
            }
        },
 
        constrainX: function(x) {
            return Math.min(this.getMaxPosition().x, Math.max(x, 0));
        },
 
        constrainY: function(y) {
            return Math.min(this.getMaxPosition().y, Math.max(y, 0));
        },
 
        // overridden in RTL mode to swap min/max momentum values
        convertEasingConfig: function(config) {
            return config;
        },
 
        detachListeners: function() {
            this.getElement().un(this.elementListeners);
        },
 
        /**
         * @private
         */
        doRefresh: function(size) {
            var me = this,
                configuredSize = me.configuredSize;
 
            if (me.refreshScheduled) {
                me.refreshScheduled = me.refreshScheduled.destroy();
            }
 
            if (me.getElement()) {
                me.stopAnimation();
 
                me.getTranslatable().refresh();
 
                me.setSize({
                    x: configuredSize.x ? undefined : null,
                    y: configuredSize.y ? undefined : null
                });
 
                me.fireEvent('refresh', me);
            }
        },
 
        doScrollTo: function(x, y, animate) {
            var me = this,
                isDragging = me.isDragging,
                isPrimary = me.isPrimary,
                ret, deferred, position, positionChanged, translatable, translationX, translationY;
 
            if (me.destroyed || !me.getElement()) {
                return me;
            }
 
            translatable = me.getTranslatable();
            position = me.position;
 
            if (!isDragging || me.isAxisEnabled('x')) {
                if (isNaN(x) || typeof x !== 'number') {
                    x = position.x;
                }
                else {
                    if (isPrimary !== false && !isDragging) {
                        x = me.constrainX(x);
                    }
 
                    if (position.x !== x) {
                        positionChanged = true;
                    }
                }
            }
 
            if (!isDragging || me.isAxisEnabled('y')) {
                if (isNaN(y) || typeof y !== 'number') {
                    y = position.y;
                }
                else {
                    if (isPrimary !== false && !isDragging) {
                        y = me.constrainY(y);
                    }
 
                    if (position.y !== y) {
                        positionChanged = true;
                    }
                }
            }
 
            if (positionChanged) {
                if (!me.isScrolling) {
                    me.onScrollStart();
                }
 
                translationX = me.convertX(-x);
                translationY = -y;
 
                if (animate) {
                    deferred = new Ext.Deferred();
 
                    // Use onFrame here to let the scroll complete and animations to fire.
                    translatable.on('animationend', function() {
                        // Check destroyed vs destroying since we're onFrame here
                        if (me.destroyed) {
                            deferred.reject();
                        }
                        else {
                            deferred.resolve();
                        }
                    }, Ext.global, { single: true, onFrame: true });
 
                    // onAnimationEnd calls onScrollEnd
                    translatable.translateAnimated(translationX, translationY, animate);
 
                    ret = deferred.promise;
                }
                else {
                    translatable.translate(translationX, translationY);
 
                    me.onScroll(x, y);
 
                    if (me.isWheeling || me.isScrollbarScrolling) {
                        me.onScrollEnd();
                    }
                    else if (!isDragging) {
                        me.doOnScrollEnd();
                    }
                }
            }
            else if (animate && animate.callback) {
                animate.callback();
            }
 
            return ret || Ext.Deferred.getCachedResolved();
        },
 
        /**
         * @private
         */
        getAnimationEasing: function(axis, e) {
            if (!this.isAxisEnabled(axis)) {
                return null;
            }
 
            // eslint-disable-next-line vars-on-top
            var me = this,
                currentPosition = me.position[axis],
                minPosition = me.getMinUserPosition()[axis],
                maxPosition = me.getMaxUserPosition()[axis],
                maxAbsVelocity = me.getMaxAbsoluteVelocity(),
                boundValue = null,
                dragEndTime = me.dragEndTime,
                velocity = e.flick.velocity[axis],
                isX = axis === 'x',
                easingConfig, easing;
 
            if (currentPosition < minPosition) {
                boundValue = minPosition;
            }
            else if (currentPosition > maxPosition) {
                boundValue = maxPosition;
            }
 
            if (isX) {
                currentPosition = me.convertX(currentPosition);
                boundValue = me.convertX(boundValue);
            }
 
            // Out of bound, to be pulled back
            if (boundValue !== null) {
                easing = me.getBounceEasing()[axis];
                easing.setConfig({
                    startTime: dragEndTime,
                    startValue: -currentPosition,
                    endValue: -boundValue
                });
 
                return easing;
            }
 
            if (velocity === 0) {
                return null;
            }
 
            if (velocity < -maxAbsVelocity) {
                velocity = -maxAbsVelocity;
            }
            else if (velocity > maxAbsVelocity) {
                velocity = maxAbsVelocity;
            }
 
            easing = me.getMomentumEasing()[axis];
            easingConfig = {
                startTime: dragEndTime,
                startValue: -currentPosition,
                startVelocity: velocity * 1.5,
                minMomentumValue: -maxPosition,
                maxMomentumValue: 0
            };
 
            if (isX) {
                me.convertEasingConfig(easingConfig);
            }
 
            easing.setConfig(easingConfig);
 
            return easing;
        },
 
        /**
         * @private
         * @return {Number/null}
         */
        getSnapPosition: function(axis) {
            var me = this,
                snapSize = me.getSlotSnapSize()[axis],
                snapPosition = null,
                position, snapOffset, maxPosition, mod;
 
            if (snapSize !== 0 && me.isAxisEnabled(axis)) {
                position = me.position[axis];
                snapOffset = me.getSlotSnapOffset()[axis];
                maxPosition = me.getMaxUserPosition()[axis];
 
                mod = Math.floor((position - snapOffset) % snapSize);
 
                if (mod !== 0) {
                    if (position !== maxPosition) {
                        if (Math.abs(mod) > snapSize / 2) {
                            snapPosition = Math.min(maxPosition, position + (
                                (mod > 0) ? snapSize - mod : mod - snapSize)
                            );
                        }
                        else {
                            snapPosition = position - mod;
                        }
                    }
                    else {
                        snapPosition = position - mod;
                    }
                }
            }
 
            return snapPosition;
        },
 
        /**
         * Returns `true` if a specified axis is enabled.
         * @private
         * @param {String} axis The axis to check (`x` or `y`).
         * @return {Boolean} `true` if the axis is enabled.
         */
        isAxisEnabled: function(axis) {
            this.getX();
            this.getY();
 
            return this.isAxisEnabledFlags[axis];
        },
 
        onAnimationEnd: function() {
            this.isAnimating = false;
            this.snapToBoundary();
            this.onScrollEnd();
        },
 
        onAnimationFrame: function(translatable, x, y) {
            this.onScroll(this.convertX(-x), -y);
        },
 
        onAnimationStart: function() {
            this.isAnimating = true;
        },
 
        onAxisDrag: function(axis, delta) {
            // Nothing to do if no delta, or it's on a disabled axis
            if (delta && this.isAxisEnabled(axis)) {
                // eslint-disable-next-line vars-on-top
                var me = this,
                    flickStartPosition = me.flickStartPosition,
                    flickStartTime = me.flickStartTime,
                    lastDragPosition = me.lastDragPosition,
                    dragDirection = me.dragDirection,
                    old = me.position[axis],
                    min = me.getMinUserPosition()[axis],
                    max = me.getMaxUserPosition()[axis],
                    start = me.startPosition[axis],
                    last = lastDragPosition[axis],
                    current = start - delta,
                    lastDirection = dragDirection[axis],
                    restrictFactor = me.getOutOfBoundRestrictFactor(),
                    startMomentumResetTime = me.getStartMomentumResetTime(),
                    now = Ext.Date.now(),
                    distance;
 
                if (current < min) {
                    current *= restrictFactor;
                }
                else if (current > max) {
                    distance = current - max;
                    current = max + distance * restrictFactor;
                }
 
                if (current > last) {
                    dragDirection[axis] = 1;
                }
                else if (current < last) {
                    dragDirection[axis] = -1;
                }
 
                if ((lastDirection !== 0 && (dragDirection[axis] !== lastDirection)) ||
                    (now - flickStartTime[axis]) > startMomentumResetTime) {
                    flickStartPosition[axis] = old;
                    flickStartTime[axis] = now;
                }
 
                lastDragPosition[axis] = current;
 
                return true;
            }
        },
 
        onDrag: function(e) {
            var me = this,
                lastDragPosition = me.lastDragPosition;
 
            if (!me.isDragging) {
                return;
            }
 
            // If there's any moving to do, then move the content.
            // Boolean or operator avoids shortcutting the second function call if
            // first returns true.
            if (me.onAxisDrag('x', me.convertX(e.deltaX)) | me.onAxisDrag('y', e.deltaY)) {
                me.doScrollTo(lastDragPosition.x, lastDragPosition.y);
            }
        },
 
        onDragEnd: function(e) {
            var me = this,
                easingX, easingY;
 
            if (me.isDragging) {
                me.dragEndTime = Ext.Date.now();
 
                me.onDrag(e);
 
                me.isDragging = false;
 
                easingX = me.getAnimationEasing('x', e);
                easingY = me.getAnimationEasing('y', e);
 
                if (easingX || easingY) {
                    me.getTranslatable().animate(easingX, easingY);
                }
                else {
                    me.onScrollEnd();
                }
            }
        },
 
        onDragStart: function(e) {
            var me = this,
                absDeltaX = e.absDeltaX,
                absDeltaY = e.absDeltaY,
                startPosition = me.startPosition,
                flickStartPosition = me.flickStartPosition,
                flickStartTime = me.flickStartTime,
                lastDragPosition = me.lastDragPosition,
                currentPosition = me.position,
                dragDirection = me.dragDirection,
                x = currentPosition.x,
                y = currentPosition.y,
                now = Ext.Date.now(),
                horizontal = me.getX(),
                vertical = me.getY();
 
            Ext.undefer(me.scrollEndTimer);
 
            if (me.getDirectionLock() && !(horizontal && vertical)) {
                if ((horizontal && absDeltaX > absDeltaY) || (vertical && absDeltaY > absDeltaX)) {
                    e.stopPropagation();
                }
                else {
                    return;
                }
            }
 
            lastDragPosition.x = x;
            lastDragPosition.y = y;
 
            flickStartPosition.x = x;
            flickStartPosition.y = y;
 
            startPosition.x = x;
            startPosition.y = y;
 
            flickStartTime.x = now;
            flickStartTime.y = now;
 
            dragDirection.x = 0;
            dragDirection.y = 0;
 
            me.dragStartTime = now;
 
            me.isDragging = true;
 
            // Only signal a scroll start if we are not already scrolling.
            // If the drag is just the user giving another impulse, it is NOT
            // the start of a drag.
            if (!me.isScrolling) {
                me.onScrollStart();
            }
        },
 
        onElementResize: function() {
            this.refresh(true);
        },
 
        onEvent: function(e) {
            // use browserEvent to get the "real" type of DOM event that was fired, not a
            // potentially translated (or recognized) type
            var me = this,
                self = me.self;
 
            if ((!self.isTouching || me.isTouching) && // prevents nested scrolling
                    !self.isWheeling && // ignore touch input while scrolling with mouse
                    e.pointerType !== 'mouse' && (me.getY() || me.getX())) {
                return me[me.listenerMap[e.type]](e);
            }
        },
 
        onInnerElementResize: function() {
            this.refresh(true);
        },
 
        onPartnerScroll: function(x, y) {
            this.callParent([x, y]);
 
            this.callIndicators('onScroll');
        },
 
        onPartnerScrollStart: function(x, y, deltaX, deltaY) {
            this.callParent([x, y, deltaX, deltaY]);
 
            this.callIndicators('onScrollStart');
        },
 
        onPartnerScrollEnd: function() {
            this.callParent();
 
            this.callIndicators('onScrollEnd', true);
        },
 
        onScroll: function(logicalX, logicalY) {
            var me = this,
                position = me.position,
                deltaX = logicalX - position.x,
                deltaY = logicalY - position.y,
                pageSize, pageSizeX, pageSizeY, offsetX, offsetY, physicalX, physicalY;
 
            if (deltaX || deltaY) {
                position.x = logicalX;
                position.y = logicalY;
 
                if (me.getInfinite()) {
                    pageSize = me.getPageSize();
                    pageSizeX = pageSize.x;
 
                    if (pageSizeX) {
                        offsetX = me.getOffsetX();
                        physicalX = logicalX - offsetX;
 
                        if (physicalX > pageSizeX) {
                            me.setOffsetX(Math.floor(logicalX / pageSizeX) * pageSizeX);
                        }
                        else if (physicalX < -pageSizeX) {
                            me.setOffsetX(Math.ceil(logicalX / pageSizeX) * pageSizeX);
                        }
                    }
 
                    pageSizeY = pageSize.y;
 
                    if (pageSizeY) {
                        offsetY = me.getOffsetY();
                        physicalY = logicalY - offsetY;
 
                        if (physicalY > pageSizeY) {
                            me.setOffsetY(Math.floor(logicalY / pageSizeY) * pageSizeY);
                        }
                        else if (physicalY < -pageSizeY) {
                            me.setOffsetY(Math.ceil(logicalY / pageSizeY) * pageSizeY);
                        }
                    }
                }
 
                if (me.isPrimary) {
                    me.callIndicators('onScroll');
 
                    me.callPartners('onPartnerScroll', logicalX, logicalY);
 
                    me.fireScroll(logicalX, logicalY, deltaX, deltaY);
 
                    me.callPartners('fireScroll', logicalX, logicalY);
                }
            }
        },
 
        doOnScrollEnd: function() {
            var me = this,
                position = me.position,
                x = position.x,
                y = position.y;
 
            if (!me.destroying && !me.destroyed && me.isScrolling && me.isPrimary &&
                    !me.isTouching && !me.snapToSlot() && me.touchScrollCount <= 1) {
 
                me.isScrolling = Ext.isScrolling = me.isWheeling = me.self.isWheeling =
                    me.isScrollbarScrolling = false;
 
                me.callIndicators('onScrollEnd');
 
                me.callPartners('onPartnerScrollEnd');
 
                me.fireScrollEnd(x, y);
 
                me.callPartners('fireScrollEnd', x, y);
 
                if (!me.isScrolling) { // if scrollend event handler did not initiate another scroll
                    me.setPrimary(null);
                }
            }
 
            if (me.touchScrollCount > 0) {
                me.touchScrollCount--;
            }
        },
 
        onScrollStart: function() {
            var me = this,
                position = me.position,
                x = position.x,
                y = position.y;
 
            if (!me.isScrolling) {
                me.setPrimary(true);
 
                me.callIndicators('onScrollStart');
 
                me.callPartners('onPartnerScrollStart', x, y);
 
                me.fireScrollStart(x, y, 0, 0);
 
                me.callPartners('fireScrollStart', x, y);
            }
        },
 
        onTouchEnd: function() {
            var me = this;
 
            me.isTouching = me.self.isTouching = false;
 
            if (!me.isAnimating) {
                me.onScrollEnd();
            }
        },
 
        onTouchMove: function(e) {
            // Prevents the page from scrolling during scroll of a VirtualScroller.
            e.preventDefault();
        },
 
        onTouchStart: function(e) {
            var me = this;
 
            Ext.getDoc().on({
                touchend: 'onTouchEnd',
                scope: me,
                single: true
            });
 
            me.isTouching = me.self.isTouching = true;
 
            me.stopAnimation();
 
            me.touchScrollCount++;
        },
 
        onWheel: function(e) {
            var me = this,
                self = me.self,
                deltaX = e.deltaX,
                deltaY = e.deltaY,
                position = me.position,
                oldX = position.x,
                oldY = position.y,
                x = me.constrainX(position.x + deltaX),
                y = me.constrainY(position.y + deltaY);
 
            if (!== oldX || y !== oldY) {
                me.isWheeling = self.isWheeling = true;
 
                me.doScrollTo(x, y);
 
                e.preventDefault();
            }
            else if (me.isScrolling) {
                me.onScrollEnd();
            }
        },
 
        refreshAxes: function() {
            var me = this,
                flags = me.isAxisEnabledFlags,
                size = me.getSize(),
                clientSize = me.getClientSize(),
                maxX, maxY, x, y;
 
            if (!size || !clientSize) {
                return;
            }
 
            maxX = Math.max(0, size.x - clientSize.x);
            maxY = Math.max(0, size.y - clientSize.y);
            x = me.getX();
            y = me.getY();
 
            me.setMaxPosition({
                x: maxX,
                y: maxY
            });
 
            if (=== true || x === 'auto') {
                // auto scroll - axis is only enabled if the content is overflowing in the
                // same direction
                flags.x = !!maxX;
            }
            else if (=== false) {
                flags.x = false;
            }
            else if (=== 'scroll') {
                flags.x = true;
            }
 
            if (=== true || y === 'auto') {
                // auto scroll - axis is only enabled if the content is overflowing in the
                // same direction
                flags.y = !!maxY;
            }
            else if (=== false) {
                flags.y = false;
            }
            else if (=== 'scroll') {
                flags.y = true;
            }
 
            me.setMaxUserPosition({
                x: flags.x ? maxX : 0,
                y: flags.y ? maxY : 0
            });
 
            if (!me.isConfiguring) {
                me.callIndicators('onRefresh', true);
            }
        },
 
        refreshScrollbarSize: function() {
            var me = this,
                indicators = me.getIndicators(),
                barSize = me.scrollbarSize,
                scrollbarSize = Ext.scrollbar.size(),
                xIndicator, yIndicator;
 
            if (indicators) {
                xIndicator = indicators.x;
                yIndicator = indicators.y;
 
                if (xIndicator && xIndicator.isScrollbar && me.getX()) {
                    barSize.height = scrollbarSize.height;
                }
 
                if (yIndicator && yIndicator.isScrollbar && me.getY()) {
                    barSize.width = scrollbarSize.width;
                }
            }
        },
 
        refreshSize: function(size, oldSize) {
            var me = this,
                el = me.getElement(),
                extraPadding = Ext.isIE ? 1 : 0,
                scrollbarSize, scrollbarWidth, scrollbarHeight, globalScrollbarSize,
                hasVerticalScrollbar, hasHorizontalScrollbar, clientSizeX, clientSizeY,
                sizeX, sizeY, dom, innerDom, flags, measuredX, measuredY, indicators,
                xIndicator, yIndicator, recalcX, recalcY, scrollbarCorner;
 
            if (el && !el.destroyed) {
                dom = el.dom;
                innerDom = me.getInnerElement().dom;
                sizeX = size.x;
                sizeY = size.y;
                scrollbarSize = me.scrollbarSize;
                scrollbarWidth = scrollbarSize.width;
                scrollbarHeight = scrollbarSize.height;
                globalScrollbarSize = Ext.scrollbar.size();
                hasVerticalScrollbar = false;
                hasHorizontalScrollbar = false;
                indicators = me.getIndicators();
                xIndicator = indicators.x;
                yIndicator = indicators.y;
                scrollbarCorner = me.scrollbarCorner;
 
                if (sizeX === undefined) {
                    sizeX = oldSize && oldSize.x;
                }
 
                if (sizeY === undefined) {
                    sizeY = oldSize && oldSize.y;
                }
 
                // using scrollWidth/scrollHeight instead of offsetWidth/offsetHeight ensures
                // that the size includes any contained absolutely positioned items
                if (sizeX == null) { // could be undefined if oldSize has not yet been set
                    sizeX = innerDom.scrollWidth;
                    measuredX = true;
                }
 
                if (sizeY == null) { // could be undefined if oldSize has not yet been set
                    sizeY = innerDom.scrollHeight;
                    measuredY = true;
                }
 
                clientSizeX = dom.clientWidth;
                clientSizeY = dom.clientHeight;
 
                flags = me.getScrollbarFlags(sizeX, sizeY, clientSizeX, clientSizeY);
                hasVerticalScrollbar = !!(flags & 1);
                hasHorizontalScrollbar = !!(flags & 2);
 
                if (flags && (flags !== me.scrollbarFlags)) {
                    if (xIndicator) {
                        xIndicator.setEnabled(me.getX() === 'scroll');
                        // make indicator invisible to avoid potential flicker if we have to
                        // perform multiple measurements
                        xIndicator.setStyle('visibility', 'hidden');
                    }
 
                    if (yIndicator) {
                        yIndicator.setEnabled(me.getY() === 'scroll');
                        // make indicator invisible to avoid potential flicker if we have to
                        // perform multiple measurements
                        yIndicator.setStyle('visibility', 'hidden');
                    }
 
                    if (measuredX && hasVerticalScrollbar) {
                        yIndicator.setEnabled(true);
                        recalcX = true;
                    }
 
                    if (measuredY && hasHorizontalScrollbar) {
                        xIndicator.setEnabled(true);
                        recalcY = true;
                    }
 
                    if (recalcX) {
                        sizeX = innerDom.scrollWidth;
                    }
 
                    if (recalcY) {
                        sizeY = innerDom.scrollHeight;
                    }
 
                    if (recalcX || recalcY) {
                        flags = me.getScrollbarFlags(sizeX, sizeY, clientSizeX, clientSizeY);
                        hasVerticalScrollbar = !!(flags & 1);
                        hasHorizontalScrollbar = !!(flags & 2);
                    }
 
                    if (hasHorizontalScrollbar && hasVerticalScrollbar) {
                        if (!scrollbarCorner) {
                            me.scrollbarCorner = scrollbarCorner = me.getElement().createChild({
                                cls: me.scrollbarCornerCls
                            });
 
                            scrollbarCorner.setVisibilityMode(2);
 
                            // windows displays scroll buttons by default and in some browsers 
                            // this can cause scrollbarCorner to grow in size, so we manually 
                            // set height/width of this container.
                            if (Ext.isWindows) {
                                scrollbarCorner.setStyle({
                                    width: (scrollbarWidth + extraPadding) + 'px',
                                    height: (scrollbarHeight + extraPadding) + 'px'
                                });
                            }
                        }
 
                        scrollbarCorner.show();
                    }
                    else if (scrollbarCorner) {
                        scrollbarCorner.hide();
                    }
 
                    if (xIndicator) {
                        xIndicator.setStyle('visibility', '');
                    }
 
                    if (yIndicator) {
                        yIndicator.setStyle('visibility', '');
                    }
 
                    me.scrollbarFlags = flags;
 
                    me.setScrollbarSize({
                        width: hasVerticalScrollbar ? scrollbarWidth : 0,
                        height: hasHorizontalScrollbar ? scrollbarHeight : 0,
                        reservedWidth: hasVerticalScrollbar
                            ? globalScrollbarSize.reservedWidth
                            : '',
                        reservedHeight: hasHorizontalScrollbar
                            ? globalScrollbarSize.reservedHeight
                            : ''
                    });
                }
 
                clientSizeX = hasVerticalScrollbar ? (clientSizeX - scrollbarWidth) : clientSizeX;
                clientSizeY = hasHorizontalScrollbar
                    ? (clientSizeY - scrollbarHeight)
                    : clientSizeY;
 
                me.setClientSize({
                    x: clientSizeX,
                    y: clientSizeY
                });
 
                return {
                    x: Math.max(sizeX, clientSizeX),
                    y: Math.max(sizeY, clientSizeY)
                };
            }
 
            return null;
        },
 
        /**
         * Returns the following bit flags
         *
         * vertical scrollbar       1   '00000001'
         * horizontal scrollbar     2   '00000010'
         * both scrollbars          3   '00000011'
         *
         * @private
         * @param {Number} sizeX 
         * @param {Number} sizeY 
         * @param {Number} clientSizeX 
         * @param {Number} clientSizeY 
         */
        getScrollbarFlags: function(sizeX, sizeY, clientSizeX, clientSizeY) {
            var me = this,
                flags = 0,
                scrollbarSize = me.scrollbarSize,
                scrollbarWidth = scrollbarSize.width,
                scrollbarHeight = scrollbarSize.height;
 
            if (scrollbarWidth && (me.getY() === 'scroll' || sizeY > clientSizeY)) {
                flags |= 1;
            }
 
            if (scrollbarHeight && (me.getX() === 'scroll' || sizeX > clientSizeX)) {
                flags |= 2;
            }
 
            if ((flags & 1) && (scrollbarHeight && (sizeX > clientSizeX - scrollbarWidth))) {
                flags |= 2;
            }
 
            if ((flags & 2) && (scrollbarWidth && (sizeY > clientSizeY - scrollbarHeight))) {
                flags |= 1;
            }
 
            return flags;
        },
 
        snapToBoundary: function() {
            var me = this,
                position = me.getPosition();
 
            // If we haven't scrolled anywhere, we're done.
            if (me.isConfiguring || !(position.x || position.y)) {
                return;
            }
 
            // eslint-disable-next-line vars-on-top
            var minPosition = me.getMinUserPosition(),
                maxPosition = me.getMaxUserPosition(),
                minX = minPosition.x,
                minY = minPosition.y,
                maxX = maxPosition.x,
                maxY = maxPosition.y,
                x = Math.round(position.x),
                y = Math.round(position.y);
 
            if (< minX) {
                x = minX;
            }
            else if (> maxX) {
                x = maxX;
            }
 
            if (< minY) {
                y = minY;
            }
            else if (> maxY) {
                y = maxY;
            }
 
            me.doScrollTo(x, y);
        },
 
        /**
         * @private
         * @return {Boolean} 
         */
        snapToSlot: function() {
            var me = this,
                snapX = me.getSnapPosition('x'),
                snapY = me.getSnapPosition('y'),
                easing = me.getSlotSnapEasing();
 
            if (snapX !== null || snapY !== null) {
                me.doScrollTo(snapX, snapY, {
                    easingX: easing.x,
                    easingY: easing.y
                });
 
                return true;
            }
 
            return false;
        },
 
        /**
         * @private
         * Stops the animation of the scroller at any time.
         */
        stopAnimation: function() {
            this.getTranslatable().stopAnimation();
        },
 
        syncOffsets: function(translatable) {
            translatable.setOffsetX(-this.getOffsetX());
            translatable.setOffsetY(-this.getOffsetY());
        },
 
        toggleResizeListeners: function(autoRefresh) {
            var me = this,
                element = me.getElement(),
                method, innerElement;
 
            if (element) {
                innerElement = me.getInnerElement();
                method = autoRefresh ? 'on' : 'un';
 
                element[method]('resize', 'onElementResize', me);
                innerElement[method]('resize', 'onInnerElementResize', me);
            }
        },
 
        /**
         * Removes the wrapper created by {@link #wrapContent}.
         * @private
         */
        unwrapContent: function() {
            var innerDom = this.getInnerElement().dom,
                dom = this.getElement().dom,
                child;
 
            while ((child = innerDom.firstChild)) {
                dom.insertBefore(child, innerDom);
            }
        },
 
        /**
         * Wraps the element's content in a innerElement
         * @param {Ext.dom.Element} element 
         * @return {Ext.dom.Element} the innerElement
         * @private
         */
        wrapContent: function(element) {
            var wrap = document.createElement('div'),
                dom = element.dom,
                child;
 
            while ((child = dom.lastChild)) { // jshint ignore:line
                wrap.insertBefore(child, wrap.firstChild);
            }
 
            dom.appendChild(wrap);
 
            this.setInnerElement(wrap);
 
            // Set a flag that indiacates the element's content was not already pre-wrapped
            // when the scroller was instanced.  This means we had to wrap the content
            // and so must unwrap when we destroy the scroller.
            this._isWrapped = true;
 
            return this.getInnerElement();
        },
 
        //--------------------------------------------------------
        // Private Config Properties
 
        // autoRefresh
 
        updateAutoRefresh: function(autoRefresh) {
            this.toggleResizeListeners(autoRefresh);
        },
 
        // bounceEasing
 
        applyBounceEasing: function(easing) {
            var defaultClass = Ext.fx.easing.EaseOut;
 
            return {
                x: Ext.factory(easing, defaultClass),
                y: Ext.factory(easing, defaultClass)
            };
        },
 
        updateBounceEasing: function(easing) {
            this.getTranslatable().setEasingX(easing.x).setEasingY(easing.y);
        },
 
        // clientSize
 
        updateClientSize: function() {
            if (!this.isConfiguring) {
                // to avoid multiple calls to refreshAxes() during initialization we will
                // call it once after initConfig has finished.
                this.refreshAxes();
            }
        },
 
        // innerElement
 
        applyInnerElement: function(innerElement) {
            if (innerElement) {
                if (!innerElement.isElement) {
                    innerElement = Ext.get(innerElement);
                }
            }
 
            //<debug>
            if (this.isConfiguring && !innerElement) {
                Ext.raise("Cannot create Ext.scroll.VirtualScroller with null innerElement");
            }
            //</debug>
 
            return innerElement;
        },
 
        updateInnerElement: function(innerElement) {
            if (innerElement) {
                innerElement.addCls(this.innerCls);
            }
 
            this.getTranslatable().setElement(innerElement);
        },
 
        // maxPosition
 
        applyMaxPosition: function(maxPosition, oldMaxPosition) {
            // If a no-op (generated setter tests object identity), return undefined to abort set.
            if (oldMaxPosition && maxPosition.x === oldMaxPosition.x &&
                maxPosition.y === oldMaxPosition.y) {
                return;
            }
 
            // eslint-disable-next-line vars-on-top
            var translatable = this.getTranslatable(),
                yEasing;
 
            // If an animation is in flight...
            if (translatable.isAnimating) {
 
                // Find its Y dimension easing object
                yEasing = translatable.activeEasingY;
 
                // If it's animating in the -ve direction (scrolling up), and we are
                // shortening the scroll range, ensure the easing's min point complies
                // with the new end position.
                if (yEasing && yEasing.getStartVelocity &&
                    yEasing.getStartVelocity() < 0 && maxPosition.y < oldMaxPosition.y) {
                    yEasing.setMinMomentumValue(-maxPosition.y);
                }
            }
 
            return maxPosition;
        },
 
        // maxUserPosition
 
        applyMaxUserPosition: function(maxUserPosition, oldMaxUserPosition) {
            // If a no-op (generated setter tests object identity), return undefined to abort set.
            if (!oldMaxUserPosition || maxUserPosition.x !== oldMaxUserPosition.x ||
                maxUserPosition.y !== oldMaxUserPosition.y) {
                return maxUserPosition;
            }
        },
 
        updateMaxUserPosition: function() {
            this.snapToBoundary();
        },
 
        // minUserPosition
 
        updateMinUserPosition: function() {
            this.snapToBoundary();
        },
 
        // momentumEasing
 
        applyMomentumEasing: function(easing) {
            var defaultClass = Ext.fx.easing.BoundMomentum;
 
            return {
                x: Ext.factory(easing, defaultClass),
                y: Ext.factory(easing, defaultClass)
            };
        },
 
        // offsetX
 
        updateOffsetX: function(offsetX) {
            if (!this.isConfiguring) {
                this.getTranslatable().setOffsetX(offsetX);
            }
        },
 
        // offsetY
 
        updateOffsetY: function(offsetY) {
            if (!this.isConfiguring) {
                this.getTranslatable().setOffsetY(offsetY);
            }
        },
 
        // size
 
        applySize: function(size, oldSize) {
            size = this.callParent([size, oldSize]);
 
            return this.refreshSize(size, oldSize);
        },
 
        updateSize: function(size, oldSize) {
            if (!this.isConfiguring) {
                // to avoid multiple calls to refreshAxes() during initialization we will
                // call it once after initConfig has finished.
                this.refreshAxes();
            }
        },
 
        // slotSnapEasing
 
        applySlotSnapEasing: function(easing) {
            var defaultClass = Ext.fx.easing.EaseOut;
 
            return {
                x: Ext.factory(easing, defaultClass),
                y: Ext.factory(easing, defaultClass)
            };
        },
 
        // slotSnapOffset
 
        applySlotSnapOffset: function(snapOffset) {
            if (typeof snapOffset === 'number') {
                snapOffset = {
                    x: snapOffset,
                    y: snapOffset
                };
            }
 
            return snapOffset;
        },
 
        // translatable
 
        updateTranslatable: function(translatable) {
            if (translatable) {
                translatable.on({
                    animationframe: 'onAnimationFrame',
                    animationstart: 'onAnimationStart',
                    animationend: 'onAnimationEnd',
                    scope: this
                });
            }
        },
 
        createTranslatable: function(defaults) {
            return Ext.apply({
                element: this.getInnerElement()
            }, defaults);
        }
    }
});