自定義滾動條的實現思路與關鍵算法

在web開發中,自定義滾動條是個常見的需求,雖然瀏覽器原生的滾動條很強大而且在大多數場景下表現的很好,但某些時候咱們仍然但願修改他的樣式,好比變細一點,或者去掉圓角和軌道,又或者隱藏他們。這些都屬於自定義行爲,本篇文章將介紹自定義滾動條的幾種實現思路,並着重講解最流行的js方案。javascript


上圖是自定義的效果(視頻在轉換時降速了,其實很是快)css

在正文開始前,咱們先統一滾動條各個部分的名稱。java


1、實現思路

實現自定義滾動條的方式不止一種,這裏列出三種方式。react

一、css修改。

這是最簡單的方式,你能夠經過::-webkit-scrollbar這個css僞類選擇器去修改滾動條樣式,包括滾動條軌道、滑塊以及上下箭頭等,但它只支持webkit內核的瀏覽器,而且它不是css標準的一部分,這意味着除了瀏覽器兼容性問題外,未來還可能被瀏覽器廠商刪掉並轉而採用新標準。git

二、自行實現滾動條部分,但scroll行爲交給瀏覽器原生實現。

這種思路的關鍵是不能將容器的overflow設爲hidden,這樣雖然隱藏了滾動條,但也禁止了滾動行爲。因此開發者嘗試將滾動條遮蓋起來,通常經過多個div的嵌套和偏移(偏移量剛好是滾動條的寬度)來實現。遮蓋後再將模擬的滾動條固定在容器右側和底部。以後的關鍵點就是計算模擬滾動條的寬高與位置,而且監聽容器的scroll事件,及時更新滾動條的狀態,若是用戶拖動滾動條,則此時不能依靠原生滾動行爲,須要本身計算實際滾動距離去更新容器的scrollLeft及scrollTop。參考simplebarreact-custom-scrollbarsgithub

該方案有不少優勢,首先你能夠徹底自定義滾動條的樣式而不用考慮兼容性問題,其次它的性價比很是高,絕大多數時間,你使用的是瀏覽器默認的行爲(他們性能優秀並且覆蓋了邊際狀況),只有在用戶拖動滾動條時,才須要手動計算並更新容器的滾動距離。不過該方案也並不是天衣無縫,最大的問題是你須要添加多層div才能覆遮蓋住原生滾動條,這在必定程度上破壞了開發者預先設想的文檔結構。web

三、自行實現滾動行爲與滾動條樣式。

該方案比較複雜,由於滾動行爲一般由三個條件觸發,分別是鼠標滾輪(或觸控板)滑動、鍵盤導航、鼠標拖動(選擇文字時),你得同時監聽這三種事件,同時要考慮兼容問題,由於這三種事件在各個瀏覽器不統一。滾動條部分與方案2相同,這裏再也不贅述。 雖然這個方案很差搞,但正由於徹底自定義,你得以寫出更豐富的滾動邏輯,好比整屏滾動或者增長顏色特效。該方案在社區最爲流行。算法

2、js實現思路(pc端)

這裏會詳細闡述方案3的實現思路。讓咱們從零開始,如今有一個容器,他的子元素高度超過了容器的高度,須要給他添加一個縱向的滾動條,從交互角度出發,能夠分解成如下步驟。
segmentfault

一、監聽容器的mousewheel事件。

經過鼠標滾輪或者觸控板的滑動,瀏覽器會生成mousewheel事件,事件中帶有滾動偏移量,咱們要利用該數值來修改容器的scrollTop以達到滾動效果。這裏的問題是mousewheel不是一個標準事件,各個瀏覽器攜帶不同的事件信息,滾動偏移量也不一樣,因此咱們須要抹平他們的差別。一個好的辦法是將滾動偏移量統一設爲1。瀏覽器

