構建動態互動式H5導航欄:滑動高亮、吸頂和錨點導航技巧詳解

一颗冰淇淋發表於2024-04-14

功能描述

產品要求在h5頁面實現集錨點、吸頂及滑動高亮為一體的功能,如下圖展示的一樣。當頁面滑動時,內容區域對應的選項卡高亮。當點選選項卡時,內容區域自動滑動到選項卡正下方。

佈局設計

css 佈局

為了更清晰的描述各功能實現的方式,將頁面佈局進行了如下的拆分。

★ 最外層的元素定義為 contentWrap,是使用 Intersection 定義的觀察根元素
★ 所有可縱向滑動的元素包裹在 vertScrollWrap 中,也是粘性定位需要找到的父元素。
★ 橫向可滑動的導航欄是 horiScrollWrap ,實現吸頂功能需要設定粘性定位。
★ observerWrap 用來包裹可觀察的元素,observerItem 用來形容每一個可觀察的子元素。

資料結構

導航欄的資料結構為陣列,裡面包括了選項卡需要顯示的文案,對應的值,以及唯一值 key 。

const list =  {
  label: "選項卡一",
  value: "1",
  key: "1",
  height: 150, // 模擬使用,真實場景並不需要,資料會自動將盒子撐開
}]

在我們真實的業務場景中,導航欄的標題來源於後端介面,內容區域也需要根據標題型別結合資料展示不同的內容,在獲取介面資料後,我會為每一條資料增加一個隨機的 key(非索引值,不會重複的8位雜湊值) ,在選項卡內容區域增加自定義屬性,如 data-tab-item-id,這樣可以精準的獲取到所需要的 dom 元素。

選項卡吸頂

按照這個場景,首先把選項卡橫向滾動吸頂的功能實現。這裡程式碼語法很簡單,透過 position: sticky 就能實現,但需要注意的是,這裡的 dom 元素佈局很重要,父元素需要包裹滑動時無需展示的中間區域,以及選項卡、及裡面的內容區域。

具體程式碼如下,這樣就能實現向上滑動時,選項卡一整行固定在頭部區域和內容區域之間。

// 父元素
.vertScrollWrap {
  position: relative;
  overflow: scroll;
  height: calc(100vh - 100px);
}

// 子元素 
.horiScrollWrap {
  position: sticky
  top: 0
}

滑動導航高亮

當手指觸控頁面滑動時,我們需要知道當前出現在可視區域的內容區域是哪些,傳統方案可以透過繫結 scroll 方法,這裡我使用的是 IntersectionObserver,透過觀察元素與父元素的交叉狀態,注意⚠️ 這個api有一定的瀏覽器版本要求。

map 儲存 dom 結構資訊

在頁面滑動時,需要知道每個內容區域距離父元素頂部的距離,找出距離頂部最近的元素,才能高亮對應的選項卡。當選項卡點選時,我們希望知道每個內容區域的高度,高度計算後,滾動整體到指定的高度,讓選項卡對應的內容元素放在選項卡的最下方。

根據以上邏輯,需要每個內容模組的屬性,這裡我使用map來儲存這些資料,key 為 dom 元素,value 值為物件,其中包含是否與父元素相交、距離頂部元素、元素高度等屬性。

// 初始化map
domMap = new Map();

// 設定map屬性
setDomMap = (dom, obj) => {
  const element = this.domMap.get(dom);
  const value = {
    key: element?.key,
    top: element?.top,
    height: element?.height,
    index: element?.index,
    isIntersecting: element?.isIntersecting,
    ...obj,
  };
  this.domMap.set(dom, value);
};

IntersectionObserver 觀察相交狀態

使用 new IntersectionObserver(callback[, options]) 來定義觀察邏輯。

初始化 domMap

在元件掛載時,初始化map資料,遍歷所有的內容區域元素。

const prefix = "nav";
const blockId = `${prefix}-block-id`;
// 每一個 observerItem 繫結 nav-block-id 的屬性, 為了儲存其 key 值
const observerNodes = [
  ...contentWrap.querySelectorAll(`[${blockId}^="${prefix}-"]`),
];

observerNodes.forEach((el, index) => {
  this.observer.observe(el);
  const attr = el.getAttribute(blockId);
  const key = attr?.split("-")?.[1];
  this.setDomMap(el, {
    isIntersecting: false,
    key,
    index,
    top: -1,
    height: -1,
  });
});
callback 定義相交規則
this.observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    // 更新 isIntersecting 屬性,是否相交
    this.setDomMap(entry.target, { isIntersecting: entry.isIntersecting });
  });

  // 遍歷所有屬性,更新距離頂部高度
  Array.from(this.domMap.keys()).forEach((dom) => {
    const rect = dom.getBoundingClientRect();
    this.setDomMap(dom, { top: rect.top, height: rect.height });
  });

  let min = 1000;
  let key = null;

  // 遍歷domMap,根據每個dom元素儲存的top值,找到距離父元素最近的一個dom元素 
  for (const [, value] of this.domMap) {
    if (value.isIntersecting) {
      if (value.top < min) {
        min = value.top;
        key = value.key;
      }
    }
  }

  // 找到這個key值後,設定選項卡高亮,saveInfo.clickFlag 這裡是判斷當前操作是滑動還是手動點選了選項卡,如果手動點選選項卡後執行的滾動邏輯,則不再這裡重複複製
  if (key && !saveInfo.clickFlag) {
    this.setActiveKey(key);
  }
  saveInfo.clickFlag = false;
}, options);
options 中定義文件視口的屬性
const options = {
  root: contentWrap, // 監聽元素的祖先DOM元素
  rootMargin: `-${marginTop}px 0px 0px 0px`, // 計算交叉值時新增至根的邊界盒中的一組偏移量,marginTop 是頭部區域+選項卡的高度
  threshold: 0, // 規定了一個監聽目標與邊界盒交叉區域的比例值
};

