基於 React 的滾動條方案

看到標題,你必定要問:react

爲何還要再造一個輪子?IScroll很差用嗎?那還有 Better-Scroll啊?api

這兩個庫都不錯,本身平時也用,之因此要作,緣由只有兩個:瀏覽器

  1. 不符合 React 的範式:ui = f(state)

這兩個庫都是跨平臺的,都是直接操做 dom 的,跨平臺不是很差,確定是好,可是在 React 的世界,要處理狀態的同步,一般都是經過狀態或屬性來控制,雖然能夠用個 React 包裹上面兩個庫,提供 React 版本,可是老是以爲不那麼完美。dom

  1. 產品的無理需求

團隊提供的是PC端面向B端的商業產品,須要有很好的交互體驗函數

產品說:系統的滾動條能不能改改樣式?工具

我說:能夠改,Chrome 能夠改,可是 FireFox 等改不了,oop

產品說:能鼠標 Hover 的時候變大?性能

我說:我試試,Edeg 原本身就支持,Chrome 努努力也行,其餘的好像不行動畫

產品說:你看這裏表格裏面的滾動條,能不能拿到瀏覽器邊上來ui

我說:我靠,這個表格是在內部啊,離瀏覽器邊上隔着幾座山,他是個單獨的組件

產品說:我怎麼拖拽頁面,就讓頁面滾動,不用拖滾動條

我說:移動端觸摸屏能夠,PC 上你能夠用觸摸板

產品說:個人觸摸板咋不起做用

我說:Mac 上能夠,你這 TinkPad 觸摸板好像得安裝個驅動,你能夠用鼠標滾輪

好了,綜上,咱們的產品需求你明白了嗎?下面我先寫一條本身的感悟:

若是你想成長,那麼在面對產品經理的無理需求的時候,你要拒絕,就要作到心安理得的拒絕

事實上,拒絕很容易,老是有理由的,成本也是最低的,可是心裏老是以爲,這個確定能夠實現,惋惜時間成本有點高,bug 這麼多,改不過來,要實現這個,怎麼不得一兩天?一兩天都未必夠,說不許有什麼問題

固然,我仍是是拒絕了產品,集中精力改阻斷性的 bug,而後,趁着週末,好好構思了下這個滾動條該怎麼作,要是答應了產品,最後作不出來,就糗大了,作出來,算是給產品的驚喜,雖然他意識不到這個有多坑……

設計目標

  1. 貼近原生,易用,易於從默認滾動條切換到新的滾動條

原生寫法:

<div className="container"
    style={{
        width: 500,
        height: 400,
        overflow:'auto'
    }}
    onScroll={onScroll}
>
    <div className="content" ref="content" style={{
        width: 1000,
        height: 800,
    }}>
        {content}
    </div>
</div>
複製代碼

只須要修改容器的標籤,替換後:

<Scroll className="container"
    style={{
        width: 500,
        height: 400,
        overflow:'auto'
    }}
    onScroll={onScroll}
>
    <div className="content" ref="content" style={{
        width: 1000,
        height: 800,
    }}>
        {content}
    </div>
</Scroll>
複製代碼
  1. onScroll 接口和原生保持一致,不影響原有業務邏輯
export interface IScrollEvent {
    target: {
        scrollLeft: number;
        scrollTop: number;
    };
}
export interface IScrollProps{
    /** * 滾動條距離左側的距離 */
    scrollLeft?: number;
    /** * 滾動條距離頂部距離 */
    scrollTop?: number;
    onScroll?: (e: IScrollEvent) => void;
}
複製代碼
  1. 支持 scrollLeftscrollTop 屬性修改滾動條位置,同時對象實例也提供 以下屬性,兼容原生 DOM API
export interface IScroll {
    scrollLeft: number;
    scrollTop: number;
    /** * 滾動到指定位置 */
    scrollTo: (left: number, top: number) => void;
    /** * 滾動相對距離 */
    scrollBy: (left: number, top: number) => void;
    /** * 從新計算滾動區域 */
    refresh: () => void;
}

複製代碼

面對的問題

  1. 如何讓元素移動

可使用 絕對定位 或者 transform ,transform 是首選,由於能夠支持 gpu 加速,處理滾動動畫性能會好一些

  1. 如何支持拖拽

固然是監聽 mousedownmousemovemouseup,計算鼠標移動的方向,和相對距離,而後肯定元素的位置,移動端要監聽 touchstart, touchmove, touchend事件

  1. 如何支持觸摸板

查了一下,PC 觸摸板能夠出發 onwheel 事件,就是鼠標滾輪滾動事件,靠這個能夠支持觸摸板,個人 ThinkPad 也能夠支持。

  1. 如何作滾動動畫