const userAgent = window.navigator.userAgent; 
let isSafari = (userAgent.indexOf('Chrome') === -1) && (userAgent.indexOf('Safari') >= 0);
function standardizedWheel(e) {  
  let wheelEvent = Object.assign({}, e);  
  // vertical 
  if (typeof e.wheelDeltaY !== 'undefined') {    
    // webkit 
    wheelEvent.deltaY = e.wheelDeltaY / 120;  
  } else if (typeof e.VERTICAL_AXIS !== 'undefined' && e.axis === e.VERTICAL_AXIS) {    
    // Firefox < 17 
    wheelEvent.deltaY = -e.detail / 3;  
  }  

  // horizental 
  if (typeof e.wheelDeltaX !== 'undefined') {    
    // webkit 
    if (isSafari) {      
      wheelEvent.deltaX = - (e.wheelDeltaX / 120);    
    } else {      
      wheelEvent.deltaX = e.wheelDeltaX / 120;    
    }  
  } else if (typeof e.HORIZONTAL_AXIS !== 'undefined' && e2.axis === e2.HORIZONTAL_AXIS) {    
    // Firefox < 17 
    wheelEvent.deltaX = -e.detail / 3;  
  } 

  if (wheelEvent.deltaY === 0 && wheelEvent.deltaX === 0 && e.wheelDelta) {    
    // IE 
    wheelEvent.deltaY = e.wheelDelta / 120;    
  }  
  return wheelEvent;
}複製代碼

一般來說,向下滾動偏移量爲-1,反之爲1。

以後咱們設定一個滾動係數(scrollFactor),它能夠是字的行高,也能夠是任意數值,這取決於你但願在一次滾動中通過的像素是多少。而後用它乘以偏移量,做爲最終的滾動偏移量。

let containerDom;
const scrollFactor = 50;
containerDom.addEventListener('mousewheel', (e) => {  
  let wheelEvent = standardizedWheel(e);  
  let scrollTop = containerDom.scrollTop - e.deltaY * scrollFactor;  
  containerDom.scrollTop = scrollTop;
})複製代碼

二、校準滑塊的大小與位置

在縱向的滾動條中,滑塊的高度如何計算呢?若是把內容與滾動條分紅兩個區域,那麼他們的可見區域和可滾動區域的比是相等的。

// 根據 visibleHeight / scrollHeight = sliderHeight / scrollbarHeight 得出
sliderHeight = visbileHeight * scrollbarHeight / scrollHeight複製代碼

接下來解決滑塊的位置,在先前的方法中,咱們已經知道了scrollTop,只須要讓它乘以兩個區域的可滾動高度比便可。

// 滑塊區域與內容區域的比例
sliderRatio = (scrollHeight - visibleHeight) / (scrollbarHeight - sliderHeight)

sliderTop = scrollTop * sliderRatio 複製代碼

咱們將滑塊狀態的計算方式寫成一個函數,隨着頁面滾動scrollTop始終在變化,須要不停地調用它來更新滑塊狀態。另外你要處理好邊界狀況,判斷滾動行爲是否到了可滾動區域的上限,不要讓滾動無休止的下去。

let scrollbar /* 滾動條元素 */
let sliderDom /* 滑塊元素 */
function updateSlider(scrollTop) {
    sliderHeight = containerDom.clientHeight * scrollbar.clientHeight / containerDom.scrollHeight;
    sliderRatio = (scrollbar.clientHeight - sliderDom.clientHeight) / (containerDom.scrollHeight - containerDom.clientHeight);
    sliderTop = scrollTop * sliderRatio;
    // 更新滑塊的高度和位置
    sliderDom.style.height = sliderHeight + 'px';
    sliderDom.style.top = sliderTop + 'px';
}複製代碼

三、滑塊拖拽

本質上咱們能夠把滑塊的狀態做爲容器的衍生狀態來看待,因此只要有容器的scrollTop,滑塊的位置就能肯定。如今咱們使用兼容性更好的mouse事件。當鼠標點擊滑塊時,觸發mousedown,記錄下當時滑塊的位置(pageY),隨後開始mousemove的監聽,在鼠標移動的過程當中,咱們使用新的pageY減去初始pageY,做爲該次滾動的差值moveDelta,得出滑塊滾動的位置 sliderTop = lastedSliderTop + moveDelta。還記得咱們以前提到的公式嗎,稍微改下就得出scrollTop = sliderTop / sliderRatio。以後根據scrollTop校準滑塊的位置便可。

sliderDom.addEventListener('mousedown', (e) => {  
  let lastedPageY = e.pageY;  
  let lastedScrollTop = containerDom.scrollTop * sliderRatio;  
  let scrollTop;  
  document.addEventListener('mousemove', (e) => {    
    let moveDelta = e.pageY - lastedPageY;    
    let sliderTop = lastedScrollTop + moveDelta;    
    scrollTop = sliderTop / sliderRatio;    
    containerDom.scollTop = scrollTop;    
    updateSlider(scrollTop);  
  });
})複製代碼

