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

本程式碼研究室將說明如何在網頁上建構 Instagram 限時動態等體驗。建立元件的過程中,我們將先從 HTML、CSS 和 JavaScript 開始建構。

請參閱我的網誌文章「建構故事元件」,瞭解建構這個元件時所進行的漸進式強化項目。

設定

  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"> 容器,我們需要水平捲動容器。我們可以透過下列方式達成:

  • 將容器設為格線
  • 設定每個子項填入資料列軌
  • 將每個子項的寬度設為行動裝置可視區域的寬度

格線會繼續將新的 100vw 寬欄放在上一欄的右側,直到將所有 HTML 元素放入標記中為止。

開啟 Chrome 和開發人員工具,以格狀檢視模式顯示完整的寬度版面配置
Chrome 開發人員工具顯示格線欄溢位,會產生水平捲軸。

將以下 CSS 加到 app/css/index.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 捲動貼齊點」和「滑鼠遊標懸停行為」一節。

這會讓父項容器和子項同意捲動貼齊,因此現在要處理這個部分。請將下列程式碼加入 app/css/index.css 底部:

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

您的應用程式尚無法運作,但以下影片說明啟用和停用 scroll-snap-type 會有什麼影響。啟用後,每個水平捲動都會貼齊下一個故事。如果停用,瀏覽器就會使用預設的捲動行為。

這樣您就能捲動瀏覽好友,但解決故事時仍有問題。

.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,我們會將玻璃故事轉換為視窗,並竊取沒有其他的使用者互動行為。選在利弊之後 還得在 CSS 中管理我們沒有表現得在z-index。我感覺很不錯。

JavaScript

對使用者來說,故事元件的互動相當簡單:輕觸右側即可繼續,輕觸左側則可返回。對使用者而言,簡單的工作很難我們會處理很多問題。

設定

首先,請盡可能提高運算和儲存資訊。在 app/js/index.js 加入以下程式碼:

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

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

狀態

接下來,我們要製作一個與邏輯相關的小型物件,該物件會有與邏輯相關的狀態。在本案例中,我們只關注目前的故事。在 HTML 標記中,我們可擷取第一個好友及他們最新的報導來加以存取。將醒目顯示的程式碼新增至 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')
})

短片故事導覽

就該處理故事獨特的商業邏輯,以及他們越來越受歡迎的使用者體驗吧!這看起來有點複雜,但我認為如果您逐行列出,就會更容易理解。

首先,我們省略部分選取器,協助我們決定要捲動至好友,或顯示/隱藏故事。由於 HTML 是我們的工作場所,我們會查詢其是否有好友 (使用者) 或故事 (故事)。

這些變數可協助我們回答有關問題,像是「特許故事 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
    }
  }
}

立即體驗

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

結語

這個單元已完整滿足我對這項元件的需求。您不妨根據這些資料建構服務,運用資料發揮資料價值,大致上就是由您做主!