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

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