四、點擊滾動軌道的特定位置

咱們的算法不變,假設用戶在軌道上隨機一個位置點擊,咱們只需得出該位置相對於滾動條的偏移量便可。在現代瀏覽器中,mousedown事件會直接返回給你offsetY,假如沒有就須要簡單算下。咱們須要使用pageY,注意這個屬性是包含文檔的滾動距離的。

// 事實上pageY與scrollY在那些陳舊的瀏覽器也不支持,你能夠參考mdn給出的兼容方案
offsetY = e.pageY - scrollbar.getBoundingClientRect().top - window.scrollY;複製代碼

offsetY減去滑塊的高度就是此次滾動的末端位置,不過瀏覽器一般會定位到滑塊的中心點,咱們也遵照這個原則,只須要除以2便可。如今咱們算出了滑塊的位置,將它除以sliderRatio,就得出 scrollTop = (offsetY - sliderHeight / 2) / sliderRatio

scollbar.addEventListener('mousedown', (e) => {
  if (e.target !== sliderDom) {
    let offsetY = e.pageY - scrollbar.getBoundingClientRect().top - window.scrollY;
    scrollTop = (offsetY - sliderDom.clientHeight / 2) / sliderRatio;
    containerDom.scrollTop = scrollTop;
    updateSlider(scrollTop);
  }
})複製代碼

五、平滑滾動

若是一次滾動直愣愣的到達終點,是否是很生硬?咱們讓他看起來更絲滑一些,這也是原生滾動具備的效果。由於scrollTop屬性沒法用css作動畫,因此只能用js實現。咱們但願滾動在一開始很快,隨着時間推移在快到終點時變慢,因此定義一個緩動函數

// 參數t是時間進度
function easeOutCubic(t) {
  return 1 - Math.pow(1 - t, 3);
}複製代碼

好比滾動開始時間是0,你但願在3s內滾動到終點,那麼他在前2s行動的很快,在最後一秒又降速變慢,固然過程是很平緩的。咱們的目標是根據當前的時間進度,得出滾動距離。

function scrollToSmooth(from/* 起點 */, to/* 終點 */, duration/* 持續時間 */) {  
  let startTime = Date.now();  
  let delta = to - from; 
  function tick(now) {    
    // 計算完成度 
    let completion = (now - startTime) / duration;  
    if (completion < 1) {      
      // 小於1表示沒有完成滾動 
      let newScrollTop = from + delta * easeOutCubic(completion);      
      return {        
        scrollTop: newScrollTop,        
        done: false      
      }    
    }    
    return {      
      scrollTop: to,      
      done: true    
    }  
  }  
  function performScrolling() {    
    let update = tick(Date.now());    
    if (update.done) {      
      return    
    }    
    // 用新的距離更新容器的 
    containerDom.scrollTop = update.newScrollTop;    
    requestAnimationFrame(performScrolling());  
  }  
  //爲了得到性能提高,這裏用requestAnimationFrame執行它。 
  requestAnimationFrame(performScrolling());
}複製代碼

六、滾動條隱藏與顯示

mouseover時,處理好滾動條的display便可。

七、鍵盤導航

容器要響應上下左右,PageUp,PageDown, Home, End按鍵,每一個按鍵有不一樣的滾動offset。難點在鍵盤事件的兼容性上,參考KeyboardEvent和快捷鍵

八、內容選中滾動

這種場景在鼠標選中容器子元素的而且越過容器邊界的時候發生。須要容器監聽mousedown,在mousemove的過程當中檢查鼠標座標是否越過容器邊界,再根據鼠標停留時間作若干次偏移。

3、還須要作什麼

作完了上面這些,你已經搞出一個可用的滾動條了,但這只是爲了講述思路的玩具代碼,並不能用在真實環境。在實際開發中,你可能須要使用面向對象設計來組織你的代碼,並處理好全部事件的回收,此外,你還要當心處理如下問題。

一、容器的resize。你要從新計算滾動條的全部狀態,以保證顯示正確。

二、當心iframe。在鼠標事件通過iframe時會產生各類匪夷所思的問題,若是你不幸使用了它,最簡單的辦法是跨過iframe時銷燬監聽函數,保證內存不泄露。

三、別忘了橫向滾動條。

四、別忘了手機與平板環境下的滾動。

相關文章
相關標籤/搜索