Codelab: Создание компонента «Истории»

В этой практической работе вы узнаете, как создать веб-приложение, подобное Instagram Stories. Мы разработаем компонент по ходу дела, начав с HTML, затем CSS и JavaScript.

Ознакомьтесь с записью в моем блоге Создание компонента Stories, чтобы узнать о последовательных улучшениях, внесенных в ходе разработки этого компонента.

Настраивать

  1. Нажмите «Ремикс для редактирования», чтобы сделать проект редактируемым.
  2. Откройте 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 в вашей разметке.

Chrome и DevTools открываются с сеткой, отображающей макет на всю ширину.
В Chrome DevTools показано переполнение столбцов сетки, что приводит к появлению горизонтальной полосы прокрутки.

Добавьте следующий 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, где строка и столбец имеют одинаковый псевдоним 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>

Мы воспользуемся свойством 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
    }
  }
}

Попробуйте это

  • Для предварительного просмотра сайта нажмите «Просмотреть приложение» . Затем нажмите «Полный экран». полноэкранный .

Заключение

Вот и всё, что я хотел сказать о компоненте. Можете смело его дорабатывать, дополнять данными и в целом использовать по своему усмотрению!