需求
这个是 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)