摘要:而之所以彈出后繼續滑動手指始終不松開,仍能看到頁面在滾動,這是因為這是瀏覽器的默認行為,并且過程的發生時刻早于,所以在隊列中沒法阻塞它。
前言
上一篇為了解釋移動端web的事件和點擊穿透問題,我做了一個彈出框做例子,見demo。現在請把關注點轉移到彈出層本身上來,我使用fix定位將它定在屏幕中間,滾動屏幕時發現問題沒有,底層元素還是在滾動,只是彈出層在屏幕正中間而且周圍有遮罩。所以我們就“滾動”這件事詳細說說,可能存在哪些滾動需求。
頁面滾動原理在PC上網頁滾動主要靠鼠標滾輪,其次按“上”“下”鍵也能滾動頁面,還可以按“空格”“Page Down/Up”以及“HOME”鍵,或者直接點擊或拖動滾動條也能滾動頁面。那么我們來做個實驗,看這些事件的發生順序是怎樣的。
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事件會先觸發,然后也是scroll。
PC上沒啥問題,那來看看手機端的表現。
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很詭異的。
當慢慢滑時,只會 alert touchstart,然后就沒有了。而快速滑時,alert touchstart 然后 alert scroll。這是因為alert框會阻塞事件響應,當touchstart后還沒來的及滑動就已經彈出alert了,整個事件線程就被中斷了,所以就不會響應scroll了。而當彈出alert后繼續滑動(從開始到現在手指始終不松開),然后再松開手指,我們會發現 alert touchstart 后又 alert scroll。為什么alert又沒中斷事件線程呢?
我們知道PC上的alert框是會中斷整個頁面的,即除非你先點“確定”,否則頁面上的任何操作都是無效的,即整個用戶界面被“卡住”了。而在手機上,由于觸摸事件的連貫性,我猜測是這樣的。當手機上彈出alert時是阻塞其他事件的,但由于手指始終沒松開,所以整個觸摸過程還在繼續。一邊是alert的阻塞性,一邊是前一輪的觸摸過程還未結束,由于js單線程的特性,所有事件在用戶界面上的響應都是要進入隊列處理的,然后才會在界面上體現出來。因為觸摸過程是先發生的,它仍未結束,而alert是后發生的,所以alert并不能阻塞當前還未結束的觸摸過程。因此只要不松開手指,繼續滑動,最后再松開手指,alert touchstart 后還會 alert scroll。
那么還有個問題,為什么不會 alert touchmove 和 alert touchend 呢?我們繼續做實驗,依次把 touchstart 和 touchmove 的 alert 語句注釋掉,看看表現結果。
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沒法阻塞它。
以上只是我的猜測,有誰知道具體細節的請告訴我~ 手指不松開時,這個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 = $(""); $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手機模擬器)
禁用事件根據上面頁面滾動原理我們做的實驗,很明顯可以把滾動涉及到的事件干掉,這樣當然不會滾動了。
// 記錄原來的事件函數,以便恢復 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 定位應該作用在 的一級子元素,而彈出層的包裹元素也是 的一級子元素,于是 改進后的 demo 如下
現在“頁面底部”這幾個字不會頂上來了,但是滑動一段距離后再打開彈出層時的頁面底層還是會抖動,這個暫時也想不出很好的解決方案
最后感謝葉小釵,最近一直在看他關于移動端事件原理的博客,有點學會了他那種 代碼實驗 -> 猜測解釋 -> 驗證原理 -> 改進問題 這樣的學習方法。本文也花了很大力氣寫代碼實驗,疏漏之處望多多指正,謝謝耐心的看完
參考資料知乎上的一個討論
本文最早發表在我的個人博客上,轉載請保留出處 http://jsorz.cn/blog/2015/10/popup-scroll-tricks.html
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/86052.html
摘要:問題眾所周知,移動端當有遮罩背景和彈出層時,在屏幕上滑動能夠滑動背景下面的內容,這就是著名的滾動穿透問題之前搜索了一圈,找到下面兩種方案之頁面彈出層上將添加到上,禁用和的滾動條但是這個方案有兩個缺點由于和的滾動條都被禁用,彈出層 問題 眾所周知,移動端當有 fixed 遮罩背景和彈出層時,在屏幕上滑動能夠滑動背景下面的內容,這就是著名的滾動穿透問題 之前搜索了一圈,找到下面兩種方案 c...
摘要:頁面中經常會遇到彈出層的部件,當彈出層激活時覆蓋整個頁面,且背景部分不能滾動。 頁面中經常會遇到彈出層的部件,當彈出層激活時覆蓋整個頁面,且背景部分不能滾動。實現起來有以下要點: 彈出層position設置為fixed,四個定位錨點均設為0; 激活彈出層時給html和body設置overflow: hidden;; 以下是實踐: codepen 為了讓徹底禁止滾動,還可以在mous...
摘要:頁面中經常會遇到彈出層的部件,當彈出層激活時覆蓋整個頁面,且背景部分不能滾動。 頁面中經常會遇到彈出層的部件,當彈出層激活時覆蓋整個頁面,且背景部分不能滾動。實現起來有以下要點: 彈出層position設置為fixed,四個定位錨點均設為0; 激活彈出層時給html和body設置overflow: hidden;; 以下是實踐: codepen 為了讓徹底禁止滾動,還可以在mous...
摘要:頁面中經常會遇到彈出層的部件,當彈出層激活時覆蓋整個頁面,且背景部分不能滾動。 頁面中經常會遇到彈出層的部件,當彈出層激活時覆蓋整個頁面,且背景部分不能滾動。實現起來有以下要點: 彈出層position設置為fixed,四個定位錨點均設為0; 激活彈出層時給html和body設置overflow: hidden;; 以下是實踐: codepen 為了讓徹底禁止滾動,還可以在mous...
閱讀 3119·2021-09-28 09:42
閱讀 3457·2021-09-22 15:21
閱讀 1129·2021-07-29 13:50
閱讀 3580·2019-08-30 15:56
閱讀 3374·2019-08-30 15:54
閱讀 1201·2019-08-30 13:12
閱讀 1180·2019-08-29 17:03
閱讀 1203·2019-08-29 10:59