設定選項卡高亮

設定選項卡高亮只需要透過 state 來繫結一個變數,這裡需要注意兩個邏輯⚠️。

  1. 當需要高亮的選項卡不在當前可視區域內,需要將整個選項卡整體向左邊滑動,露出高亮的選項卡。
  2. 當頁面已經滑到底時,高亮的選項卡仍然可視區域內最靠近選項卡的那一個,比如下圖的選項卡六。

判斷選項卡是否在可視區域

首先是判斷需高亮的選項卡是否在可視區域內,如果在可視區域內也就不需要再左滑了。

isInViewport = (element) => {
  const rect = element.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <=
      (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
};
計算左滑的距離

可以透過即將高亮的選項卡dom元素來計算,如果每滑動一次都要進行dom計算會比較的耗費效能,更建議一開始就將每一個選項卡元素距離左邊的x軸距離儲存起來,在元件初始化的時候使用一個物件儲存起來。

calcTabsLeft() {
  this.tabsObj = {};
  // 為所有選項卡元素都繫結一個屬性,格式為 data-tab-item-id={`${prefix}-${item.key}`}
  const tabs = document.querySelectorAll(`[data-tab-item-id]`);
  tabs.forEach((tab) => {
    const rect = tab.getBoundingClientRect();
    // 拆分出每個元素繫結在 dom 上的 key 值
    const key = tab.getAttribute("data-tab-item-id");
    this.tabsObj[key] = rect.x;
  });
}
判斷當前展示內容是否已滑動到底部
canElementScrollDown = () => {
  // vertScrollWrap 是上圖所標記出來的,滑動元素的父級
  return vertScrollWrap.scrollTop < vertScrollWrap.scrollHeight - vertScrollWrap.clientHeight;
};
導航欄橫向滑動

為每一個 horiScrollItem 定義了 data-tab-item-id 屬性,用於記錄其 key 值。

navScroll() {
  const { activeKey } = this.state;
  // 可橫向滾動選項卡父級
  const scrollTab = document.querySelector('[data-tab="tab"]');
  // 需滑動的選項卡元素
  const horiScrollItem = scrollTab?.querySelector(
    `[data-tab-item-id=${prefix}-${activeKey}]`
  );

  // 如果選項卡元素存在並且不在可視區域內,才滑動
 if (horiScrollItem && !this.isInViewport(horiScrollItem)) {
    const navDataId = `${prefix}-${activeKey}`;
    const elementX = this.tabsObj[navDataId] - 12;
    scrollTab.scrollTo(elementX, 0);
  }
}

接著就可以定義高亮選項卡的方法

setActiveKey = (key) => {
  // 如果已經滑動到底部,則不繼續設定高亮選項卡
  if (!this.canElementScrollDown()) return;
  this.setState(
    {
      activeKey: key,
    },
    () => {
      // 判斷選項卡是否在可視區域內,如果不是,則滑動到可視區域內
      this.navScroll();
    }
  );
};

錨點跳轉

在點選選項卡的時候,透過選項卡自定義屬性上的 key 值找到對應內容區域的 dom 元素,再計算出它和父元素的距離,將對應的 vertScrollItem 滑動到可視區域即可。

這裡需要注意⚠️的是,錨點元素已經完全出現在可視區域或者已經滑到底部時,內容區域不會再向上滑動。比如下圖中,點選選項卡七選項卡八展示的頁面形式是一樣的,因為他們對應的內容區域已經完全展示出來了。如果設計為向上滑動,則會頁面底部很大一片空白。

計算內容區域與父級的距離

getTop = (key) => {
  let scrollTop = 0;
  Array.from(this.domMap.keys()).forEach((dom) => {
    const domValue = this.domMap.get(dom);
    if (domValue.key === key) {
      scrollTop = dom.offsetTop;
    }
  });
  return scrollTop;
};

點選錨點後滑動到可視區域

 onClickTabItem = (key) => {
    const vertScrollWrap = document.querySelector(".vertScrollWrap");
    // 導航欄高度 + 距離父元素高度
    const tabs = document.querySelector(".horiScrollWrap");
    const tabsHeight = tabs.getBoundingClientRect().height;
    const top = this.getTop(key) - tabsHeight;

    const observerItem = vertScrollWrap.querySelector(
      `[${blockId}="${prefix}-${key}"]`
    );
    if (observerItem) {
      // 將 clickFlag 定義為 true 時,不會在 intersectionObserver 處因為滑動導致不相交時而再次更新選項卡高亮的值
      saveInfo.clickFlag = true;
      const options = {
        left: 0,
        top,
      };
      vertScrollWrap.scroll(options);
    }

    this.setState({
      activeKey: key,
    });
  };

完整程式碼

以上便是滑動高亮+吸頂+錨點跳轉的H5導航欄功能的分佈解析,完整程式碼我放在了 github 上,戳 H5導航欄 anchor-sticky-nav 可檢視,歡迎大家點個 star~

相關文章