由彈出層引起對滾動原理的討論

前言

上一篇爲了解釋移動端web的事件和點擊穿透問題,我作了一個彈出框作例子,見demo。如今請把關注點轉移到彈出層自己上來,我使用fix定位將它定在屏幕中間,滾動屏幕時發現問題沒有,底層元素仍是在滾動,只是彈出層在屏幕正中間並且周圍有遮罩。因此咱們就「滾動」這件事詳細說說,可能存在哪些滾動需求。css

頁面滾動原理

在PC上網頁滾動主要靠鼠標滾輪,其次按「上」「下」鍵也能滾動頁面,還能夠按「空格」「Page Down/Up」以及「HOME」鍵,或者直接點擊或拖動滾動條也能滾動頁面。那麼咱們來作個實驗,看這些事件的發生順序是怎樣的。html

document.addEventListener('scroll', function(){
    alert('document scroll');
});

window.addEventListener('scroll', function(){
    alert('window scroll');
});

window.addEventListener('mousewheel', function(){
    alert('window mousewheel');
});

window.addEventListener('keydown', function(e){
    if(37 <= e.keyCode && e.keyCode <= 40 || e.keyCode == 32){
        alert('keydown ' + e.keyCode);
    }
});

能夠得知,當經過鼠標滾輪時,mousewheel事件會先觸發,而後纔是scroll。而事件的listener默認是遵循冒泡的,因此綁在document上的函數會先觸發,而後纔是window上的。同理,當經過按特定的鍵去滾動頁面時,keydown事件會先觸發,而後也是scrolljquery

PC上沒啥問題,那來看看手機端的表現。git

document.addEventListener('scroll', function(){
    alert('document scroll');
});

document.addEventListener('touchstart', function(){
    alert('document touchstart');
});

document.addEventListener('touchmove', function(){
    alert('document touchmove');
});

document.addEventListener('touchend', function(){
    alert('document touchend');
});

按照PC上相似的邏輯,以及前一篇文章中提到的touch事件原理,咱們很容易猜出alert順序是:touchstart -> touchmove -> scroll -> touchend 但這是事件發生的順序,並非alert結果的順序。能夠掃二維碼看看,這個alert很詭異的。github

當慢慢滑時,只會 alert touchstart,而後就沒有了。而快速滑時,alert touchstart 而後 alert scroll。這是由於alert框會阻塞事件響應,當touchstart後還沒來的及滑動就已經彈出alert了,整個事件線程就被中斷了,因此就不會響應scroll了。而當彈出alert後繼續滑動(從開始到如今手指始終不鬆開),而後再鬆開手指,咱們會發現 alert touchstart 後又 alert scroll。爲何alert又沒中斷事件線程呢?web

咱們知道PC上的alert框是會中斷整個頁面的,即除非你先點「肯定」,不然頁面上的任何操做都是無效的,即整個用戶界面被「卡住」了。而在手機上,因爲觸摸事件的連貫性,我猜想是這樣的。當手機上彈出alert時是阻塞其餘事件的,但因爲手指始終沒鬆開,因此整個觸摸過程還在繼續。一邊是alert的阻塞性,一邊是前一輪的觸摸過程還未結束,因爲js單線程的特性,全部事件在用戶界面上的響應都是要進入隊列處理的,而後纔會在界面上體現出來。由於觸摸過程是先發生的,它仍未結束,而alert是後發生的,因此alert並不能阻塞當前還未結束的觸摸過程。所以只要不鬆開手指,繼續滑動,最後再鬆開手指,alert touchstart 後還會 alert scroll。chrome

那麼還有個問題,爲何不會 alert touchmove 和 alert touchend 呢?咱們繼續作實驗,依次把 touchstart 和 touchmove 的 alert 語句註釋掉,看看錶現結果。segmentfault

document.addEventListener('touchstart', function(){
    // alert('document touchstart');
});

document.addEventListener('touchmove', function(){
    alert('document touchmove');
});

document.addEventListener('touchend', function(){
    alert('document touchend');
});

