Codelab: การสร้างคอมโพเนนต์เรื่องราว

Codelab นี้จะสอนวิธีสร้างประสบการณ์การใช้งานแบบ Instagram Stories บนเว็บ เราจะสร้างคอมโพเนนต์ไปเรื่อยๆ โดยเริ่มจาก HTML, CSS และ JavaScript

อ่านบล็อกโพสต์การสร้างคอมโพเนนต์เรื่องราวเพื่อดูข้อมูลเกี่ยวกับการปรับปรุงอย่างต่อเนื่องที่เกิดขึ้นขณะสร้างคอมโพเนนต์นี้

ตั้งค่า

  1. คลิกรีมิกซ์เพื่อแก้ไขเพื่อให้โปรเจ็กต์แก้ไขได้
  2. เปิด app/index.html

HTML

เรามุ่งมั่นที่จะใช้ HTML เชิงความหมายเสมอ เนื่องจากเพื่อนแต่ละคนจะมีเรื่องราวกี่เรื่องก็ได้ เราจึงคิดว่าการใช้องค์ประกอบ <section> สำหรับเพื่อนแต่ละคนและองค์ประกอบ <article> สำหรับเรื่องราวแต่ละเรื่องน่าจะเหมาะสม แต่มาเริ่มกันตั้งแต่ต้นกัน ก่อนอื่น เราต้องมีคอนเทนเนอร์สำหรับคอมโพเนนต์เรื่องราว

วิธีเพิ่มองค์ประกอบ <div> ลงใน <body>

<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) เพื่อช่วยสร้างต้นแบบเรื่องราว
  • แอตทริบิวต์ style ใน <article> แต่ละรายการเป็นส่วนหนึ่งของเทคนิคการโหลดตัวยึดตำแหน่ง ซึ่งคุณจะดูข้อมูลเพิ่มเติมได้ในส่วนถัดไป

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 นี้ในส่วนจุดหยุดของ Scroll CSS และ overscroll-behavior ของบล็อกโพสต์

ทั้งคอนเทนเนอร์หลักและคอนเทนเนอร์ย่อยต้องยอมรับการเลื่อนแบบ Snap เรามาจัดการเรื่องนี้กัน เพิ่มโค้ดต่อไปนี้ที่ด้านล่างของ 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

ตอนนี้เราแค่ต้องจัดสไตล์รายการเรื่องราว

ก่อนหน้านี้เราได้พูดถึงว่าแอตทริบิวต์ style บนองค์ประกอบ <article> แต่ละรายการเป็นส่วนหนึ่งของเทคนิคการโหลดตัวยึดตำแหน่ง ดังนี้

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

เราจะใช้พร็อพเพอร์ตี้ background-image ของ CSS ซึ่งช่วยให้เราระบุภาพพื้นหลังได้มากกว่า 1 รายการ เราสามารถจัดเรียงรูปภาพเพื่อให้รูปภาพผู้ใช้อยู่ด้านบนและจะแสดงขึ้นโดยอัตโนมัติเมื่อโหลดเสร็จ หากต้องการเปิดใช้ฟีเจอร์นี้ เราจะใส่ URL รูปภาพไว้ในพร็อพเพอร์ตี้ที่กำหนดเอง (--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 ที่เรียบร้อยซึ่งเรียกว่า loading tombstone ดังนี้

  • รูปภาพพื้นหลัง 1 (var(--bg)) คือ URL ที่เราส่งในบรรทัดใน HTML
  • รูปภาพพื้นหลัง 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) เป็นไล่ระดับสี) เพื่อแสดงขณะที่ URL กำลังโหลด

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 บอกให้เบราว์เซอร์ทราบว่าการโต้ตอบเหล่านี้ควรได้รับการพิจารณาว่าเป็นเหตุการณ์การแตะ ซึ่งจะช่วยให้เบราว์เซอร์ไม่ต้องพยายามตัดสินว่าคุณกำลังคลิก URL หรือไม่

สุดท้าย มาเพิ่ม 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 ลงในเรื่องราวที่ต้องมีทางออก ฉันได้ฟังก์ชันการเปลี่ยนค่าแบบกำหนดเอง (cubic-bezier(0.4, 0.0, 1,1)) จากคำแนะนำการเปลี่ยนค่าของ Material Design (เลื่อนไปที่ส่วนการเปลี่ยนค่าแบบเร่ง)

หากสังเกตดีๆ คุณอาจเห็น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
}

Listener

ตอนนี้เรามีตรรกะเพียงพอที่จะเริ่มฟังเหตุการณ์ของผู้ใช้และเปลี่ยนเส้นทางได้แล้ว

หนู

มาเริ่มด้วยการฟังเหตุการณ์ '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 เราจะค้นหาเพื่อน (ผู้ใช้) หรือเรื่องราว (story) ใน 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
    }
  }
}

ลองเลย

  • หากต้องการดูตัวอย่างเว็บไซต์ ให้กดดูแอป แล้วกดเต็มหน้าจอ เต็มหน้าจอ

บทสรุป

นี่เป็นสรุปความต้องการเกี่ยวกับคอมโพเนนต์ คุณปรับแต่ง ขับเคลื่อนด้วยข้อมูล และปรับให้เหมาะกับตัวเองได้