程式碼研究室:建構故事元件

本程式碼研究室會說明如何在網路上建構類似 Instagram Stories 的體驗。我們會逐步建構元件,從 HTML 開始,然後是 CSS,最後是 JavaScript。

請參閱我的網誌文章「建立 Stories 元件」,瞭解在建構此元件時所做的漸進式改善。

設定

  1. 按一下「Remix to Edit」,即可編輯專案。
  2. 開啟 app/index.html

HTML

我總是盡量使用語意式 HTML。由於每位好友可能有任意數量的短片,我認為為每位好友使用 <section> 元素,並為每部短片使用 <article> 元素,是比較有意義的做法。不過,讓我們從頭開始。首先,我們需要一個容器來放置 Stories 元件。

<body> 中加入 <div> 元素:

<div class="stories">

</div>

新增一些 <section> 元素來代表朋友:

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

新增一些 <article> 元素來代表故事:

<div class="stories">
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
  </section>
</div>
  • 我們會使用圖片服務 (picsum.com) 製作故事原型。
  • 每個 <article> 上的 style 屬性是預留位置載入技巧的一部分,您將在下一節中進一步瞭解。

CSS

我們的內容已準備好進行樣式設定。讓我們將這些骨頭轉變成人們想互動的東西。我們今天會先處理行動版內容。

.stories

我們需要一個水平捲動的容器來放置 <div class="stories"> 容器。我們可以透過以下方式達成這項目標:

  • 將容器設為「格線」
  • 將每個子項設為填滿列軌
  • 將每個子項的寬度設為行動裝置可視區的寬度

格線會繼續將新的 100vw 寬度欄置於前一欄的右側,直到將所有 HTML 元素置入標記為止。

Chrome 和開發人員工具已開啟,並以顯示全寬版面的格狀視覺效果
Chrome 開發人員工具顯示格狀欄溢位,並製作水平捲動條。

app/css/index.css 底部加入下列 CSS:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

既然內容已超出可視區域,現在就該告訴容器如何處理了。將醒目顯示的程式碼行加入 .stories 規則集:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
  touch-action: pan-x;
}

我們想要水平捲動,因此將 overflow-x 設為 auto。我們希望元件在使用者捲動時,能輕柔地停留在下一個故事上,因此我們會使用 scroll-snap-type: x mandatory。如要進一步瞭解這項 CSS,請參閱我的網誌文章中的 CSS 捲動固定點overscroll-behavior 部分。

父項容器和子項都必須同意捲動自動對齊,因此我們現在就來處理這個問題。在 app/css/index.css 底部加入下列程式碼:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

您的應用程式尚未運作,但下方的影片會說明啟用和停用 scroll-snap-type 時會發生什麼事。啟用後,每個水平捲動畫面都會固定在下一個短片。停用後,瀏覽器會使用預設的捲動行為。

這樣你就能瀏覽好友,但我們仍需解決 Stories 的問題。

.user

讓我們在 .user 區段中建立版面配置,將這些子故事元素妥善安排。我們將使用方便的堆疊技巧來解決這個問題。我們基本上會建立 1x1 格線,其中的資料列和欄都會使用相同的格線別名 [story],而每個故事格線項目都會嘗試使用該空間,導致堆疊。

將醒目顯示的程式碼加入 .user 規則集:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

app/css/index.css 底部加入下列規則集:

.story {
  grid-area: story;
}

如今,如果沒有絕對定位、浮動或其他將元素從流程中移除的版面配置指令,我們仍會處於流程中。而且,它幾乎沒有任何程式碼,請看!影片和網誌文章會進一步說明這項資訊。

.story

現在只需要為故事項目本身設定樣式即可。

我們先前提到,每個 <article> 元素的 style 屬性是預留位置載入技巧的一部分:

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

我們將使用 CSS 的 background-image 屬性,這樣就能指定多個背景圖片。我們可以將這些圖片排序,讓使用者圖片置於頂端,並在載入完成時自動顯示。為啟用這項功能,我們會將圖片網址放入自訂屬性 (--bg),並在 CSS 中使用該屬性,以便與載入預留位置建立層疊效果。

首先,我們要更新 .story 規則集,以便在載入完成後,將漸層圖片替換為背景圖片。將醒目顯示的程式碼加入 .story 規則集:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

background-size 設為 cover 可確保檢視區中沒有空白空間,因為圖片會填滿檢視區。定義 2 個背景圖片後,我們就能使用名為「loading tombstone」的 CSS 網路技巧:

  • 背景圖片 1 (var(--bg)) 是我們在 HTML 中傳遞的內嵌網址
  • 背景圖片 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) 是網址載入時顯示的漸層效果

圖片下載完成後,CSS 會自動將漸層圖案替換為圖片。

接下來,我們將新增一些 CSS 來移除某些行為,讓瀏覽器可以更快速地移動。將醒目顯示的程式碼加入 .story 規則集:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;
}
  • user-select: none 可防止使用者不小心選取文字
  • touch-action: manipulation 會指示瀏覽器將這些互動視為觸控事件,讓瀏覽器不必嘗試判斷您是否點選網址

