Source: framework/components/scroll-view/component.js

Object.assign(pc, function () {
    var _tempScrollValue = new pc.Vec2();

    /**
     * @component
     * @constructor
     * @name pc.ScrollViewComponent
     * @extends pc.Component
     * @classdesc A ScrollViewComponent enables a group of entities to behave like a masked scrolling area, with optional horizontal and vertical scroll bars.
     * @description Create a new ScrollViewComponent.
     * @param {pc.ScrollViewComponentSystem} system The ComponentSystem that created this Component
     * @param {pc.Entity} entity The Entity that this Component is attached to.
     * @property {Boolean} horizontal Whether to enable horizontal scrolling.
     * @property {Boolean} vertical Whether to enable vertical scrolling.
     * @property {pc.SCROLL_MODE} scrollMode Specifies how the scroll view should behave when the user scrolls past the end of the content. Modes are defined as follows:
     * <ul>
     *     <li>{@link pc.SCROLL_MODE_CLAMP}: Content does not scroll any further than its bounds.</li>
     *     <li>{@link pc.SCROLL_MODE_BOUNCE}: Content scrolls past its bounds and then gently bounces back.</li>
     *     <li>{@link pc.SCROLL_MODE_INFINITE}: Content can scroll forever.</li>
     * </ul>
     * @property {Number} bounceAmount Controls how far the content should move before bouncing back.
     * @property {Number} friction Controls how freely the content should move if thrown, i.e. by flicking on a phone or by flinging the scroll wheel on a mouse. A value of 1 means that content will stop immediately; 0 means that content will continue moving forever (or until the bounds of the content are reached, depending on the scrollMode).
     * @property {pc.SCROLLBAR_VISIBILITY} horizontalScrollbarVisibility Controls whether the horizontal scrollbar should be visible all the time, or only visible when the content exceeds the size of the viewport.
     * @property {pc.SCROLLBAR_VISIBILITY} verticalScrollbarVisibility Controls whether the vertical scrollbar should be visible all the time, or only visible when the content exceeds the size of the viewport.
     * @property {pc.Entity} viewportEntity The entity to be used as the masked viewport area, within which the content will scroll. This entity must have an ElementGroup component.
     * @property {pc.Entity} contentEntity The entity which contains the scrolling content itself. This entity must have an Element component.
     * @property {pc.Entity} horizontalScrollbarEntity The entity to be used as the vertical scrollbar. This entity must have a Scrollbar component.
     * @property {pc.Entity} verticalScrollbarEntity The entity to be used as the vertical scrollbar. This entity must have a Scrollbar component.
     */
    var ScrollViewComponent = function ScrollViewComponent(system, entity) {
        pc.Component.call(this, system, entity);

        this._viewportReference = new pc.EntityReference(this, 'viewportEntity', {
            'element#gain': this._onViewportElementGain,
            'element#resize': this._onSetContentOrViewportSize
        });

        this._contentReference = new pc.EntityReference(this, 'contentEntity', {
            'element#gain': this._onContentElementGain,
            'element#lose': this._onContentElementLose,
            'element#resize': this._onSetContentOrViewportSize
        });

        this._scrollbarUpdateFlags = {};
        this._scrollbarReferences = {};
        this._scrollbarReferences[pc.ORIENTATION_HORIZONTAL] = new pc.EntityReference(this, 'horizontalScrollbarEntity', {
            'scrollbar#set:value': this._onSetHorizontalScrollbarValue,
            'scrollbar#gain': this._onHorizontalScrollbarGain
        });
        this._scrollbarReferences[pc.ORIENTATION_VERTICAL] = new pc.EntityReference(this, 'verticalScrollbarEntity', {
            'scrollbar#set:value': this._onSetVerticalScrollbarValue,
            'scrollbar#gain': this._onVerticalScrollbarGain
        });

        this._prevContentSizes = {};
        this._prevContentSizes[pc.ORIENTATION_HORIZONTAL] = null;
        this._prevContentSizes[pc.ORIENTATION_VERTICAL] = null;

        this._scroll = new pc.Vec2();
        this._velocity = new pc.Vec3();

        this._dragStartPosition = new pc.Vec3();
        this._disabledContentInput = false;
        this._disabledContentInputEntities = [];

        this._toggleLifecycleListeners('on', system);
        this._toggleElementListeners('on');
    };
    ScrollViewComponent.prototype = Object.create(pc.Component.prototype);
    ScrollViewComponent.prototype.constructor = ScrollViewComponent;

    Object.assign(ScrollViewComponent.prototype, {
        _toggleLifecycleListeners: function (onOrOff, system) {
            this[onOrOff]('set_horizontal', this._onSetHorizontalScrollingEnabled, this);
            this[onOrOff]('set_vertical', this._onSetVerticalScrollingEnabled, this);

            system.app.systems.element[onOrOff]('add', this._onElementComponentAdd, this);
            system.app.systems.element[onOrOff]('beforeremove', this._onElementComponentRemove, this);

            // TODO Handle scrollwheel events
        },

        _toggleElementListeners: function (onOrOff) {
            if (this.entity.element) {
                if (onOrOff === 'on' && this._hasElementListeners) {
                    return;
                }

                this.entity.element[onOrOff]('resize', this._onSetContentOrViewportSize, this);

                this._hasElementListeners = (onOrOff === 'on');
            }
        },

        _onElementComponentAdd: function (entity) {
            if (this.entity === entity) {
                this._toggleElementListeners('on');
            }
        },

        _onElementComponentRemove: function (entity) {
            if (this.entity === entity) {
                this._toggleElementListeners('off');
            }
        },

        _onViewportElementGain: function () {
            this._syncAll();
        },

        _onContentElementGain: function () {
            this._destroyDragHelper();
            this._contentDragHelper = new pc.ElementDragHelper(this._contentReference.entity.element);
            this._contentDragHelper.on('drag:start', this._onContentDragStart, this);
            this._contentDragHelper.on('drag:end', this._onContentDragEnd, this);
            this._contentDragHelper.on('drag:move', this._onContentDragMove, this);

            this._prevContentSizes[pc.ORIENTATION_HORIZONTAL] = null;
            this._prevContentSizes[pc.ORIENTATION_VERTICAL] = null;

            this._syncAll();
        },

        _onContentElementLose: function () {
            this._destroyDragHelper();
        },

        _onContentDragStart: function () {
            if (this._contentReference.entity && this.enabled && this.entity.enabled) {
                this._dragStartPosition.copy(this._contentReference.entity.getLocalPosition());
            }
        },

        _onContentDragEnd: function () {
            this._prevContentDragPosition = null;
            this._enableContentInput();
        },

        _onContentDragMove: function (position) {
            if (this._contentReference.entity && this.enabled && this.entity.enabled) {
                this._wasDragged = true;
                this._setScrollFromContentPosition(position);
                this._setVelocityFromContentPositionDelta(position);

                // if we haven't already, when scrolling starts
                // disable input on all child elements
                if (!this._disabledContentInput) {

                    // Disable input events on content after we've moved past a threshold value
                    var dx = (position.x - this._dragStartPosition.x);
                    var dy = (position.y - this._dragStartPosition.y);

                    if (Math.abs(dx) > this.dragThreshold ||
                        Math.abs(dy) > this.dragThreshold) {
                        this._disableContentInput();
                    }

                }
            }
        },

        _onSetContentOrViewportSize: function () {
            this._syncAll();
        },

        _onSetHorizontalScrollbarValue: function (scrollValueX) {
            if (!this._scrollbarUpdateFlags[pc.ORIENTATION_HORIZONTAL] && this.enabled && this.entity.enabled) {
                this._onSetScroll(scrollValueX, null);
            }
        },

        _onSetVerticalScrollbarValue: function (scrollValueY) {
            if (!this._scrollbarUpdateFlags[pc.ORIENTATION_VERTICAL] && this.enabled && this.entity.enabled) {
                this._onSetScroll(null, scrollValueY);
            }
        },

        _onSetHorizontalScrollingEnabled: function () {
            this._syncScrollbarEnabledState(pc.ORIENTATION_HORIZONTAL);
        },

        _onSetVerticalScrollingEnabled: function () {
            this._syncScrollbarEnabledState(pc.ORIENTATION_VERTICAL);
        },

        _onHorizontalScrollbarGain: function () {
            this._syncScrollbarEnabledState(pc.ORIENTATION_HORIZONTAL);
            this._syncScrollbarPosition(pc.ORIENTATION_HORIZONTAL);
        },

        _onVerticalScrollbarGain: function () {
            this._syncScrollbarEnabledState(pc.ORIENTATION_VERTICAL);
            this._syncScrollbarPosition(pc.ORIENTATION_VERTICAL);
        },

        _onSetScroll: function (x, y, resetVelocity) {
            if (resetVelocity !== false) {
                this._velocity.set(0, 0, 0);
            }

            var hasChanged = false;
            hasChanged |= this._updateAxis(x, 'x', pc.ORIENTATION_HORIZONTAL);
            hasChanged |= this._updateAxis(y, 'y', pc.ORIENTATION_VERTICAL);

            if (hasChanged) {
                this.fire('set:scroll', this._scroll);
            }
        },

        _updateAxis: function (scrollValue, axis, orientation) {
            var hasChanged = (scrollValue !== null && Math.abs(scrollValue - this._scroll[axis]) > 1e-5);

            // always update if dragging because drag helper directly updates the entity position
            // always update if scrollValue === 0 because it will be clamped to 0
            // if viewport is larger than content and position could be moved by drag helper but
            // hasChanged will never be true
            if (hasChanged || this._isDragging() || scrollValue === 0) {
                this._scroll[axis] = this._determineNewScrollValue(scrollValue, axis, orientation);
                this._syncContentPosition(orientation);
                this._syncScrollbarPosition(orientation);
            }

            return hasChanged;
        },

        _determineNewScrollValue: function (scrollValue, axis, orientation) {
            // If scrolling is disabled for the selected orientation, force the
            // scroll position to remain at the current value
            if (!this._getScrollingEnabled(orientation)) {
                return this._scroll[axis];
            }

            switch (this.scrollMode) {
                case pc.SCROLL_MODE_CLAMP:
                    return pc.math.clamp(scrollValue, 0, this._getMaxScrollValue(orientation));

                case pc.SCROLL_MODE_BOUNCE:
                    this._setVelocityFromOvershoot(scrollValue, axis, orientation);
                    return scrollValue;

                case pc.SCROLL_MODE_INFINITE:
                    return scrollValue;

                default:
                    console.warn('Unhandled scroll mode:' + this.scrollMode);
                    return scrollValue;
            }
        },

        _syncAll: function () {
            this._syncContentPosition(pc.ORIENTATION_HORIZONTAL);
            this._syncContentPosition(pc.ORIENTATION_VERTICAL);
            this._syncScrollbarPosition(pc.ORIENTATION_HORIZONTAL);
            this._syncScrollbarPosition(pc.ORIENTATION_VERTICAL);
            this._syncScrollbarEnabledState(pc.ORIENTATION_HORIZONTAL);
            this._syncScrollbarEnabledState(pc.ORIENTATION_VERTICAL);
        },

        _syncContentPosition: function (orientation) {
            var axis = this._getAxis(orientation);
            var sign = this._getSign(orientation);
            var contentEntity = this._contentReference.entity;

            if (contentEntity) {
                var prevContentSize = this._prevContentSizes[orientation];
                var currContentSize = this._getContentSize(orientation);

                // If the content size has changed, adjust the scroll value so that the content will
                // stay in the same place from the user's perspective.
                if (prevContentSize !== null && Math.abs(prevContentSize - currContentSize) > 1e-4) {
                    var prevMaxOffset = this._getMaxOffset(orientation, prevContentSize);
                    var currMaxOffset = this._getMaxOffset(orientation, currContentSize);
                    if (currMaxOffset === 0) {
                        this._scroll[axis] = 1;
                    } else {
                        this._scroll[axis] = pc.math.clamp(this._scroll[axis] * prevMaxOffset / currMaxOffset, 0, 1);
                    }
                }

                var offset = this._scroll[axis] * this._getMaxOffset(orientation);
                var contentPosition = contentEntity.getLocalPosition();
                contentPosition[axis] = offset * sign;

                contentEntity.setLocalPosition(contentPosition);

                this._prevContentSizes[orientation] = currContentSize;
            }
        },

        _syncScrollbarPosition: function (orientation) {
            var axis = this._getAxis(orientation);
            var scrollbarEntity = this._scrollbarReferences[orientation].entity;

            if (scrollbarEntity && scrollbarEntity.scrollbar) {
                // Setting the value of the scrollbar will fire a 'set:value' event, which in turn
                // will call the _onSetHorizontalScrollbarValue/_onSetVerticalScrollbarValue handlers
                // and cause a cycle. To avoid this we keep track of the fact that we're in the process
                // of updating the scrollbar value.
                this._scrollbarUpdateFlags[orientation] = true;
                scrollbarEntity.scrollbar.value = this._scroll[axis];
                scrollbarEntity.scrollbar.handleSize = this._getScrollbarHandleSize(axis, orientation);
                this._scrollbarUpdateFlags[orientation] = false;
            }
        },

        // Toggles the scrollbar entities themselves to be enabled/disabled based
        // on whether the user has enabled horizontal/vertical scrolling on the
        // scroll view.
        _syncScrollbarEnabledState: function (orientation) {
            var entity = this._scrollbarReferences[orientation].entity;

            if (entity) {
                var isScrollingEnabled = this._getScrollingEnabled(orientation);
                var requestedVisibility = this._getScrollbarVisibility(orientation);

                switch (requestedVisibility) {
                    case pc.SCROLLBAR_VISIBILITY_SHOW_ALWAYS:
                        entity.enabled = isScrollingEnabled;
                        return;

                    case pc.SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED:
                        entity.enabled = isScrollingEnabled && this._contentIsLargerThanViewport(orientation);
                        return;

                    default:
                        console.warn('Unhandled scrollbar visibility:' + requestedVisibility);
                        entity.enabled = isScrollingEnabled;
                }
            }
        },

        _contentIsLargerThanViewport: function (orientation) {
            return this._getContentSize(orientation) > this._getViewportSize(orientation);
        },

        _contentPositionToScrollValue: function (contentPosition) {
            var maxOffsetH = this._getMaxOffset(pc.ORIENTATION_HORIZONTAL);
            var maxOffsetV = this._getMaxOffset(pc.ORIENTATION_VERTICAL);

            if (maxOffsetH === 0) {
                _tempScrollValue.x = 0;
            } else {
                _tempScrollValue.x = contentPosition.x / maxOffsetH;
            }

            if (maxOffsetV === 0) {
                _tempScrollValue.y = 0;
            } else {
                _tempScrollValue.y = contentPosition.y / -maxOffsetV;
            }

            return _tempScrollValue;
        },

        _getMaxOffset: function (orientation, contentSize) {
            contentSize = contentSize === undefined ? this._getContentSize(orientation) : contentSize;

            var viewportSize = this._getViewportSize(orientation);

            if (contentSize < viewportSize) {
                return -this._getViewportSize(orientation);
            }

            return viewportSize - contentSize;
        },

        _getMaxScrollValue: function (orientation) {
            return this._contentIsLargerThanViewport(orientation) ? 1 : 0;
        },

        _getScrollbarHandleSize: function (axis, orientation) {
            var viewportSize = this._getViewportSize(orientation);
            var contentSize = this._getContentSize(orientation);

            if (Math.abs(contentSize) < 0.001) {
                return 1;
            }

            var handleSize = Math.min(viewportSize / contentSize, 1);
            var overshoot = this._toOvershoot(this._scroll[axis], orientation);

            if (overshoot === 0) {
                return handleSize;
            }

            // Scale the handle down when the content has been dragged past the bounds
            return handleSize / (1 + Math.abs(overshoot));
        },

        _getViewportSize: function (orientation) {
            return this._getSize(orientation, this._viewportReference);
        },

        _getContentSize: function (orientation) {
            return this._getSize(orientation, this._contentReference);
        },

        _getSize: function (orientation, entityReference) {
            if (entityReference.entity && entityReference.entity.element) {
                return entityReference.entity.element[this._getCalculatedDimension(orientation)];
            }

            return 0;
        },

        _getScrollingEnabled: function (orientation) {
            if (orientation === pc.ORIENTATION_HORIZONTAL) {
                return this.horizontal;
            } else if (orientation === pc.ORIENTATION_VERTICAL) {
                return this.vertical;
            }

            console.warn('Unrecognized orientation: ' + orientation);
        },

        _getScrollbarVisibility: function (orientation) {
            if (orientation === pc.ORIENTATION_HORIZONTAL) {
                return this.horizontalScrollbarVisibility;
            } else if (orientation === pc.ORIENTATION_VERTICAL) {
                return this.verticalScrollbarVisibility;
            }

            console.warn('Unrecognized orientation: ' + orientation);
        },

        _getSign: function (orientation) {
            return orientation === pc.ORIENTATION_HORIZONTAL ? 1 : -1;
        },

        _getAxis: function (orientation) {
            return orientation === pc.ORIENTATION_HORIZONTAL ? 'x' : 'y';
        },

        _getCalculatedDimension: function (orientation) {
            return orientation === pc.ORIENTATION_HORIZONTAL ? 'calculatedWidth' : 'calculatedHeight';
        },

        _destroyDragHelper: function () {
            if (this._contentDragHelper) {
                this._contentDragHelper.destroy();
            }
        },

        onUpdate: function () {
            if (this._contentReference.entity) {
                this._updateVelocity();
                this._syncScrollbarEnabledState(pc.ORIENTATION_HORIZONTAL);
                this._syncScrollbarEnabledState(pc.ORIENTATION_VERTICAL);
            }
        },

        _updateVelocity: function () {
            if (!this._isDragging()) {
                if (this.scrollMode === pc.SCROLL_MODE_BOUNCE) {
                    if (this._hasOvershoot('x', pc.ORIENTATION_HORIZONTAL)) {
                        this._setVelocityFromOvershoot(this.scroll.x, 'x', pc.ORIENTATION_HORIZONTAL);
                    }

                    if (this._hasOvershoot('y', pc.ORIENTATION_VERTICAL)) {
                        this._setVelocityFromOvershoot(this.scroll.y, 'y', pc.ORIENTATION_VERTICAL);
                    }
                }

                this._velocity.x *= (1 - this.friction);
                this._velocity.y *= (1 - this.friction);

                if (Math.abs(this._velocity.x) > 1e-4 || Math.abs(this._velocity.y) > 1e-4) {
                    var position = this._contentReference.entity.getLocalPosition();
                    position.x += this._velocity.x;
                    position.y += this._velocity.y;
                    this._contentReference.entity.setLocalPosition(position);

                    this._setScrollFromContentPosition(position);
                }
            }
        },

        _hasOvershoot: function (axis, orientation) {
            return Math.abs(this._toOvershoot(this.scroll[axis], orientation)) > 0.001;
        },

        _toOvershoot: function (scrollValue, orientation) {
            var maxScrollValue = this._getMaxScrollValue(orientation);

            if (scrollValue < 0) {
                return scrollValue;
            } else if (scrollValue > maxScrollValue) {
                return scrollValue - maxScrollValue;
            }

            return 0;
        },

        _setVelocityFromOvershoot: function (scrollValue, axis, orientation) {
            var overshootValue = this._toOvershoot(scrollValue, orientation);
            var overshootPixels = overshootValue * this._getMaxOffset(orientation) * this._getSign(orientation);

            if (Math.abs(overshootPixels) > 0) {
                // 50 here is just a magic number – it seems to give us a range of useful
                // range of bounceAmount values, so that 0.1 is similar to the iOS bounce
                // feel, 1.0 is much slower, etc. The + 1 means that when bounceAmount is
                // 0, the content will just snap back immediately instead of moving gradually.
                this._velocity[axis] = -overshootPixels / (this.bounceAmount * 50 + 1);
            }
        },

        _setVelocityFromContentPositionDelta: function (position) {
            if (this._prevContentDragPosition) {
                this._velocity.sub2(position, this._prevContentDragPosition);
                this._prevContentDragPosition.copy(position);
            } else {
                this._velocity.set(0, 0, 0);
                this._prevContentDragPosition = position.clone();
            }
        },

        _setScrollFromContentPosition: function (position) {
            var scrollValue = this._contentPositionToScrollValue(position);

            if (this._isDragging()) {
                scrollValue = this._applyScrollValueTension(scrollValue);
            }

            this._onSetScroll(scrollValue.x, scrollValue.y, false);
        },

        // Create nice tension effect when dragging past the extents of the viewport
        _applyScrollValueTension: function (scrollValue) {
            var max;
            var overshoot;
            var factor = 1;

            max = this._getMaxScrollValue(pc.ORIENTATION_HORIZONTAL);
            overshoot = this._toOvershoot(scrollValue.x, pc.ORIENTATION_HORIZONTAL);
            if (overshoot > 0) {
                scrollValue.x = max + factor * Math.log10(1 + overshoot);
            } else if (overshoot < 0) {
                scrollValue.x = -factor * Math.log10(1 - overshoot);
            }

            max = this._getMaxScrollValue(pc.ORIENTATION_VERTICAL);
            overshoot = this._toOvershoot(scrollValue.y, pc.ORIENTATION_VERTICAL);

            if (overshoot > 0) {
                scrollValue.y = max + factor * Math.log10(1 + overshoot);
            } else if (overshoot < 0) {
                scrollValue.y = -factor * Math.log10(1 - overshoot);
            }

            return scrollValue;
        },

        _isDragging: function () {
            return this._contentDragHelper && this._contentDragHelper.isDragging;
        },

        _setScrollbarComponentsEnabled: function (enabled) {
            if (this._scrollbarReferences[pc.ORIENTATION_HORIZONTAL].hasComponent('scrollbar')) {
                this._scrollbarReferences[pc.ORIENTATION_HORIZONTAL].entity.scrollbar.enabled = enabled;
            }

            if (this._scrollbarReferences[pc.ORIENTATION_VERTICAL].hasComponent('scrollbar')) {
                this._scrollbarReferences[pc.ORIENTATION_VERTICAL].entity.scrollbar.enabled = enabled;
            }
        },

        _setContentDraggingEnabled: function (enabled) {
            if (this._contentDragHelper) {
                this._contentDragHelper.enabled = enabled;
            }
        },

        // re-enable useInput flag on any descendent that was disabled
        _enableContentInput: function () {
            while (this._disabledContentInputEntities.length) {
                var e = this._disabledContentInputEntities.pop();
                if (e.element) {
                    e.element.useInput = true;
                }
            }

            this._disabledContentInput = false;
        },

        // disable useInput flag on all descendents of this contentEntity
        _disableContentInput: function () {
            var self = this;
            var _disableInput = function (e) {
                if (e.element && e.element.useInput) {
                    self._disabledContentInputEntities.push(e);
                    e.element.useInput = false;
                }

                var children = e.children;
                var i, l;
                for (i = 0, l = children.length; i < l; i++) {
                    _disableInput(children[i]);
                }
            };

            var contentEntity = this._contentReference.entity;
            if (contentEntity) {
                // disable input recursively for all children of the content entity
                var children = contentEntity.children;
                var i, l = children.length;
                for (i = 0; i < l; i++) {
                    _disableInput(children[i]);
                }
            }

            this._disabledContentInput = true;
        },

        onEnable: function () {
            this._viewportReference.onParentComponentEnable();
            this._contentReference.onParentComponentEnable();
            this._scrollbarReferences[pc.ORIENTATION_HORIZONTAL].onParentComponentEnable();
            this._scrollbarReferences[pc.ORIENTATION_VERTICAL].onParentComponentEnable();
            this._setScrollbarComponentsEnabled(true);
            this._setContentDraggingEnabled(true);

            this._syncAll();
        },

        onDisable: function () {
            this._setScrollbarComponentsEnabled(false);
            this._setContentDraggingEnabled(false);
        },

        onRemove: function () {
            this._toggleLifecycleListeners('off', this.system);
            this._toggleElementListeners('off');
            this._destroyDragHelper();
        }
    });

    Object.defineProperty(ScrollViewComponent.prototype, 'scroll', {
        get: function () {
            return this._scroll;
        },

        set: function (value) {
            this._onSetScroll(value.x, value.y);
        }
    });

    return {
        ScrollViewComponent: ScrollViewComponent
    };
}());

/**
 * @event
 * @name pc.ScrollViewComponent#set:scroll
 * @description Fired whenever the scroll position changes.
 * @param {pc.Vec2} scrollPosition Horizontal and vertical scroll values in the range 0...1.
 */