CSS & JS Effect – sticky horizontal scrollbar

需求

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

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章