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

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

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

ตั้งค่า

  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"> เราต้องการคอนเทนเนอร์แบบเลื่อนแนวนอน เราจะทำได้โดยทำดังนี้

  • การเปลี่ยนคอนเทนเนอร์เป็นตารางกริด
  • ตั้งค่าให้บุตรหลานแต่ละคนเติมแทร็กแถว
  • กำหนดให้ความกว้างของแต่ละองค์ประกอบย่อยเป็นความกว้างของ Viewport ของอุปกรณ์เคลื่อนที่

Grid จะวางคอลัมน์ใหม่ที่มีความกว้าง 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 และoverscroll-behavior ในบล็อกโพสต์ของฉัน

ทั้งคอนเทนเนอร์หลักและคอนเทนเนอร์ย่อยต้องยอมรับการเลื่อนแบบสแนป ดังนั้น มาจัดการเรื่องนี้กันเลย เพิ่มโค้ดต่อไปนี้ที่ด้านล่างของ app/css/index.css

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

แอปของคุณยังใช้งานไม่ได้ แต่ในวิดีโอด้านล่างนี้จะแสดงให้เห็นสิ่งที่เกิดขึ้นเมื่อเปิดและปิดใช้ scroll-snap-type เมื่อเปิดใช้แล้ว การเลื่อนแนวนอนแต่ละครั้งจะเลื่อนไปที่เรื่องถัดไป เมื่อปิดใช้ เบราว์เซอร์จะใช้ลักษณะการเลื่อนเริ่มต้น

ซึ่งจะช่วยให้คุณเลื่อนดูเพื่อนๆ ได้ แต่เรายังคงมีปัญหาเกี่ยวกับสตอรี่ที่ต้องแก้ไข

.user

มาสร้างเลย์เอาต์ในส่วน .user ที่จัดการองค์ประกอบเรื่องราวของบุตรหลานเหล่านั้นให้เข้าที่กัน เราจะใช้เคล็ดลับการซ้อนที่สะดวกเพื่อแก้ปัญหานี้ เรากำลังสร้างตารางกริดขนาด 1x1 ซึ่งแถวและคอลัมน์มีนามแฝง Grid เดียวกันคือ [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 ที่เรียบร้อยซึ่งเรียกว่าเครื่องหมายหลุมศพที่กำลังโหลดได้

  • ภาพพื้นหลัง 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

การโต้ตอบของคอมโพเนนต์ Stories นั้นค่อนข้างง่ายสำหรับผู้ใช้ โดยแตะที่ด้านขวาเพื่อไปข้างหน้า และแตะที่ด้านซ้ายเพื่อย้อนกลับ สิ่งที่ดูเรียบง่ายสำหรับผู้ใช้มักเป็นงานที่ยากสำหรับนักพัฒนาแอป แต่เราจะดูแลให้เอง

ตั้งค่า

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

การไปยังส่วนต่างๆ ของเรื่องราว

ถึงเวลาที่จะจัดการกับตรรกะทางธุรกิจที่เป็นเอกลักษณ์ของ Stories และ UX ที่ทำให้ Stories โด่งดัง โค้ดนี้อาจดูซับซ้อนและยาก แต่ฉันคิดว่าหากคุณพิจารณาโค้ดทีละบรรทัด คุณจะพบว่าโค้ดนี้เข้าใจได้ง่าย

เราจะซ่อนตัวเลือกบางอย่างไว้ล่วงหน้าเพื่อช่วยในการตัดสินใจว่าจะเลื่อนไปหาเพื่อนหรือแสดง/ซ่อนสตอรี่ เนื่องจากเรากำลังทำงานใน 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
    }
  }
}

ลองเลย

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

บทสรุป

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