去掉 alert touchstart 後發現只彈出 alert touchmove,我猜想是由於 touchstart / touchmove / touchend 都是在同一輪觸摸過程當中的,因爲alert的阻塞性,前面解釋了它容許先發生的觸摸(還未鬆開的手指)繼續touch,可是 alert 會阻塞同一輪觸摸過程的其餘事件的響應函數。而之因此alert彈出後繼續滑動手指(始終不鬆開),仍能看到頁面在滾動,這是由於這是瀏覽器的默認行爲,而且touch過程的發生時刻早於alert,因此在隊列中alert無法阻塞它。windows

以上只是個人猜想,有誰知道具體細節的請告訴我~ 手指不鬆開時,這個alert框的底層滾動問題正好也迎合了本文一開始說的彈出框demo,若是有需求說彈出框出現時必須讓外部不能滾動,該怎麼辦?瀏覽器

滾動禁用

overflow

咱們常常會寫overflow: hidden這樣的css去讓固定尺寸的元素寫死,這樣就算它的子元素超出了父容器的尺寸範圍,也不會「溢出來」。借這個道理,咱們能夠在root元素上寫死,這樣body裏面就不會溢出屏幕了,就不會出現滾動條了。

html, body{
    overflow: hidden;
}

但隨之又出現了另外一個問題,若是頁面原來是有滾動條的,在windows下的瀏覽器中滾動條是會佔據必定寬度的(chrome下是17px,firefox下多是13px),會讓整個viewport的寬度減少一段,看起就像頁面裏的全部元素總體往左偏移一小段。而mac下瀏覽器的滾動條是懸浮在上面的,因此不會佔據頁面上的空間。

這樣的話,windows就哭了。假設頁面本來就是有滾動條的,當咱們打開彈出框時,爲了禁止滾動,root元素被加上overflow: hidden,滾動條消失,底層全部元素就向右偏移一小段。關閉彈出框時,要讓頁面恢復滾動,root元素改爲overflow: auto,滾動條又出現了,底層全部元素又向左偏移一小段。整個體驗很糟糕!

辦法就是在overflow: hidden的同時經過padding-right把滾動條的空間預留出來。那麼如何知道不一樣瀏覽器中滾動條到底佔多寬呢?一般相似判斷當前瀏覽器是否支持某個css屬性或者某些取值,這種跟瀏覽器環境相關的問題,辦法就是試探。用js動態生成一個元素,把你想測試的屬性或值賦在這個元素上,而後把元素append到document中去,最後再經過js去取相應的值,看它到底表現出來是啥。

參考這篇文章,能夠知道

滾動條寬度 = 元素的offsetWidth - 元素border佔據的2倍寬 - 元素的clientWidth

上面公式的前提是,元素具有y軸滾動條。還有種相似辦法是

滾動條寬度 = 不帶滾動條的元素的clientWidth - 爲該元素加上y軸滾動條後的clientWidth

var getScrollbarWidth = function(){
    if(typeof getScrollbarWidth.value === 'undefined'){
        var $test = $('<div></div>');
        $test.css({
            width: '100px',
            height: '1px',
            'overflow-y': 'scroll'
        });

        $('body').append($test);
        getScrollbarWidth.value = $test[0].offsetWidth - $test[0].clientWidth;
        $test.remove();
    }
    return getScrollbarWidth.value;
};

這是根據第一種計算方式寫出的方法,有了這個再配合overflow就能實現頁面滾動的禁用與恢復了。詳細代碼見demo

var disableScroll = function(){
    // body上禁用
    $('body, html').css({
        'overflow': 'hidden',
        'padding-right': getScrollbarWidth() + 'px'
    });
};

var enableScroll = function(){
    $('body, html').css({
        'overflow': 'auto',
        'padding-right': '0'
    });
};

咱們看看錶現結果:PC上很OK,簡單有效;手機上徹底沒卵用!(我是安卓機,注意是真機上無效,而非chrome手機模擬器)

20151010_02.png

禁用事件

根據上面頁面滾動原理咱們作的實驗,很明顯能夠把滾動涉及到的事件幹掉,這樣固然不會滾動了。

// 記錄原來的事件函數,以便恢復
var oldonwheel, oldonmousewheel, oldonkeydown, oldontouchmove;
var isDisabled;

var disableScroll = function(){
    oldonwheel = window.onwheel;
    window.onwheel = preventDefault;

    oldonmousewheel = window.onmousewheel;
    window.onmousewheel = preventDefault;

    oldonkeydown = document.onkeydown;
    document.onkeydown = preventDefaultForScrollKeys;

    oldontouchmove = window.ontouchmove;
    window.ontouchmove = preventDefault;

    isDisabled = true;
};

