需求
這個是 Google Ads 裏的 table。
那個 horizontal scrollbar 可以 sticky bottom。
我們知道 scrollbar 是遊覽器原生的,我們能做的 styling 少之又少,挺多隻能調 size, color 而已。要讓它 sticky bottom 根本不可能。
實現思路
首先要弄一個假的 scrollbar 出來。
怎麼理解,怎麼弄?
做一個 div1 > div2
div1 設置 max-width, overflow auto
div2 設置 width。
這樣一個 horizontal scrollbar 就出來的。
這個 div 裏只有一個 scrollbar 沒有其它內容,所以它看上去就是一個 scrollbar。
而 div 是可以 sticky bottom 的 (當然上面這個例子,使用的不是 CSS 原生的 sticky 功能,而是模擬的)。
這樣我們就有了一個可以 sticky bottom 的 scrollbar。
接着我們把原生的 horizontal scrollbar hide 起來,這樣看上去就 ok 了。
看是沒有問題了,但是交互還需要搞一搞。
監聽假 scrollbar 同步 scrollLeft 給 container,反過來也需要,監聽 container scroll 同步 scrollLeft 給假 scrollbar。
這樣就大功告成了。
破壞性
但凡 “假的 / 模擬的” 都是旁門左道,一定會引起一些 bug 之類的。所以一定要控制好範圍,避免失控。
下圖是我們常見的盒子 container
offsetHeight 的計算是 border to border (border + scrollbar + padding + content)
clientHeight 的計算是 padding to padding (padding + content, 沒有 border 和 scrollbar)
問題來了,我們的假 scrollbar 要放在 container 裏面還是外面?
如果放在裏面,那麼它會在 padding 之上 (因爲它算是內容丫),而不是取代 native scrollbar 的位置 (padding 之下)。
於是我們需要 remove container 的 padding-bottom 然後在假 scrollbar 補回 padding-bottom。
即便看上去沒問題,但是 clientHeight 的計算肯定就錯了,因爲假 scrollbar 被當成了內容,而 clientHeight 是不應該計算 scrollbar height 的。
把假 scrollbar 放到 container 外面也有類似的問題,我們需要 remove container 的 border-bottom,然後在假 scrollbar 補回 border-bottom。
這回 clientHeight 對了,但是 offsetHeight 計算卻錯了,少算了一個 border-bottom。
所以不管放哪一邊總會影響到某些地方,這就是旁門左道的代價。
下面例子我選擇放外面,因爲放裏面還需要用上 sticky left 會更麻煩。
Step by Step
搭環境
index.html
<div class="vertical-container"> <div class="horizontal-container"> <div class="my-content"></div> </div> </div>
index.scss
.vertical-container { width: max-content; margin-inline: auto; max-height: 512px; overflow-y: auto; } .horizontal-container { max-width: 768px; overflow: auto; &.hide-scrollbar { &::-webkit-scrollbar { height: 0; } scrollbar-width: none; } } .my-content { width: 500px; height: 10px; background-color: pink; }
注意那個 class hide-scrollbar
由於 JS 無法 querySelector 僞元素,所以 hide scrollbar 只能讓 CSS 負責了。
index.ts
首先 query container
const container = document.querySelector<HTMLElement>('.horizontal-container')!;
然後做一些 first time setup
// 創捷 scrollbar const scrollbar = document.createElement('div'); // 創建 scrollbar content const scrollbarContent = document.createElement('div'); // 把 scrollbar content 插入到 scrollbar scrollbar.appendChild(scrollbarContent); // 把 scrollbar 插入到 container next sibling container.parentElement!.insertBefore(scrollbar, container.nextElementSibling); // 設置 scrollbar 一些 style scrollbar.style.overflowX = 'auto'; scrollbar.style.overflowY = 'hidden'; // 監聽 scroll 同步 container 和 scrollbar 的 scrollLeft scrollbar.addEventListener('scroll', () => { container.scrollLeft = scrollbar.scrollLeft; }); container.addEventListener('scroll', () => { scrollbar.scrollLeft = container.scrollLeft; });
因爲我們需要監聽 container resize,所以特別區分 first time setup。
接着,封裝一個 getContainerInfo 函數
// container info 接口 interface ContainerInfo { clientWidth: number; scrollWidth: number; hasScrollbar: boolean; scrollbarHeight: number; } // 記入最後一次的 scrollbar height let lastScrollbarHeight = 0; function getContainerInfo(container: HTMLElement): ContainerInfo { // getElementSize 是一個方便拿 element size 的功能,把它當作是 getComputedStyle 就可以了 const containerSize = getElementSize(container); const containerClientWidth = containerSize.client.width; const containerScrollWidth = containerSize.scroll.size.width; // 判斷有沒有 scrollbar 出現 const hasScrollbar = containerClientWidth !== containerScrollWidth; // 計算 native scrollbar 的 height let scrollbarHeight = containerSize.offset.height - containerSize.border.block - containerSize.client.height; // 因爲我們會 hide native scrollbar, // 所以第一次可以拿到 scrollbar height 但是第二次可能就拿不到了 // 所以我們需要把 scrollbar height 存起來 if (hasScrollbar && scrollbarHeight !== 0) { lastScrollbarHeight = scrollbarHeight; } // 第二次拿不到 scrollbar height 的時候,我們拿存起來的來用 if (hasScrollbar && scrollbarHeight === 0) { scrollbarHeight = lastScrollbarHeight; // Firefox 是永遠拿不到 scrollbar height 的,給它一個默認 12 就好。 if (scrollbarHeight === 0) scrollbarHeight = 12; } return { clientWidth: containerClientWidth, scrollWidth: containerScrollWidth, hasScrollbar, scrollbarHeight, }; }
在 first setup 之前 getContainerInfo
必須提前讀取 container information,如果在 first setup 後纔讀取會導致遊覽器立刻 repaint / reflow。
接着,封裝一個 updateSize 函數
function updateSize( container: HTMLElement, scrollbar: HTMLElement, scrollbarContent: HTMLElement, containerInfo: ContainerInfo, ) { const { clientWidth, scrollWidth, hasScrollbar, scrollbarHeight } = containerInfo; // 如果需求 scrollbar 那就 hide native scrollbar container.classList[hasScrollbar ? 'add' : 'remove']('hide-scrollbar'); // 如果不需要 scrollbar 就 display none 假 scrollbar if (!hasScrollbar) scrollbar.style.display = 'none'; if (hasScrollbar) { // 如果需要 scrollbar 就 update scrollbar 和 scrollbar content 的 size scrollbar.style.removeProperty('display'); scrollbar.style.maxWidth = `${clientWidth}px`; scrollbar.style.maxHeight = `${scrollbarHeight}px`; scrollbarContent.style.height = `${scrollbarHeight}px`; scrollbarContent.style.width = `${scrollWidth}px`; } }
在 first setup 之後調用 updateSize for firstload
做一個 resize 監聽
// StgResizeObserver 是一個基於 RxJS 的 ResizeObserver // 把它當作 native 的 ResizeObserver 看待就可以了 const ro = new StgResizeObserver(); // 注意監聽的是 container 的所有 child elements // 因爲 container 已經 overflow 了,它是不會 resize 的, resize 的是它的 children merge(...Array.from(container.children).map(el => ro.observe(el))).subscribe(() => { // 每當 resize 就重新 getContainerInfo + updateSize const containerInfo = getContainerInfo(container); updateSize(container, scrollbar, scrollbarContent, containerInfo); });
這樣就大功告成了。
提醒:container 不支持放 border 哦,因爲我選擇的是把假 scrollbar 放到 container 之外,如果是放在 container 裏面的話就支持 border 但不支持 padding,同時需要 sticky left 比較麻煩。有興趣的可以自己玩一玩。
至於如何讓假 scrollbar sticky bottom,請參考:CSS & JS Effect – Simulation Position Sticky (用 JavaScript 實現 position sticky)