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

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

請參閱我的網誌文章「Building a Stories component」,瞭解建構這個元件時逐步進行的強化作業。

設定

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

HTML

我一律會使用語意式 HTML。由於每位好友可以有多則動態消息,因此我認為為每位好友使用 <section> 元素,並為每則動態消息使用 <article> 元素,是很有意義的做法。不過,我們還是先從頭說起。首先,我們需要故事元件的容器。

<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"> 容器是可水平捲動的容器。 我們可透過下列方式達成此目標:

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

Grid 會繼續在先前資料欄的右側放置新的 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 時的情況。啟用後,每次水平捲動都會跳到下一則動態消息。停用時,瀏覽器會使用預設捲動行為。

這樣就能捲動瀏覽好友,但我們仍有故事相關問題待解決。

.user

我們會在 .user 區段中建立版面配置,將這些子項故事元素整理到適當位置。我們將使用實用的堆疊技巧來解決這個問題。 我們基本上會建立 1x1 的格線,其中列和欄具有相同的 [story] Grid 別名,且每個動態消息格線項目都會嘗試宣告該空間,進而產生堆疊。

將醒目顯示的程式碼新增至 .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 張背景圖片可讓我們運用稱為「載入墓碑」的 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,即可將玻璃故事變成視窗,不再竊取使用者互動。這項取捨不算太糟,目前在我們的 CSS 中管理也不會太困難。我們不是在玩z-index,我對此仍感到滿意。

JavaScript

使用者與「故事」元件的互動相當簡單:輕觸右側可前進,輕觸左側可返回。對使用者來說簡單的事,往往是開發人員的苦差事。不過,我們將處理許多相關事宜。

設定

首先,讓我們盡可能計算及儲存資訊。 在 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
}

事件監聽器

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

老鼠

首先,請監聽故事容器中的 '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')
})

瀏覽短片故事

現在要處理的是動態消息的獨特商業邏輯,以及動態消息聞名的 UX。這看起來很複雜,但我覺得只要逐行閱讀,就會發現其實很容易理解。

我們預先儲存一些選取器,協助判斷是否要捲動至朋友或顯示/隱藏動態消息。由於我們是在 HTML 中作業,因此會查詢是否有好友 (使用者) 或故事 (故事)。

這些變數有助於回答以下問題:「如果使用者正在觀看好友 X 的故事,點選『下一個』是會繼續觀看好友 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
    }
  }
}

立即試用

  • 如要預覽網站,請按下「查看應用程式」,然後按下「全螢幕」圖示 全螢幕

結論

這就是我對元件的需求。歡迎您以此為基礎,運用資料推動發展,打造專屬的應用程式!