scroll.vue 11.8 KB
<template>
    <div :class="wrapClasses" style="touch-action: none;">
        <div
            :class="scrollContainerClasses"
            :style="{height: height + 'px'}"
            @scroll="handleScroll"
            @wheel="onWheel"
            @touchstart="onPointerDown"
            ref="scrollContainer"
        >
            <div :class="loaderClasses" :style="{paddingTop: wrapperPadding.paddingTop}" ref="toploader">
                <loader :text="localeLoadingText" :active="showTopLoader"></loader>
            </div>
            <div :class="slotContainerClasses" ref="scrollContent">
                <slot></slot>
            </div>
            <div :class="loaderClasses" :style="{paddingBottom: wrapperPadding.paddingBottom}" ref="bottomLoader">
                <loader :text="localeLoadingText" :active="showBottomLoader"></loader>
            </div>
        </div>
    </div>
</template>
<script>
    import throttle from 'lodash.throttle';
    import loader from './loading-component.vue';
    import { on, off } from '../../utils/dom';
    import Locale from '../../mixins/locale';

    const prefixCls = 'ivu-scroll';
    const dragConfig = {
        sensitivity: 10,
        minimumStartDragOffset: 5, // minimum start drag offset
    };

    const noop = () => Promise.resolve();

    export default {
        name: 'Scroll',
        mixins: [ Locale ],
        components: {loader},
        props: {
            height: {
                type: [Number, String],
                default: 300
            },
            onReachTop: {
                type: Function
            },
            onReachBottom: {
                type: Function
            },
            onReachEdge: {
                type: Function
            },
            loadingText: {
                type: String
            },
            distanceToEdge: [Number, Array]
        },
        data() {
            const distanceToEdge = this.calculateProximityThreshold();
            return {
                showTopLoader: false,
                showBottomLoader: false,
                showBodyLoader: false,
                lastScroll: 0,
                reachedTopScrollLimit: true,
                reachedBottomScrollLimit: false,
                topRubberPadding: 0,
                bottomRubberPadding: 0,
                rubberRollBackTimeout: false,
                isLoading: false,
                pointerTouchDown: null,
                touchScroll: false,
                handleScroll: () => {},
                pointerUpHandler: () => {},
                pointerMoveHandler: () => {},

                // near to edge detectors
                topProximityThreshold: distanceToEdge[0],
                bottomProximityThreshold: distanceToEdge[1]
            };
        },
        computed: {
            wrapClasses() {
                return `${prefixCls}-wrapper`;
            },
            scrollContainerClasses() {
                return `${prefixCls}-container`;
            },
            slotContainerClasses() {
                return [
                    `${prefixCls}-content`,
                    {
                        [`${prefixCls}-content-loading`]: this.showBodyLoader
                    }
                ];
            },
            loaderClasses() {
                return `${prefixCls}-loader`;
            },
            wrapperPadding() {
                return {
                    paddingTop: this.topRubberPadding + 'px',
                    paddingBottom: this.bottomRubberPadding + 'px'
                };
            },
            localeLoadingText () {
                if (this.loadingText === undefined) {
                    return this.t('i.select.loading');
                } else {
                    return this.loadingText;
                }
            },
        },
        methods: {
            // just to improve feeling of loading and avoid scroll trailing events fired by the browser
            waitOneSecond() {
                return new Promise(resolve => {
                    setTimeout(resolve, 1000);
                });
            },

            calculateProximityThreshold(){
                const dte = this.distanceToEdge;
                if (typeof dte == 'undefined') return [20, 20];
                return Array.isArray(dte) ? dte : [dte, dte];
            },

            onCallback(dir) {
                this.isLoading = true;
                this.showBodyLoader = true;
                if (dir > 0) {
                    this.showTopLoader = true;
                    this.topRubberPadding = 20;
                } else {
                    this.showBottomLoader = true;
                    this.bottomRubberPadding = 20;

                    // to force the scroll to the bottom while height is animating
                    let bottomLoaderHeight = 0;
                    const container = this.$refs.scrollContainer;
                    const initialScrollTop = container.scrollTop;
                    for (let i = 0; i < 20; i++) {
                        setTimeout(() => {
                            bottomLoaderHeight = Math.max(
                                bottomLoaderHeight,
                                this.$refs.bottomLoader.getBoundingClientRect().height
                            );
                            container.scrollTop = initialScrollTop + bottomLoaderHeight;
                        }, i * 50);
                    }
                }

                const callbacks = [this.waitOneSecond(), this.onReachEdge ? this.onReachEdge(dir) : noop()];
                callbacks.push(dir > 0 ? this.onReachTop ? this.onReachTop() : noop() : this.onReachBottom ? this.onReachBottom() : noop());

                let tooSlow = setTimeout(() => {
                    this.reset();
                }, 5000);

                Promise.all(callbacks).then(() => {
                    clearTimeout(tooSlow);
                    this.reset();
                });
            },

            reset() {
                [
                    'showTopLoader',
                    'showBottomLoader',
                    'showBodyLoader',
                    'isLoading',
                    'reachedTopScrollLimit',
                    'reachedBottomScrollLimit'
                ].forEach(prop => (this[prop] = false));

                this.lastScroll = 0;
                this.topRubberPadding = 0;
                this.bottomRubberPadding = 0;
                clearInterval(this.rubberRollBackTimeout);

                // if we remove the handler too soon the screen will bump
                if (this.touchScroll) {
                    setTimeout(() => {
                        off(window, 'touchend', this.pointerUpHandler);
                        this.$refs.scrollContainer.removeEventListener('touchmove', this.pointerMoveHandler);
                        this.touchScroll = false;
                    }, 500);
                }
            },

            onWheel(event) {
                if (this.isLoading) return;

                // get the wheel direction
                const wheelDelta = event.wheelDelta ? event.wheelDelta : -(event.detail || event.deltaY);
                this.stretchEdge(wheelDelta);
            },

            stretchEdge(direction) {
                clearTimeout(this.rubberRollBackTimeout);

                // check if set these props
                if (!this.onReachEdge) {
                    if (direction > 0) {
                        if (!this.onReachTop) return;
                    } else {
                        if (!this.onReachBottom) return;
                    }
                }

                // if the scroll is not strong enough, lets reset it
                this.rubberRollBackTimeout = setTimeout(() => {
                    if (!this.isLoading) this.reset();
                }, 250);

                // to give the feeling its ruberish and can be puled more to start loading
                if (direction > 0 && this.reachedTopScrollLimit) {
                    this.topRubberPadding += 5 - this.topRubberPadding / 5;
                    if (this.topRubberPadding > this.topProximityThreshold) this.onCallback(1);
                } else if (direction < 0 && this.reachedBottomScrollLimit) {
                    this.bottomRubberPadding += 6 - this.bottomRubberPadding / 4;
                    if (this.bottomRubberPadding > this.bottomProximityThreshold) this.onCallback(-1);
                } else {
                    this.onScroll();
                }
            },

            onScroll() {
                if (this.isLoading) return;
                const el = this.$refs.scrollContainer;
                const scrollDirection = Math.sign(this.lastScroll - el.scrollTop); // IE has no Math.sign, check that webpack polyfills this
                const displacement = el.scrollHeight - el.clientHeight - el.scrollTop;

                const topNegativeProximity = this.topProximityThreshold < 0 ? this.topProximityThreshold : 0;
                const bottomNegativeProximity = this.bottomProximityThreshold < 0 ? this.bottomProximityThreshold : 0;
                if (scrollDirection == -1 && displacement + bottomNegativeProximity <= dragConfig.sensitivity) {
                    this.reachedBottomScrollLimit = true;
                } else if (scrollDirection >= 0 && el.scrollTop + topNegativeProximity <= 0) {
                    this.reachedTopScrollLimit = true;
                } else {
                    this.reachedTopScrollLimit = false;
                    this.reachedBottomScrollLimit = false;
                    this.lastScroll = el.scrollTop;
                }
            },

            getTouchCoordinates(e) {
                return {
                    x: e.touches[0].pageX,
                    y: e.touches[0].pageY
                };
            },

            onPointerDown(e) {
                // we just use scroll and wheel in desktop, no mousedown
                if (this.isLoading) return;
                if (e.type == 'touchstart') {
                    // if we start do touchmove on the scroll edger the browser will scroll the body
                    // by adding 5px margin on pointer down we avoid this behaviour and the scroll/touchmove
                    // in the component will not be exported outside of the component
                    const container = this.$refs.scrollContainer;
                    if (this.reachedTopScrollLimit) container.scrollTop = 5;
                    else if (this.reachedBottomScrollLimit) container.scrollTop -= 5;
                }
                if (e.type == 'touchstart' && this.$refs.scrollContainer.scrollTop == 0)
                    this.$refs.scrollContainer.scrollTop = 5;

                this.pointerTouchDown = this.getTouchCoordinates(e);
                on(window, 'touchend', this.pointerUpHandler);
                this.$refs.scrollContainer.parentElement.addEventListener('touchmove', e => {
                    e.stopPropagation();
                    this.pointerMoveHandler(e);
                }, {passive: false, useCapture: true});
            },

            onPointerMove(e) {
                if (!this.pointerTouchDown) return;
                if (this.isLoading) return;

                const pointerPosition = this.getTouchCoordinates(e);
                const yDiff = pointerPosition.y - this.pointerTouchDown.y;

                this.stretchEdge(yDiff);

                if (!this.touchScroll) {
                    const wasDragged = Math.abs(yDiff) > dragConfig.minimumStartDragOffset;
                    if (wasDragged) this.touchScroll = true;
                }
            },

            onPointerUp() {
                this.pointerTouchDown = null;
            }
        },
        created() {
            this.handleScroll = throttle(this.onScroll, 150, {leading: false});
            this.pointerUpHandler = this.onPointerUp.bind(this); // because we need the same function to add and remove event handlers
            this.pointerMoveHandler = throttle(this.onPointerMove, 50, {leading: false});
        }
    };

</script>