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