Эта лаборатория кода научит вас, как создать в Интернете такой опыт, как Instagram Stories. Мы будем создавать компонент по ходу дела, начиная с HTML, затем CSS, затем JavaScript.
Прочтите мою публикацию в блоге «Создание компонента историй», чтобы узнать о прогрессивных улучшениях, внесенных при создании этого компонента.
Настраивать
- Нажмите Remix to Edit, чтобы сделать проект доступным для редактирования.
- Откройте
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">
нам нужен контейнер с горизонтальной прокруткой. Мы можем добиться этого путем:
- Превращение контейнера в сетку
- Настройка каждого дочернего элемента для заполнения дорожки строк
- Сделать ширину каждого дочернего элемента шириной области просмотра мобильного устройства.
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» и «Поведение при прокрутке» моего поста в блоге.
Чтобы согласиться на привязку прокрутки, требуется и родительский контейнер, и дочерние элементы, так что давайте разберемся с этим сейчас. Добавьте следующий код в конец файла 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;
}
Теперь, без абсолютного позиционирования, float или других директив макета, которые выводят элемент из потока, мы все еще находимся в потоке. Плюс, это почти никакой код, посмотрите на это! Более подробно это описано в видео и в блоге.
.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
Взаимодействие компонента «Истории» довольно просто для пользователя: нажмите справа, чтобы перейти вперед, нажмите слева, чтобы вернуться назад. Простые вещи для пользователей обычно становятся тяжелой работой для разработчиков. Однако мы обо многом позаботимся.
Настраивать
Для начала давайте вычислим и сохраним как можно больше информации. Добавьте следующий код в 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
}
}
}
Попробуйте это
- Чтобы просмотреть сайт, нажмите «Просмотреть приложение» . Затем нажмите Полноэкранный режим .
Заключение
Это подведение итогов того, что у меня было с этим компонентом. Не стесняйтесь развивать его, дополнять его данными и в целом сделать его своим!