Codelab นี้จะสอนวิธีสร้างประสบการณ์การใช้งานแบบ Instagram Stories บนเว็บ เราจะสร้างคอมโพเนนต์ไปเรื่อยๆ โดยเริ่มจาก HTML, CSS แล้วตามด้วย JavaScript
ดูบล็อกโพสต์การสร้างคอมโพเนนต์เรื่องราวเพื่อดูข้อมูลเกี่ยวกับการเพิ่มประสิทธิภาพแบบต่อเนื่องที่ทำขณะสร้างคอมโพเนนต์นี้
ตั้งค่า
- คลิกรีมิกซ์เพื่อแก้ไขเพื่อให้โปรเจ็กต์แก้ไขได้
- เปิด
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 ทั้งหมดในมาร์กอัป
เพิ่ม 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 Scroll Snap Points และ 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
}
Listeners
ตอนนี้เรามีตรรกะเพียงพอที่จะเริ่มฟังเหตุการณ์ของผู้ใช้และกำหนดทิศทางแล้ว
เมาส์
มาเริ่มด้วยการฟังเหตุการณ์ '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 เราจะค้นหา 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
}
}
}
ลองเลย
- หากต้องการดูตัวอย่างเว็บไซต์ ให้กดดูแอป แล้วกดเต็มหน้าจอ
บทสรุป
สรุปความต้องการที่ฉันมีเกี่ยวกับคอมโพเนนต์ คุณสามารถนำไปต่อยอด ขับเคลื่อนด้วยข้อมูล และปรับให้เหมาะกับคุณได้เลย