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

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

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

Настраивать

  1. Нажмите Remix to Edit, чтобы сделать проект доступным для редактирования.
  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"> нам нужен контейнер с горизонтальной прокруткой. Мы можем добиться этого путем:

  • Превращение контейнера в сетку
  • Настройка каждого дочернего элемента для заполнения дорожки строк
  • Сделать ширину каждого дочернего элемента шириной области просмотра мобильного устройства.

Grid будет продолжать размещать новые столбцы шириной 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, в которой строка и столбец имеют один и тот же псевдоним сетки [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
    }
  }
}

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

  • Чтобы просмотреть сайт, нажмите «Просмотреть приложение» . Затем нажмите Полноэкранный режим полноэкранный .

Заключение

Это подведение итогов того, что у меня было с этим компонентом. Не стесняйтесь развивать его, дополнять его данными и в целом сделать его своим!