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