最後,我們來新增一點 CSS,為故事之間的轉場效果加上動畫。將醒目顯示的程式碼新增至 .story 規則集:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;

  transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);

  &.seen {
    opacity: 0;
    pointer-events: none;
  }
}

系統會將 .seen 類別新增至需要退出的故事。我從 Material Design 的緩和指南中取得自訂緩和函式 (cubic-bezier(0.4, 0.0, 1,1)) (請捲動至「加速緩和」一節)。

如果你眼力敏銳,可能會注意到 pointer-events: none 宣告,並對此感到疑惑。我認為這是目前為止解決方案唯一的缺點。我們需要這麼做,因為 .seen.story 元素會置於頂端,並且會接收輕觸動作,即使它是不可見的。將 pointer-events 設為 none 後,我們就能將 Glass 故事變成視窗,不再竊取使用者互動。這項權衡並沒有太大問題,目前在 CSS 中也沒有太難管理。我們並未同時處理 z-index。我仍然對此感到滿意。

JavaScript

使用者與 Stories 元件的互動方式相當簡單:輕觸右側可前進,輕觸左側則可返回。對使用者來說簡單的事情,對開發人員來說往往是艱鉅的挑戰。不過,我們會處理其中大部分內容。

設定

首先,我們會盡可能計算及儲存大量資訊。在 app/js/index.js 加入以下程式碼:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

第一行 JavaScript 會擷取並儲存主要 HTML 元素根目錄的參照。下一行會計算元素的中間位置,以便我們判斷輕觸動作是向前還是向後。

接下來,我們會建立一個小物件,其中包含與邏輯相關的狀態。在本例中,我們只想瞭解目前的故事。在 HTML 標記中,我們可以透過擷取第 1 位好友和他們最近的故事來存取。將醒目顯示的程式碼加入 app/js/index.js

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

事件監聽器

我們現在有足夠的邏輯,可以開始監聽使用者事件並進行導向。

老鼠

我們先在 Stories 容器上監聽 'click' 事件。將醒目顯示的程式碼新增至 app/js/index.js

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

如果點擊並非發生在 <article> 元素上,我們會退出並什麼都不做。如果是文章,我們會使用 clientX 取得滑鼠或手指的水平位置。我們尚未實作 navigateStories,但所採用的引數會指定我們需要前往的方向。如果使用者位置大於中位數,我們就會知道需要前往 next,否則是 prev (前一個)。

鍵盤

接著,讓我們監聽按下鍵盤的動作。如果按下「向下箭頭」,我們會前往 next。如果是「向上箭頭」,我們會前往 prev

將醒目顯示的程式碼新增至 app/js/index.js

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

document.addEventListener('keydown', ({key}) => {
  if (key !== 'ArrowDown' || key !== 'ArrowUp')
    navigateStories(
      key === 'ArrowDown'
        ? 'next'
        : 'prev')
})

動態消息瀏覽

接下來,我們將探討 Stories 的獨特商業邏輯,以及讓 Stories 聲名大噪的使用者體驗。這看起來很複雜,但我認為如果您逐行閱讀,就會發現它相當易懂。

我們會先儲存一些選取器,以便決定要捲動至好友,還是要顯示/隱藏故事。由於我們要處理 HTML,因此會查詢是否有朋友 (使用者) 或故事 (story)。

這些變數可協助我們回答以下問題:「在 x 個故事中,『下一個』是否表示要轉移至同一位朋友的其他故事,還是轉移至其他朋友?」我使用我們建立的樹狀結構,將其擴展至父項和子項。

app/js/index.js 底部加入下列程式碼:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling
}

以下是我們的商業邏輯目標,盡可能接近自然語言:

  • 決定如何處理輕觸事件
    • 如果有下一則/上一則故事,請顯示該故事
    • 如果是好友的最後一個/第一個故事:顯示新好友
    • 如果沒有要前往的路線,請不要採取任何行動
  • 將新的目前故事儲存至 state

將醒目顯示的程式碼新增至 navigateStories 函式:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling

  if (direction === 'next') {
    if (lastItemInUserStory === story && !hasNextUserStory)
      return
    else if (lastItemInUserStory === story && hasNextUserStory) {
      state.current_story = story.parentElement.nextElementSibling.lastElementChild
      story.parentElement.nextElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.classList.add('seen')
      state.current_story = story.previousElementSibling
    }
  }
  else if(direction === 'prev') {
    if (firstItemInUserStory === story && !hasPrevUserStory)
      return
    else if (firstItemInUserStory === story && hasPrevUserStory) {
      state.current_story = story.parentElement.previousElementSibling.firstElementChild
      story.parentElement.previousElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.nextElementSibling.classList.remove('seen')
      state.current_story = story.nextElementSibling
    }
  }
}

立即試用

  • 如要預覽網站,請按下「View App」。然後按下「Fullscreen」圖示 全螢幕

結論

以上就是我對元件的需求。歡迎您自由發揮,運用資料強化這項功能,並將其視為自己的功能!