在專案中,你會如何設定 Scrollbar 的樣式呢?
當我們想要自定義網頁的 Scrollbar 時,最常見的方式之一就是用 CSS 改寫原生的 Scrollbar 樣式。儘管 CSS 可以做到 Scrollbar 的客製化,但也有不少限制。例如,跨瀏覽器問題:在不同瀏覽器上,我們需要使用不同的前綴甚至不同的方法來實現。
不過,有時候我們還是希望能有更高的可控性與跨瀏覽器的支援度,這時 <AtomicScrollbar>
元件可以滿足我們這些需求。
在開始實作前,我們先研究各個 UI Library 的 Scrollbar 元件是如何設計的。
Element Plus
<ElScrollbar height="400px">
<li v-for="item in 20" :key="item">{{ item }}</li>
</ElScrollbar>
Element Plus 提供 <ElScrollbar>
作為容器,高度可透過 props 傳入,這個高度用於定義 <ElScrollbar>
內部 Wrap 的大小。如果內容超出 Wrap,則會啟動 Scrollbar。此外,若希望自定義 Viewport 的 HTML tag,<ElScrollbar>
接受 tag
這個 props 來設定。
PrimeVue
<ScrollPanel style="width: 100%; height: 200px">
<li v-for="item in 20" :key="item">{{ item }}</li>
</ScrollPanel>
PrimeVue 的元件名稱為 <ScrollPanel>
,使用方式與 Element Plus 基本相同。不同的是,Element Plus 的 height
設定是透過 props,並將其應用於 Wrap 上,而 PrimeVue 則是直接限制最外層元件的大小來達到效果。
Radix Vue
<ScrollAreaRoot style="--scrollbar-size: 10px">
<ScrollAreaViewport>
<ul :style="{ padding: '15px 20px' }">
<li v-for="item in 20" :key="item">{{ item }}</li>
</ul>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar orientation="horizontal">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
Radix Vue 的使用方式看似較為複雜,但這其實提供了一個明確的架構指南,讓我們能輕易看到 Scrollbar 元件的組成。經過封裝後,使用起來也和前兩者無異。自定義 tag 的部分,Radix Vue 幾乎所有元件都支援使用 as
來設定 HTML tag。
<ScrollArea class="h-[200px] w-[350px] rounded-md border p-4">
<p>
<!-- 略 -->
</p>
</ScrollArea>
Radix Vue 與 Element Plus、PrimeVue,或是未提及的 Vuetify、Nuxt UI 不同。Radix Vue 僅提供最基本的樣式(甚至幾乎沒有樣式)與結構,讓使用者可以自由定義風格。
在實作上,我們甚至不需要處理 Props,因此我們可以從 <template>
開始。
<div class="atomic-scrollbar">
<div class="atomic-scrollbar__viewport">
<slot name="default" />
</div>
<div class="atomic-scrollbar__track atomic-scrollbar__track--vertical">
<div class="atomic-scrollbar__thumb" />
</div>
<div class="atomic-scrollbar__track atomic-scrollbar__track--horizontal">
<div class="atomic-scrollbar__thumb" />
</div>
</div>
在這個結構中,Root 是用來提供 Track 定位的容器;Viewport 則是承載內容的容器。這樣做可以防止 Track 元素影響使用者的內容與語義化結構。
由於我們目的是自己設計 Scrollbar,因此需要先用 CSS 隱藏瀏覽器原生的 Scrollbar。
.atomic-scrollbar {
overflow: hidden;
&__viewport {
height: inherit;
overflow: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
scrollbar-width: none;
可以隱藏瀏覽器的 Scrollbar,而 &::-webkit-scrollbar { display: none; }
則用於隱藏 iOS 瀏覽器的 Scrollbar。
接下來,我們讓 Track 被定位在 Root 的右側與下方,並加上樣式來美化 Scrollbar。
.atomic-scrollbar {
position: relative;
&__track {
--atomic-scrollbar-track-size: 8px;
position: absolute;
background-color: transparent;
border-radius: 10px;
&--vertical,
&--horizontal {
right: 0;
bottom: 0;
}
&--vertical {
top: 0;
width: var(--atomic-scrollbar-track-size);
}
&--horizontal {
left: 0;
height: var(--atomic-scrollbar-track-size);
}
&:hover {
--atomic-scrollbar-track-size: 10px;
background-color: rgba(lightgray, 0.2);
}
}
&__thumb {
background-color: rgba(gray, 0.2);
border-radius: 10px;
}
&__track--vertical &__thumb {
width: 100%;
}
&__track--horizontal &__thumb {
height: 100%;
}
}
樣式部分已經處理完畢,接下來進入較為複雜的數學計算。
Scrollbar 分為 Y 軸與 X 軸,本篇文章以 Y 軸為範例。
首先,我們要根據 Viewport 高度與 Content(整個內容高度)的比例,計算出 Thumb 應該設置的高度。
由圖片可以推導出這樣的比例關係:Content : Viewport = Track : Thumb
。
我們已知 Content、Viewport 和 Track 的高度,所以我們可以藉由國中數學的比例式運算,內項相乘=外項相乘,計算出 Thumb 應該要有的高度。
由於畫面上的 Viewport 高度等於 Track 高度,因此可以寫成:
const viewportRef = ref<HTMLElement>();
const thumbHeight = ref(0);
const thumbWidth = ref(0);
const update = () => {
const viewport = viewportRef.value;
if (!viewport) return;
const { offsetHeight, offsetWidth } = viewport;
thumbHeight.value = (offsetHeight * offsetHeight) / viewport.scrollHeight;
thumbWidth.value = (offsetWidth * offsetWidth) / viewport.scrollWidth;
}
試著計算一下,假設 Content 高度為 2000,Viewport 高度為 500,那麼算出的 Thumb 高度應該是 (500 * 500) / 2000
,等於 125
。
但如果 Thumb 算出的數值過小,可能會影響使用體驗,因此我們希望 Thumb 的最小高度不小於 20。
const update = () => {
const viewport = viewportRef.value;
if (!viewport) return;
const { offsetHeight, offsetWidth } = viewport;
const originalHeight = (offsetHeight * offsetHeight) / viewport.scrollHeight;
const originalWidth = (offsetWidth * offsetWidth) / viewport.scrollWidth;
const height = Math.max(originalHeight, MIN_SIZE);
const width = Math.max(originalWidth, MIN_SIZE);
thumbHeight.value = height;
thumbWidth.value = width;
}
不過這樣可能導致算出來得 Thumb 高度超過 Viewport,因此需要再進行微調。
const update = () => {
const viewport = viewportRef.value;
if (!viewport) return;
const offsetHeight = viewport.offsetHeight;
const offsetWidth = viewport.offsetWidth;
const originalHeight = (offsetHeight * offsetHeight) / viewport.scrollHeight;
const originalWidth = (offsetWidth * offsetWidth) / viewport.scrollWidth;
const height = Math.max(originalHeight, MIN_SIZE);
const width = Math.max(originalWidth, MIN_SIZE);
thumbHeight.value = height < offsetHeight ? height : 0;
thumbWidth.value = width < offsetWidth ? width : 0;
};
大多數情況下,Content : Viewport
與 Viewport : Thumb
的比例相等。也就是說,當 Viewport 往下滾動 10%,Thumb 也會往下 10%,此時移動比率(ratioY)為 1
。
但是當 originalHeight
小於 MIN_SIZE
時,我們會取用 MIN_SIZE
,這樣前面的比例就會不相等。
因此,我們需要計算 Thumb 的移動比率:
let ratioY = 1;
let ratioX = 1;
const update = () => {
// 略
ratioY = originalHeight / (offsetHeight - originalHeight) / (height / (offsetHeight - height));
ratioX = originalWidth / (offsetWidth - originalWidth) / (width / (offsetWidth - width));
};
用程式碼表達比較複雜,列成數學式會更易理解:
由於最終計算出的 height
只會小於或等於 offsetHeight
,所以我們可以推算出 ratioY
的值會在小於等於 1 到大於 0 之間。
Thumb 的位置計算與之前類似,我們需要計算出 Thumb 的位置。
const thumbTop = ref(0);
const thumbLeft = ref(0);
const onScroll = () => {
const viewport = viewportRef.value;
if (!viewport) return;
const { offsetHeight, offsetWidth, scrollTop, scrollLeft } = viewport;
thumbTop.value = ((scrollTop * 100) / offsetHeight);
thumbLeft.value = ((scrollLeft * 100) / offsetWidth);
};
通過滾動距離與總滾動量的比例,可以計算出 Thumb 應該處於的位置。
不過,還需要考慮到 Content : Viewport
與 Viewport : Thumb
比例不相等的情況,此時需要用到前面計算的 ratioY
與 ratioX
。
const onScroll = () => {
const viewport = viewportRef.value;
if (!viewport) return;
const { offsetHeight, offsetWidth, scrollTop, scrollLeft } = viewport;
thumbTop.value = ((scrollTop * 100) / offsetHeight) * ratioY;
thumbLeft.value = ((scrollLeft * 100) / offsetWidth) * ratioX;
};
了解如何計算 Thumb 的高度、比例與位置後,我們可以將這些數值應用到元件上。
<div class="atomic-scrollbar__track atomic-scrollbar__track--vertical">
<div
class="atomic-scrollbar__thumb"
:style="{
transform: `translateY(${thumbTop}%)`,
height: `${thumbHeight}px`
}"
/>
</div>
接下來,我們來探討何時計算 Thumb 的大小與位置。
更新 Thumb 的大小與比例的時機包括:元件 mounted 後、Viewport 大小改變後、內容變更後。
const update = () => {
// 略
};
const viewportRef = ref<HTMLElement>();
let unobserve: (() => void) | null = null;
onMounted(() => {
const observer = new ResizeObserver(update);
observer.observe(viewportRef.value);
unobserve = () => observer.disconnect();
})
onUpdated(update);
onBeforeUnmount(() => {
unobserve();
unobserve = null;
})
大多數情況下,我強烈不建議在
onUpdated
中更新響應式資料,這很可能會導致無限更新循環的問題。在這個元件中,我們更新 Thumb 的大小與比例後會再次觸發
onUpdated
,但由於更新值與第一次相同,因此不會產生無限更新循環。
在處理滑鼠點擊 Thumb 拖曳前,我們先來規劃整個流程:
因此,我們需要針對 Thumb 監聽 pointerdown
事件,並在按下後開始對整個網頁監聽 pointermove
來記錄移動量,並將其應用到 Viewport 上。同時,我們也需要監聽 pointerup
事件來移除監聽的 pointermove
與 pointerup
事件。
對整個網頁監聽
pointermove
是為了確保滑鼠移出 Thumb 時仍能繼續拖曳。
onThumbPointerdown
onThumbPointerdown
的目的是記錄滑鼠按下的位置,並啟動對整個網頁的 pointermove
與 pointerup
事件監聽。
let mousePosition: number = 0;
let scrollOffset: number | null = null;
const onThumbPointerdown = (event: PointerEvent) => {
if (event.ctrlKey || event.button !== 0) return;
const viewport = viewportRef.value;
if (!viewport) return;
mousePosition = event.pageY;
scrollOffset = viewport.scrollTop;
document.addEventListener('pointermove', onDocumentPointermove);
document.addEventListener('pointerup', onDocumentPointerup);
};
onDocumentPointermove
在 onDocumentPointermove
中,我們計算滑鼠移動的距離,並將其反映到 Viewport 上。
const onDocumentPointermove = (event: PointerEvent) => {
const viewport = viewportRef.value;
if (!viewport) return;
const offset = (event.pageY - mousePosition) / viewport.offsetHeight;
viewport.scrollTop = scrollOffset! + offset * viewport.scrollHeight;
};
offset
是滑鼠移動距離相對於 Viewport 高度的比值:
將這個比值乘上 Viewport 的 scrollHeight
,就能得到滑鼠移動所反映的移動距離,最後將移動距離與預先記錄的 scrollOffset
相加,就得到了最終的 scrollTop
。
這樣,我們就能在滑鼠移動時讓 Viewport 跟著移動了!
onDocumentPointerup
最後,當滑鼠放開時,我們要移除對 pointermove
與 pointerup
的監聽,並清空暫存變數。
const onDocumentPointerup = () => {
document.removeEventListener('pointermove', onDocumentPointermove);
document.removeEventListener('pointerup', onDocumentPointerup);
currentOrientation = null;
scrollOffset = null;
mousePosition = 0;
if (document.onselectstart !== originalOnSelectStart) {
document.onselectstart = originalOnSelectStart;
}
};
我們來看看滑鼠點擊 Thumb 拖曳的效果。
Thumb 拖曳的部分,我們需要考量到在行動裝置上操作,在行動裝置上沒有滑鼠,也就沒有
MouseEvent
,這時我們使用PointerEvent
會是更好的選擇。
當點擊原生 Scrollbar 的 Track 時,會逐步跳到指定位置。我們希望 <AtomicScrollbar>
也具備這樣的功能。此處實作一個簡單版本,當使用者點擊 Track 時,Thumb 會直接跳到指定位置。
我們可以換算出點擊 Track 的位置對應到 Content 上的位置,然後將 Viewport 移動到該位置。
例如:Track 高度為 100,Content 高度為 1000,點擊 Track 的位置為 20,則對應的 Content 位置為 200,這表示 Viewport 要移動到 200 的位置。
Content 的高度剛好等於 Viewport 可滾動的高度,因此程式碼實作如下:
const onTrackPointerdown = (
event: PointerEvent,
orientation: OrientationKey
) => {
const viewport = viewportRef.value;
if (!viewport) return;
const track = event.currentTarget as HTMLElement;
const rect = track.getBoundingClientRect();
const point = Math.abs(rect.top - event.pageY);
viewport.scrollTop = point * (viewport.scrollHeight / track.offsetHeight);
};
這樣一來,我們就可以在點擊 Track 時,讓 Viewport 與 Thumb 移動到指定的位置。
最後,我們希望 Scrollbar 在沒有互動時可以隱藏。
互動行為包括:滑鼠進入 Thumb、滑鼠拖曳 Thumb、滑鼠滾動。因此,我們可以使用兩個 flag 變數來記錄這些狀態。
const active = ref(false);
const dragging = ref(false);
在拖曳過程中,dragging
設為 true
,而滑鼠移動或滾動時,active
設為 true
。
dragging
const onDocumentPointermove = (event: PointerEvent) => {
// 略
dragging.value = true;
};
const onDocumentPointerup = () => {
// 略
dragging.value = false;
};
active
const scheduleHideScrollbar = () => {
time && clearTimeout(time);
time = setTimeout(() => {
active.value = false;
}, 1000);
};
const onScroll = () => {
// 略
scheduleHideScrollbar();
active.value = true;
};
const onTrackPointerenter = () => {
// 略
active.value = true;
};
const onTrackPointerleave = scheduleHideScrollbar;
最後,在 Track 上加上 v-show
條件,當 active
或 dragging
為 true
時顯示,否則隱藏。
<div
v-show="active || dragging"
class="atomic-scrollbar__track atomic-scrollbar__track--vertical"
>
<!-- 略 -->
</div>
目前每一個 Scrollbar 都會有一個獨立的 ResizeObserver
。如果畫面上同時有很多 <AtomicScrollbar>
存在,將造成不少的資源浪費。
我們可以使用在 <AtomicLink>
裡面用過的「單例模式」,讓每個 <AtomicScrollbar>
都共享同一個 ResizeObserver
實例。
let unobserve: (() => void) | null = null;
onMounted(() => {
const observer = createResizeObserver();
unobserve = observer.observe(viewportRef.value, update);
});
onBeforeUnmount(() => {
unobserve?.();
unobserve = null;
});
createResizeObserver
的實作如下:
type CallbackFn = () => void;
type ObserveFn = (element: Element, callback: CallbackFn) => () => void;
let cache: { observe: ObserveFn } | undefined;
export default function createResizeObserver() {
if (cache) return cache;
let observer: ResizeObserver | null = null;
const callbacks = new Map<Element, CallbackFn>();
const observe: ObserveFn = (element, callback) => {
if (!observer) {
observer = new ResizeObserver(entries => {
for (const entry of entries) {
const callback = callbacks.get(entry.target);
callback?.();
}
});
}
callbacks.set(element, callback);
observer.observe(element);
return () => {
callbacks.delete(element);
observer!.unobserve(element);
if (callbacks.size === 0) {
observer!.disconnect();
observer = null;
}
};
};
return (cache = { observe });
}
與 <AtomicLink>
裡面實作的 createIntersectionObserver
方式相同,第一層的 cache
確保無論呼叫多少次 createResizeObserver
都只會在第一次建立一個 { observe }
物件並在之後共用;第二層的單例模式則確保無論呼叫多少次 observe
,都只會有一個 ResizeObserver
實例。
詳細拆解說明可以參考 AtomicLink 中的「減少重複的 IntersectionObserver」章節。
這樣,我們就可以減少重複的 ResizeObserver
實例,提高效能。
對於我們看得見的人來說,畫面上那條小小的東西很容易被認為是 Scrollbar,但對於使用螢幕閱讀器的人來說,這些東西可能不易辨識。因此,我們需要透過 role
來明確告知這是一個 Scrollbar。
首先,我們需要為 Track 加上 role="scrollbar"
屬性,這樣螢幕閱讀器才能識別這是一個 Scrollbar。
<div
class="atomic-scrollbar__track atomic-scrollbar__track--vertical"
role="scrollbar"
>
<div
class="atomic-scrollbar__thumb"
:style="{
transform: `translateY(${thumbTop}%)`,
height: `${thumbHeight}px`,
}"
/>
</div>
為了配合 role="scrollbar"
,我們還需要加上一些 aria-*
屬性,這樣螢幕閱讀器可以清楚讀取 Scrollbar 的狀態。
aria-controls
:指定 Scrollbar 控制的元素。aria-orientation
:指定 Scrollbar 的方向。aria-valuenow
:指定 Scrollbar 的當前值。aria-valuemax
:指定 Scrollbar 的最大值,預設為 100。aria-valuemin
:指定 Scrollbar 的最小值,預設為 0。我們需要計算 aria-valuenow
的值。不同於 thumbTop
是 Thumb 移動的百分比,aria-valuenow
表示已滾動的百分比。
最簡單的算法如下:
const valuenowY = computed(() => {
const viewport = viewportRef.value;
if (!viewport) return 0;
const { scrollTop, offsetHeight, scrollHeight } = viewport;
return (scrollTop / (scrollHeight - offsetHeight)) * 100;
});
但是這樣的數據不會隨著滾動自動更新,因為 scrollTop
、offsetHeight
和 scrollHeight
都不是 Vue 追蹤的響應式變數。此時,我們可以使用一個小技巧來讓 valuenowY
隨著 Scrollbar 滾動更新。
const valuenowY = computed(() => {
const viewport = viewportRef.value;
if (!viewport) return 0;
// 追蹤依賴
void thumbTop.value;
const { scrollTop, offsetHeight, scrollHeight } = viewport;
return (scrollTop / (scrollHeight - offsetHeight)) * 100;
});
computed
會在第一次運算時,收集函數中的響應式資料(如 ref
、reactive
及 computed
),當這些依賴變化時,computed
會重新計算。
thumbTop.value
是響應式變數,會隨著 Scrollbar 滾動更新。使用 void thumbTop.value
可以使 thumbTop.value
的變動觸發 valuenowY
的重新計算,從而達到追蹤當前滾動量的效果。
在製作 <AtomicScrollbar>
的過程中,我們進行了許多數學運算,計算了 Thumb 的高度、寬度與位置,並計算了滑鼠拖曳 Thumb 的移動量。最後,在實現無障礙功能時也用到了數學運算。如果一次看不懂,建議帶入一些具體的數字來計算,這樣更容易理解。
Scrollbar 雖然不是常見的元件,但相比純 CSS 處理 Scrollbar,使用 <AtomicScrollbar>
元件可以在不同作業系統和瀏覽器上達到一致的效果,特別是解決了 Windows 上 Scrollbar 寬度約 17px 的問題。
雖然我們已盡力模擬瀏覽器原生 Scrollbar 的行為,但例如在行動裝置上,點擊 Scrollbar 的震動回饋等功能尚未完全普及(特別是在 Safari 上)。因此,使用前建議與合作夥伴充分理解這些方案的優缺點,以找到最適合的解決方案。
<AtomicScrollbar>
原始碼:AtomicScrollbar.vue