Lớp học lập trình này hướng dẫn bạn cách xây dựng một trải nghiệm như Instagram Stories (Tin trên Instagram) trên web. Chúng ta sẽ tạo thành phần trong quá trình tạo, bắt đầu bằng HTML, sau đó đến CSS, sau đó là JavaScript.
Hãy xem bài đăng Xây dựng thành phần Stories trên blog của tôi để tìm hiểu về các điểm cải tiến tiến bộ được thực hiện khi xây dựng thành phần này.
Thiết lập
- Nhấp vào Remix để chỉnh sửa (Remix) để chỉnh sửa dự án.
- Mở
app/index.html
.
HTML
Tôi luôn cố gắng sử dụng HTML ngữ nghĩa.
Vì mỗi người bạn có thể có số lượng câu chuyện bất kỳ, nên tôi nghĩ sẽ có ý nghĩa khi sử dụng phần tử <section>
cho mỗi người bạn và một phần tử <article>
cho mỗi câu chuyện.
Hãy bắt đầu lại từ đầu. Trước tiên, chúng ta cần một vùng chứa cho thành phần câu chuyện.
Thêm phần tử <div>
vào <body>
:
<div class="stories">
</div>
Thêm một số phần tử <section>
để đại diện cho bạn bè:
<div class="stories">
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
</div>
Thêm một số phần tử <article>
để đại diện cho tin bài:
<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>
- Chúng tôi đang sử dụng dịch vụ hình ảnh (
picsum.com
) để giúp tạo câu chuyện mẫu. - Thuộc tính
style
trên mỗi<article>
là một phần của kỹ thuật tải phần giữ chỗ. Bạn sẽ tìm hiểu thêm về kỹ thuật này trong phần tiếp theo.
CSS
Nội dung của chúng tôi đã sẵn sàng để thể hiện phong cách. Hãy biến những xương đó thành nội dung mà mọi người sẽ muốn tương tác. Hôm nay, chúng ta sẽ ưu tiên làm việc trên thiết bị di động.
.stories
Đối với vùng chứa <div class="stories">
, chúng ta cần có một vùng chứa cuộn theo chiều ngang.
Chúng tôi có thể đạt được điều này bằng cách:
- Tạo vùng chứa thành Lưới
- Thiết lập để từng trẻ điền vào theo dõi hàng
- Đặt chiều rộng của mỗi thành phần con bằng chiều rộng của khung nhìn trên thiết bị di động
Lưới sẽ tiếp tục đặt các cột mới rộng 100vw
ở bên phải cột trước đó cho đến khi đặt tất cả các phần tử HTML vào mục đánh dấu.
Thêm CSS sau vào cuối app/css/index.css
:
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
}
Giờ đây, khi chúng ta đã có nội dung vượt ra khỏi khung nhìn, đã đến lúc cho vùng chứa đó biết cách xử lý. Thêm các dòng mã được đánh dấu vào bộ quy tắc .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;
}
Chúng ta muốn cuộn theo chiều ngang, vì vậy chúng ta sẽ đặt overflow-x
thành auto
. Khi người dùng cuộn, chúng ta muốn thành phần này nhẹ nhàng nghỉ ngơi trong câu chuyện tiếp theo, vì vậy, chúng ta sẽ sử dụng scroll-snap-type: x mandatory
. Đọc thêm về CSS này trong các phần CSS Scroll Snap Points và hành vi cuộn qua trong bài đăng trên blog của tôi.
Cả vùng chứa mẹ và thành phần con đều phải đồng ý với thao tác cuộn chụp nhanh, vì vậy hãy xử lý việc đó ngay bây giờ. Thêm mã sau vào cuối app/css/index.css
:
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
}
Ứng dụng của bạn chưa hoạt động nhưng video dưới đây cho thấy những gì sẽ xảy ra khi scroll-snap-type
được bật và tắt. Khi được bật, mỗi thanh cuộn theo chiều ngang sẽ chuyển đến câu chuyện tiếp theo. Khi bạn tắt, trình duyệt sẽ sử dụng hành vi cuộn mặc định.
Như vậy, bạn sẽ có thể lướt xem bạn bè, nhưng vẫn còn một vấn đề với những câu chuyện cần giải quyết.
.user
Hãy tạo một bố cục trong phần .user
để sắp xếp các thành phần của câu chuyện con đó. Chúng ta sẽ dùng một thủ thuật xếp chồng hữu ích để giải quyết vấn đề này.
Về cơ bản, chúng ta sẽ tạo một lưới 1x1, trong đó hàng và cột có cùng bí danh Lưới là [story]
và mỗi mục trong lưới câu chuyện sẽ thử và xác nhận không gian đó, dẫn đến một ngăn xếp.
Thêm mã được đánh dấu vào bộ quy tắc .user
của bạn:
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
display: grid;
grid: [story] 1fr / [story] 1fr;
}
Thêm bộ quy tắc sau vào cuối app/css/index.css
:
.story {
grid-area: story;
}
Giờ đây, dù không có vị trí tuyệt đối, số thực có độ chính xác đơn hoặc các lệnh bố cục khác làm mất một phần tử ngoài luồng, chúng ta vẫn đang hoạt động. Thêm vào đó, gần như không có mã nào, hãy nhìn vào đó đi! Điều này được phân tích chi tiết hơn trong video và bài đăng trên blog.
.story
Bây giờ, chúng ta chỉ cần tạo kiểu cho mục trong câu chuyện.
Trước đó, chúng tôi đã đề cập rằng thuộc tính style
trên mỗi phần tử <article>
là một phần của kỹ thuật tải phần giữ chỗ:
<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
Chúng tôi sẽ sử dụng thuộc tính background-image
của CSS, cho phép chúng ta chỉ định nhiều hình nền. Chúng ta có thể sắp xếp các biểu tượng này theo thứ tự sao cho ảnh người dùng
ở trên cùng và sẽ tự động xuất hiện khi tải xong. Để bật tính năng này, chúng ta sẽ đặt URL hình ảnh vào một thuộc tính tuỳ chỉnh (--bg
) và sử dụng URL đó trong CSS để tạo lớp cùng với phần giữ chỗ tải.
Trước tiên, hãy cập nhật bộ quy tắc .story
để thay thế hiệu ứng chuyển màu bằng hình nền sau khi tải xong. Thêm mã được đánh dấu vào bộ quy tắc .story
của bạn:
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}
Việc đặt background-size
thành cover
sẽ đảm bảo không có không gian trống trong khung nhìn vì hình ảnh của chúng ta sẽ lấp đầy hình ảnh. Khi xác định 2 hình nền, chúng ta có thể kéo một thủ thuật web CSS gọn gàng có tên là tải tombstone:
- Hình nền 1 (
var(--bg)
) là URL chúng tôi đã chuyển cùng dòng trong HTML - Hình nền 2 (
linear-gradient(to top, lch(98 0 0), lch(90 0 0))
là hiệu ứng chuyển màu để hiển thị khi URL đang tải
CSS sẽ tự động thay thế hiệu ứng chuyển màu bằng hình ảnh sau khi tải xuống xong.
Tiếp theo, chúng ta sẽ thêm một số CSS để xoá một số hành vi, giúp trình duyệt hoạt động nhanh hơn.
Thêm mã được đánh dấu vào bộ quy tắc .story
của bạn:
.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
giúp ngăn người dùng vô tình chọn văn bảntouch-action: manipulation
hướng dẫn trình duyệt rằng các hoạt động tương tác này phải được coi là sự kiện chạm, việc này sẽ giúp trình duyệt không phải quyết định xem bạn có đang nhấp vào một URL hay không
Cuối cùng, hãy thêm một chút CSS để tạo hiệu ứng chuyển đổi giữa các câu chuyện. Thêm mã được đánh dấu vào bộ quy tắc .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;
}
}
Lớp .seen
sẽ được thêm vào một câu chuyện cần thoát.
Tôi đã nhận được hàm gia tốc tuỳ chỉnh (cubic-bezier(0.4, 0.0, 1,1)
) trong hướng dẫn Gia tốc của Material Design (cuộn đến phần Tăng tốc độ tăng tốc).
Nếu tinh mắt, có thể bạn đã nhận thấy phần khai báo pointer-events: none
và hiện đang gật đầu. Tôi có thể nói rằng đây là nhược điểm
duy nhất của giải pháp này cho đến nay. Chúng ta cần điều này vì phần tử .seen.story
sẽ nằm ở trên cùng và sẽ nhận được các lượt nhấn, mặc dù phần tử đó không hiển thị. Bằng cách đặt pointer-events
thành none
, chúng tôi sẽ biến quá trình chuyển đổi thành một cửa sổ và không còn ăn cắp các hoạt động tương tác của người dùng nữa. Đó không hẳn là một sự đánh đổi, cũng không quá khó để quản lý
trong CSS của chúng tôi ngay bây giờ. Chúng tôi không kết hợp z-index
. Tôi vẫn cảm thấy
mình vẫn hài lòng.
JavaScript
Cách tương tác của một thành phần Stories khá đơn giản với người dùng: nhấn vào bên phải để chuyển đến, nhấn vào bên trái để quay lại. Những thứ đơn giản đối với người dùng thường là công việc khó khăn đối với nhà phát triển. Dù vậy, chúng tôi sẽ xử lý rất nhiều phần việc này.
Thiết lập
Để bắt đầu, hãy tính toán và lưu trữ nhiều thông tin nhất có thể.
Thêm mã sau vào app/js/index.js
:
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
Dòng JavaScript đầu tiên của chúng tôi lấy và lưu trữ tệp tham chiếu đến gốc của phần tử HTML chính. Dòng tiếp theo tính toán vị trí giữa của phần tử, vì vậy, chúng ta có thể quyết định xem một lần nhấn sẽ tiến hay lùi.
Tiểu bang
Tiếp theo, chúng ta tạo một đối tượng nhỏ có trạng thái phù hợp với logic của mình. Trong trường hợp này, chúng ta chỉ quan tâm đến tin bài hiện tại. Trong mã đánh dấu HTML, chúng ta có thể truy cập mã này bằng cách chọn người bạn thứ nhất và câu chuyện mới nhất của họ. Thêm mã được làm nổi bật vào app/js/index.js
của bạn:
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
Người nghe
Hiện chúng ta có đủ logic để bắt đầu theo dõi các sự kiện của người dùng và hướng dẫn họ.
Chuột
Hãy bắt đầu bằng cách theo dõi sự kiện 'click'
trên vùng chứa story của chúng ta.
Thêm mã đã đánh dấu vào 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')
})
Nếu một lượt nhấp xảy ra nhưng lượt nhấp đó không nằm trên phần tử <article>
, thì chúng tôi sẽ bảo lãnh và không làm gì cả.
Nếu đó là một bài viết, chúng ta sẽ lấy vị trí theo chiều ngang của chuột hoặc ngón tay bằng clientX
. Chúng tôi chưa triển khai navigateStories
, nhưng đối số mà đối số này xác định hướng chúng ta cần đi. Nếu vị trí người dùng đó lớn hơn trung vị, chúng ta sẽ biết mình cần chuyển đến next
, nếu không thì prev
(trước đó).
Bàn phím
Bây giờ, hãy cùng nghe thao tác nhấn bàn phím. Nếu nhấn Mũi tên xuống, chúng ta sẽ di chuyển đến next
. Nếu đó là Mũi tên lên, chúng ta sẽ chuyển đến prev
.
Thêm mã đã đánh dấu vào 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')
})
Điều hướng câu chuyện
Đã đến lúc xử lý logic kinh doanh độc đáo của những câu chuyện và trải nghiệm người dùng mà những câu chuyện này đã trở nên nổi tiếng. Câu này có vẻ rườm rà và phức tạp, nhưng tôi cho rằng nếu bạn đọc từng dòng một thì bạn sẽ thấy nội dung này khá dễ hiểu.
Ở phía trước, chúng tôi lưu trữ một số bộ chọn giúp chúng tôi quyết định cuộn đến một người bạn hay hiển thị/ẩn tin bài. Vì HTML là nơi chúng ta đang xử lý, nên chúng ta sẽ truy vấn HTML này xem có sự hiện diện của bạn bè (người dùng) hoặc những câu chuyện (câu chuyện) hay không.
Những biến này sẽ giúp chúng ta trả lời những câu hỏi như "cho câu chuyện x, "tiếp theo" có nghĩa là chuyển sang một câu chuyện khác từ người bạn này hay người bạn khác?" Tôi đã làm điều này bằng cách sử dụng cấu trúc cây chúng tôi đã xây dựng, tiếp cận các bậc cha mẹ và con của họ.
Thêm mã sau vào cuối 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
}
Dưới đây là mục tiêu logic kinh doanh của chúng ta, càng gần với ngôn ngữ tự nhiên càng tốt:
- Quyết định cách xử lý thao tác nhấn
- Nếu có câu chuyện tiếp theo/trước đó: hiển thị câu chuyện đó
- Nếu đó là câu chuyện cuối cùng/câu chuyện đầu tiên của người bạn đó: hãy cho một người bạn mới xem
- Nếu không có câu chuyện nào để đi theo hướng đó: không làm gì cả
- Lưu trữ truyện mới hiện tại vào
state
Thêm mã được đánh dấu vào hàm 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
}
}
}
Dùng thử
- Để xem trước trang web, hãy nhấn vào View App (Xem ứng dụng), sau đó nhấn vào Fullscreen (Toàn màn hình).
Kết luận
Đó là thông tin tóm tắt về những nhu cầu tôi có đối với thành phần này. Hãy thoải mái xây dựng dựa trên nó, lái xe với dữ liệu và nhìn chung, biến nó thành của bạn!