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

Codelab นี้จะสอนวิธีสร้างประสบการณ์การใช้งานอย่างเช่นสตอรี่ของ Instagram บนเว็บ เราจะสร้างคอมโพเนนต์นี้ไปเรื่อยๆ โดยเริ่มจาก 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 นี้ในส่วน คะแนนการเลื่อนของ CSS และส่วน overscroll-behavior ในบล็อกโพสต์ของฉัน

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

ตัวแปรเหล่านี้จะช่วยเราในการตอบคำถาม เช่น "ถ้าเรื่อง 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
    }
  }
}

ลองเลย

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

บทสรุป

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