若是隻是用滾動條,能夠不用動畫,可是拖拽的時候,要有個滾屏動畫,好比CSS3裏的 ease-in ease-out動畫,能夠用setInterval 或者 requestAnimationFrame api 來作,能夠先作個勻減速直線運動,其餘的動畫再說

  1. 若是支持 ui = f(state) 範式,頻繁修改 state,從新渲染,是否有性能問題?

相比於直接修改 dom ,性能確定是有折扣的,不過在接受的範圍內

上面的問題基本都是能夠解決,沒有阻斷性的問題,下面就是實現:

實現

拖拽移動

鼠標拖拽通常使用 onMouseDown onMouseMove onMouseUp 事件,大致流程以下:

  1. onMouseDown 事件中記錄鼠標初始位置 pointStart ,爲 document 註冊 mousemovemouseup 事件
  2. onMouseMove 事件中鼠標移動,記錄鼠標當前位置 pointEnd,減去 popointEnd - pointStart 獲得鼠標的偏移量,設置 secrollLeft ,頁面滾動。
  3. onMouseUp 事件中,獲取鼠標的 即時速度,若是速度爲 0 ,那麼終止移動,若是速度大於 0,執行滾動動畫,移除 documentmousemovemouseup 事件

這裏沒有給滾動區域的根節點加 mousemovemouseup 事件,給 document 加鼠標的 mousemovemouseup 事件,由於鼠標的移動區間可能會超過滾動區域,若是超過滾動區域這兩個事件就再也不執行了

即時速度 計算

鼠標擡起之後,須要知道移動的速度,而後以這個速度作減速運動,因此須要計算 即時速度 ,這裏不能用平均速度。

計算 即時速度 須要知道 距離時間,鼠標點擊之後,經過 setInverval 計時器,每 100ms 記錄鼠標的的位置和時間戳,鼠標擡起之後,終止計算,獲取當前位置和時間,和歷史的位置和時間戳作差,獲得最後 100ms 內的速度,計算以下:

/** * 啓動即時速度計算 */
startCaclRealV = () => {
    const me = this;
    const t = _REAL_VELOCITY_TIMESPAN;
    const timer = setInterval(() => {
        if (!me.isDraging) {
            clearInterval(timer);
            return;
        }
        if (!me.lastPos) {
            me.lastTime = Date.now();
            me.lastPos = me.endPoint;
            return;
        }
        me.lastTime = Date.now();
        me.lastPos = me.endPoint;
    }, t);
    return {
        destroy() {
            clearInterval(timer);
        },
    }
}
/** * 計算即時速度 */
caclRealV = () => {
    const me = this;
    if (!me.lastPos) {
        return {
            realXVelocity:0,
            realYVelocity:0
        }
    }
    const time = (Date.now() - me.lastTime) / 1000;
    const xdist = Math.abs(me.endPoint.x - me.lastPos.x);
    const ydist = Math.abs(me.endPoint.y - me.lastPos.y);
    return {
        realXVelocity:caclVelocity(xdist, time),
        realYVelocity:caclVelocity(ydist, time),
    }
}

複製代碼

滾動動畫

鼠標擡起之後,以 即時速度 開始作減速運動,這裏能夠利用緩動函數計算位置,設置 scrollLeft ,我這裏使用了勻減速運動,使用 requestAnimationFrame 執行動畫循環,利用 transform: translate3d(0,${indicateTop}px,0)設置偏移,能夠啓動 pgu 加速,

代碼參考:

import { TDirection, TPoint } from './types'

/** * 動畫執行函數 * @param v 速度 像素/秒 * @param a 減速度 像素/秒平方 * @param onMove 回調函數,返回移動距離 * @param onEnd 回調函數,終止動畫 */
export const animate = (v: number, a: number, onMove: (dist) => boolean, onEnd: () => void): { destroy: () => void } => {
    const t = 16;// ms
    const start = Date.now();
    return loopByFrame(t, () => {
        const time = (Date.now() - start) / 1000;
        if (time === 0) {
            return true;
        }
        const dist = move(v, a, time);
        if (dist === 0) {
            return false;
        }
        return onMove(dist);
    }, onEnd);
}

/** * 利用 requestAnimationFrame 執行動畫循環 * @param duration 動畫時間間隔,使用 requestAnimationFrame 不須要設置 * @param onMove 動畫執行函數 * @param onEnd 動畫終止函數 */
export const loopByFrame = (duration = 16, onMove = () => true, onEnd = () => void 0): { destroy: () => void } => {

    let animateFrame;
    function step(func, end = () => void 0) {
        if (!func) {
            end();
            return;
        }

        if (!func()) {
            destroy();
            end();
            return;
        }

        animateFrame = window.requestAnimationFrame(() => {
            step(func, end);
        });
    }
    function destroy() {
        if (animateFrame) {
            window.cancelAnimationFrame(animateFrame);
        }
    }

    step(onMove, onEnd);

    return {
        destroy,
    }
}

