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)

 

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