近期在看 CSSOM View 相关的一些东西,为了加深印象,便寻思着如何付诸实践,于是想起之前至少有过几次的类似下面的对话:
产品:这个滚动条样式太丑了,怎么跟 UI 上不一样
我:xx IE 不支持自定义浏览器滚动条样式,我有什么办法啊
产品:你看别人的为啥就可以
我:这个滚动条是人自己实现通过 DOM 模拟的,很麻烦(极尽夸张之能事)的,再说了这年头正经公司谁还用 xx IE 嘛,balabalabala……
正好也试着自定义一下,只知道肯定能做,但是具体实现思路以及能做到什么程度也没有整体把握,但最终实现了一个基本能用的自定义滚动条之后,再回过头来总结一下大致思路(只拿垂直方向上的举例):
- 不需要自定义滚动条的情形(非 IE、scrollHeight 和 clientHeight 相等)
- 屏蔽浏览器自带的滚动条(设置对应方向上的 overflow 为 hidden)
- 创建的 scroll 元素通过 absolute 定位时,需要保证 elm 为定位元素(即 position 的 computed value 为 static 时,需要修改为 relative)
- Track(滑道)需要「吸顶」,即 thumb(滑块)滑动时,track 需要始终保持在 scroll view 的右边,也就是说 elm 进行 scroll 多少距离,track 也要跟着 scroll 多少距离
- 确定 thumb 的长度,可以使其与 elm.clientHeight 的比例等同于 elm.clientHeight 与 elm.scrollHeight 的比例来计算出长度,但这样会出现 thumb 的长度很小的可能,所以浏览器一般都保证了 thumb 有个最小的长度,以及确定 elm scroll 时,thumb 在 track 上对应的 scroll 距离,显然二者是准在一定的比例关系的,即:thumbScrollTop / (elm.clientHeight - thumbHeight) = elm.scrollTop / (elm.scrollHeight - elm.clientHeight)
- 确定哪些事件会触发 scroll,主要是鼠标的 wheel,键盘的方向键和翻页键、鼠标点击 thumb 拖动,以及点击 track 等事件
下面便是一个为 elm 设置自定义滚动条的简单示例:
function customizeScroll (elm) {
if (!(elm instanceof HTMLElement)) {
return
}
if (elm.scrollHeight === elm.clientHeight) {
return;
}
const scrollElm = document.createElement("div");
const trackElm = document.createElement("div");
const thumbElm = document.createElement("div");
const elmComputedStyle = getComputedStyle(elm);
if (elmComputedStyle.overflowY !== "hidden") {
elm.style.overflowY = "hidden";
elm.style.tabIndex = -1;
elm.style.outline = "none";
}
if (elmComputedStyle.position === "static") {
elm.style.position = "relative";
}
scrollElm.style.position = "absolute";
scrollElm.style.top = 0;
scrollElm.style.bottom = 0;
scrollElm.style.right = 0;
trackElm.style.height = "100%";
trackElm.style.width = "10px";
trackElm.style.borderRadius = "10px";
trackElm.style.backgroundColor = "rgba(128, 128, 128, 0.26)";
thumbElm.style.width = "8px";
thumbElm.style.borderRadius = "8px";
thumbElm.style.padding = "0 1px";
thumbElm.style.backgroundColor = "#333";
thumbElm.style.opacity = 0.3;
const scrollHeight = elm.scrollHeight;
const clientHeight = elm.clientHeight;
const maxScrollTop = scrollHeight - clientHeight;
const thumbHeight = Math.max(
24,
(clientHeight * clientHeight) / scrollHeight
);
let mouseDownOnThumb = false;
let mouseDownScreenY = 0;
thumbElm.style.height = thumbHeight + "px";
trackElm.appendChild(thumbElm);
scrollElm.appendChild(trackElm);
elm.appendChild(scrollElm);
elm.addEventListener("wheel", onWheel);
elm.addEventListener("keydown", onKeyDown);
trackElm.addEventListener("click", onClickTrack);
thumbElm.addEventListener("mousedown", onMouseDown);
window.addEventListener("mouseup", onWindowMouseUp);
let tId = null;
function scrollByEvent(e, deltaY) {
if (tId) {
clearInterval(tId);
}
if (e.type !== "mousemove") {
const scrollTop = elm.scrollTop;
const duration = 100;
const frequency = 10;
const deltaT = duration / frequency;
const deltaYPerDeltaT = deltaY / frequency;
let count = 1;
tId = setInterval(function () {
const targetScrollTop = Math.max(
0,
Math.min(scrollTop + deltaYPerDeltaT * count, maxScrollTop)
);
const deltaYThumb = parseInt(
(targetScrollTop * (clientHeight - thumbHeight)) /
(scrollHeight - clientHeight)
);
elm.scrollTop = scrollTop + deltaYPerDeltaT * count;
scrollElm.style.transform =
"translateY" + "(" + targetScrollTop + "px" + ")";
thumbElm.style.transform =
"translateY" + "(" + deltaYThumb + "px" + ")";
if (count < 10) {
count++;
} else {
clearInterval(tId);
tId = null;
}
}, deltaT);
const realScrollTop = Math.max(
0,
Math.min(scrollTop + deltaY, maxScrollTop)
);
if (realScrollTop > 0 && realScrollTop < maxScrollTop) {
e.preventDefault();
}
} else {
const scrollTop = elm.scrollTop;
const targetScrollTop = Math.max(
0,
Math.min(scrollTop + deltaY, maxScrollTop)
);
const deltaYThumb = parseInt(
(targetScrollTop * (clientHeight - thumbHeight)) /
(scrollHeight - clientHeight)
);
elm.scrollTop = targetScrollTop;
scrollElm.style.transform = "translateY(" + targetScrollTop + "px)";
thumbElm.style.transform = "translateY(" + deltaYThumb + "px)";
}
}
function onWheel(e) {
if (e.deltaY !== 0) {
scrollByEvent(e, (e.deltaY / Math.abs(e.deltaY)) * 100);
}
}
function onClickTrack(e) {
if (e.target === trackElm) {
const transformY = parseInt(
thumbElm.style.transform.replace(/\D/g, "") || 0
);
const delta = e.offsetY - transformY;
if (delta !== 0) {
scrollByEvent(e, (delta / Math.abs(delta)) * 150);
}
}
}
function onKeyDown(e) {
if (e.metaKey || e.shiftKey || e.ctrlKey || e.altKey) {
return;
}
if (e.keyCode === 40) {
scrollByEvent(e, 40);
} else if (e.keyCode === 38) {
scrollByEvent(e, -40);
} else if (e.keyCode === 34) {
scrollByEvent(e, 150);
} else if (e.keyCode === 33) {
scrollByEvent(e, -150);
}
}
function onWindowMouseMove(e) {
const deltaY = e.screenY - mouseDownScreenY;
const scrollTop = elm.scrollTop;
mouseDownScreenY = e.screenY;
scrollByEvent(e, (deltaY * scrollHeight) / clientHeight);
}
function onMouseDown(e) {
if (e.button === 0) {
mouseDownOnThumb = true;
mouseDownScreenY = e.screenY;
thumbElm.style.opacity = 1;
window.addEventListener("mousemove", onWindowMouseMove);
}
}
function onWindowMouseUp(e) {
if (mouseDownOnThumb) {
mouseDownOnThumb = false;
thumbElm.style.opacity = 0.3;
window.removeEventListener("mousemove", onWindowMouseMove);
}
}
}
诚然,这只是一个满足基本条件的示例,但实际做的过程中却也不出意外地体会到「纸上得来终觉浅」:
- 普通元素需要监听 keydown 事件需要通过设置其 tabindex attribute 将其置为 focusable
- 有些浏览器的 wheel 事件、以及 key 为 pageDown、arrowDown 等的 keyDown 事件会伴随一个 default action,即触发 viewport 对应的滚动条(如果有的话)的相应行为,所以在 elm 上特定条件下,那些事件相关的 default action 可以通过 e.preventDefault() 来终止(以优化体验)
- 通过设置 elm.scrollTop 改变其值,浏览器就会 emit 一个以 elm 为 target 的 scroll 事件(原先还以为需要在 scroll 时手动调用 elm.dispatchEvent)
- keyCode 都建议不被使用了,上面的代码好像也只配在 IE 上跑