В этой практической работе вы узнаете, как создать веб-приложение, похожее на Instagram Stories. Мы будем создавать компонент по ходу дела, начиная с 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"> мы хотим использовать контейнер с горизонтальной прокруткой.
Для этого мы можем:
- Как сделать контейнер сеткой
- Настройка каждого дочернего элемента для заполнения дорожки строки
- Ширина каждого дочернего элемента равна ширине области просмотра мобильного устройства.
Сетка будет размещать новые столбцы шириной 100vw справа от предыдущего, пока не разместит все элементы HTML в вашей разметке.
Добавьте в конец файла app/css/index.css следующий код 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 и 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>
Мы будем использовать свойство CSS background-image, которое позволяет указать несколько фоновых изображений. Мы можем расположить их в таком порядке, чтобы изображение пользователя было сверху и автоматически появлялось после завершения загрузки. Для этого мы поместим 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, в области просмотра не будет пустого пространства, поскольку изображение заполнит ее целиком. Определение двух фоновых изображений позволяет нам использовать удобный веб-прием 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')
})
Навигация по историям
Теперь давайте разберемся с уникальной бизнес-логикой историй и 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
}
}
}
Попробовать
- Чтобы посмотреть сайт, нажмите View App (Посмотреть приложение), а затем – Fullscreen (Полноэкранный режим)
.
Заключение
Это все, что мне нужно было сделать с компонентом. Не стесняйтесь развивать его, управлять им с помощью данных и в целом делать его своим!