var enableScroll = function(){
    if(!isDisabled){
        return;
    }

    window.onwheel = oldonwheel;
    window.onmousewheel = oldonmousewheel;
    document.onkeydown = oldonkeydown;

    window.ontouchmove = oldontouchmove;
    isDisabled = false;
};

這裏要注意的是,不一樣瀏覽器上事件到底在window仍是document上,PC上會有一些瀏覽器兼容處理。詳細代碼見demo

一樣看看錶現結果:PC上很粗暴的解決了;手機上也OK

彈出層滾動需求

至此咱們看到,使用overflow可以解決PC上的滾動禁用問題,而禁用與滾動相關的事件可以完全解決PC和手機的問題。那麼有彈出層的話,就應該禁用整個頁面的滾動嗎,若是彈出層內部須要滾動怎麼辦?即咱們有可能面臨這樣的需求:彈出框的內部是能夠滾動的,而彈出層外部和底層元素是不能滾動的。

先看overflow

前面說到給root元素寫上overflow: hidden就能夠禁用滾動,那麼咱們對彈出層這個容器從新寫個overflow: scroll就能夠了。

#popupLayer{
    overflow: scroll;
}

PC上簡單有效,可是一樣手機上不鳥這些。見demo

事件禁用與恢復

咱們把document上的mousewheel事件禁用了,即給它綁上了一個事件函數,只不過事件函數裏將事件發生後的瀏覽器默認行爲阻止了。

function preventDefault(e) {
    e = e || window.event;
    e.preventDefault && e.preventDefault();
    e.returnValue = false;
}

var disableScroll = function(){
    $(document).on('mousewheel', preventDefault);
    $(document).on('touchmove', preventDefault);
};

因而思路就來了,咱們知道瀏覽器裏的事件是遵循冒泡機制的(準確來講是先從root節點由外向內「捕獲」,而後到達目標元素後,事件再由內向外逐層冒泡,關於這個機制請看這篇文章的第一部分,這不是本文的重點)。因此咱們就能夠爲彈出層的元素再綁個一樣的事件,阻止事件冒泡到document上,這樣就不會調用到e.preventDefault()就不會阻止瀏覽器默認的滾動行爲了。

function preventDefault(e) {
    e = e || window.event;
    e.preventDefault && e.preventDefault();
    e.returnValue = false;
}

// 內部可滾
$('#popupLayer').on('mousewheel', stopPropagation);
$('#popupLayer').on('touchmove', stopPropagation);

來看下demo,手機上請看

背景層是不能滾動的,而彈出層妥妥的能夠滾動了!可是發現問題了不,彈出層內部滾動到底部再繼續滾時,會將背景底層的元素一塊兒滾下去了,這尼瑪FUCK

改進的內部滾動

解決問題的思路很清晰,就是判斷滾動邊界,當滾動到達bottom和top時,就阻止滾動就好啦。

function innerScroll(e){
    // 阻止冒泡到document
    // document上已經preventDefault
    stopPropagation(e);

    var delta = e.wheelDelta || e.detail || 0;
    var box = $(this).get(0);

    if($(box).height() + box.scrollTop >= box.scrollHeight){
        if(delta < 0) {
            preventDefault(e);
            return false;
        }
    }
    if(box.scrollTop === 0){
        if(delta > 0) {
            preventDefault(e);
            return false;
        }
    }
    // 會阻止原生滾動
    // return false;
}

$('#popupLayer').on('mousewheel', innerScroll);

代碼很簡單,關於scrollTop scrollHeight等解釋請看這篇文章。這裏惟一要注意的是對鼠標滾動值wheelDelta的獲取可能要作兼容性處理,實在有問題的話可使用jquery-mousewheel去獲取鼠標的滾動量。

上面這段代碼是PC上的判斷滾動邊界的處理,那手機上又該怎麼作的,手機上沒有鼠標,如何獲取到滾動量delta?

IScroll的啓發

我想起「局部滾動」界的大佬——IScroll,能夠去看下源碼,細節很複雜可是大致結構是很清晰的。