/** * 利用 setInterval 執行函數循環 * @param duration 時間間隔 * @param cb 回調函數 * @param onEnd 終止函數 */
export const loopByInterval = (duration = 16, cb = () => true, onEnd = () => void 0): { destroy: () => void } => {
    const timer = setInterval(() => {
        if (!cb()) {
            clearInterval(timer);
            onEnd();
        }
    }, duration);
    return {
        destroy() {
            clearInterval(timer);
            onEnd();
        },
    }
}

/** * 計算以速度 v ,減速度 a,運動 time 時間的距離 * @param v 速度 * @param a 減速度 * @param time 時間 */
export const move = (v: number, a: number, time: number) => {
    // 獲取下一時刻速度,若是速度爲 0 終止
    const nextV = caclNextVelocity(v, a, time);
    if (nextV <= 0) {
        return 0;
    }
    // 計算下一刻的距離
    const dist = caclDist(v, time, a);
    return dist;
}

/** * 計算滾動方向,暫時只支持橫向滾動 * @param start 起始點 * @param end 終點 */
export const caclDirection = (start: TPoint, end: TPoint): TDirection => {
    const xLen = (end.x - start.x);
    const yLen = (end.y - start.y);
    if (Math.abs(xLen) > Math.abs(yLen)) {
        return xLen > 0 ? 'right' : 'left';
    } else {
        return yLen > 0 ? 'bottom' : 'top';
    }
}

/** * 減速直線運動公式,計算距離 * @param v 速度 * @param t 時間 單位秒 * @param a 加速度 */
export const caclDist = (v: number, t: number, a: number) => {
    return v * t - (a * t * t) / 2;
}

/** * 計算速度 * @param v0 初始速度 * @param a 加速度 * @param t 時間 */
export const caclNextVelocity = (v0: number, a: number, t: number) => {
    return v0 - a * t;
}

/** * 計算速度 * @param dist 距離 單位像素 * @param time 時間 單位秒 */
export const caclVelocity = (dist: number, time: number) => {
    if (time <= 0) {
        return 0;
    }
    return dist / time;
}

複製代碼

滾動條同步

碰到頁面裏須要多個區域滾動條同步到狀況,可使用這個模式,好比咱們系統中,表頭,表體,工具欄須要進行同步的狀況

滾動條同步原本咱們使用了系統自帶的滾動條的 scrollLeft 屬性進行同步,可是會很卡頓,如今使用了 Scroll 組件,利用 CSS3 的transform 來進行同步,效果好不少。

示例代碼:

import React,{Component} from 'react';

class Demo extends Component{
    constructor(props,context){
        super(props,context);
        const me=this;
        me.state={
            scrollLeft:0,
            scrollTop:0,
        }
    }
    onScroll=(e)=>{
        const me=this;
        me.setState({
            scrollLeft:e.target.scrollLeft,
            scrollTop:e.target.scrollTop
        });        
    }
    render(){
        const me=this;
        const {
            scrollLeft,
            scrollTop
        }=me.state;
        return (            
            <Scroll
                scrollLeft={scrollLeft} 
                scrollTop={scrollTop}
                className="container"
                style={{ width: 500, height: 400,}}
                onScroll={me.onScroll}
            >
                <div className="content" ref="content" 
                    style={{width: 1000,height: 800}}>
                    
                </div>
            </Scroll>      
             <Scroll
                scrollLeft={scrollLeft}
                scrollTop={scrollTop}
                className="container"
                style={{width: 500,height: 400,}}
                onScroll={me.onScroll}
            >
                <div className="content" ref="content" 
                    style={{width: 1000,height: 800,}}>
                    
                </div>
            </Scroll>            
        )
    }
}
複製代碼

效果展現

滾動列表

滾動同步

滾動同步

最後

本文嘗試自行實現滾動條來解決 PC 端各個瀏覽器滾動條行爲不一致的問題,兼容原生的 API,能作到無縫切換,總體實現難度中等,主要實現緩動動畫上須要注意一些,對有些問題是否是有更好的辦法?

  1. 即時速度 計算,是否是還有更好的方法?
  2. 文中動畫採用的是 勻減速直線運動 ,後續是否是能夠提供豐富的動畫知足需求?
  3. 目前暫不支持移動端,後續是否是能夠支持移動端?
相關文章
相關標籤/搜索