_start: function (e) {
    
    this.startX    = this.x;
    this.startY    = this.y;
    this.absStartX = this.x;
    this.absStartY = this.y;
    this.pointX    = point.pageX;
    this.pointY    = point.pageY;

    this._execEvent('beforeScrollStart');
},

_move: function (e) {
    
    var point        = e.touches ? e.touches[0] : e,
        deltaX        = point.pageX - this.pointX,
        deltaY        = point.pageY - this.pointY;

    this.pointX        = point.pageX;
    this.pointY        = point.pageY;

},

這是iscroll中的一小段代碼,這就是獲取touchmove滾動量的辦法。因而咱們就能寫出相似上面innerScroll適用於手機上的判斷滾動邊界的辦法了。

// 移動端touch重寫
var startX, startY;

$('#popupLayer').on('touchstart', function(e){
    startX = e.changedTouches[0].pageX;
    startY = e.changedTouches[0].pageY;
});

// 仿innerScroll方法
$('#popupLayer').on('touchmove', function(e){
    e.stopPropagation();

    var deltaX = e.changedTouches[0].pageX - startX;
    var deltaY = e.changedTouches[0].pageY - startY;

    // 只能縱向滾
    if(Math.abs(deltaY) < Math.abs(deltaX)){
        e.preventDefault();
        return false;
    }

    var box = $(this).get(0);

    if($(box).height() + box.scrollTop >= box.scrollHeight){
        if(deltaY < 0) {
            e.preventDefault();
            return false;
        }
    }
    if(box.scrollTop === 0){
        if(deltaY > 0) {
            e.preventDefault();
            return false;
        }
    }
    // 會阻止原生滾動
    // return false;
});

這裏要注意的是,我加了一條判斷,彈出層內部的滾動只能縱向滾,即 deltaY 要大於 deltaX。由於我發現個bug,當沒有這條判斷時,彈出層內部能夠橫向滾,滾出的都是空白,你們能夠本身試下。還有這裏到底使用e.changedTouches[0]仍是像iscroll裏的e.touches[0]獲取當前滾動的手指,其實都OK,能夠看下這篇文章

最後請看demo手機請掃二維碼,效果棒棒的!

【更新】注:一年前作這個demo時,我手機 ( Meizu Android 4.4.2 ) 上效果是OK的,在 SegmentFault 論壇上不止一我的回覆說上面的方案有問題,有一半機率是不行的,快速滑的時候確定不行。

來自SF網友的方案【更新】

網友 jiehwa 的提到不須要重寫事件那麼麻煩,經過幾個 css屬性 控制便可。

  • 彈出層父元素設置屬性 overflow-y: scroll

  • 彈窗彈出時,用js控制底層元素的 position 屬性置爲 fixed

  • 彈窗關閉時,用js控制底層元素的 position 屬性置爲 static

  • 在 iOS 端,爲了彈窗裏面的滾動效果看起來順滑,須要設置彈窗層的包裹元素屬性:-webkit-overflow-scrolling: touch

css方案的demo(感謝 SegmentFault 網友)

能夠看到有瑕疵,當強行將底層元素置爲 fixed 後,因爲 fixed 定位會讓元素脫離正常的DOM文檔流,因此本來位於頁面底部的元素就一會兒頂上來了。還有當底層元素滑動一段距離後再打開彈出層,底層元素又被 fixed 定位重置了,看着也很彆扭。

仔細閱讀後發現我誤解了,控制底層元素的 fixed 定位應該做用在 <body> 的一級子元素,而彈出層的包裹元素也是 <body> 的一級子元素,因而 改進後的 demo 以下

如今「頁面底部」這幾個字不會頂上來了,可是滑動一段距離後再打開彈出層時的頁面底層仍是會抖動,這個暫時也想不出很好的解決方案

wulian.jpg

最後感謝葉小釵,最近一直在看他關於移動端事件原理的博客,有點學會了他那種 代碼實驗 -> 猜想解釋 -> 驗證原理 -> 改進問題 這樣的學習方法。本文也花了很大力氣寫代碼實驗,疏漏之處望多多指正,謝謝耐心的看完

參考資料

本文最先發表在個人我的博客上,轉載請保留出處 http://jsorz.cn/blog/2015/10/popup-scroll-tricks.html

相關文章
相關標籤/搜索