Ćwiczenia z programowania: tworzenie komponentu Relacje

Z tego kursu dowiesz się, jak utworzyć w internecie funkcję podobną do relacji na Instagramie. Komponent będziemy tworzyć stopniowo, zaczynając od HTML, potem CSS i JavaScript.

Więcej informacji o ulepszeniach wprowadzonych podczas tworzenia tego komponentu znajdziesz w moim poście na blogu Building a Stories component.

Konfiguracja

  1. Kliknij Remiksuj, aby edytować, aby umożliwić edycję projektu.
  2. Otwórz pokój app/index.html.

HTML

Zawsze staram się używać semantycznych znaczników HTML. Każdy znajomy może mieć dowolną liczbę historii, dlatego uznałem, że warto użyć elementu <section> dla każdego znajomego i elementu <article> dla każdej historii. Zacznijmy jednak od początku. Najpierw potrzebujemy kontenera dla komponentu historii.

Dodaj element <div> do <body>:

<div class="stories">

</div>

Dodaj kilka elementów <section>, które będą reprezentować znajomych:

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

Dodaj kilka elementów <article> reprezentujących historie:

<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>
  • Do tworzenia prototypów artykułów używamy usługi obrazów (picsum.com).
  • Atrybut style w każdym <article> jest częścią techniki ładowania elementów zastępczych, o której dowiesz się więcej w następnej sekcji.

CSS

Nasze treści są gotowe do zastosowania stylu. Zmieńmy te kości w coś, z czym ludzie będą chcieli wchodzić w interakcje. Dziś będziemy pracować w trybie mobilnym.

.stories

W przypadku kontenera <div class="stories"> chcemy, aby przewijanie było poziome. Możemy to osiągnąć, wykonując te czynności:

  • Przekształcanie kontenera w siatkę
  • Ustawienie każdego elementu podrzędnego tak, aby wypełniał ścieżkę wiersza
  • Ustawienie szerokości każdego elementu podrzędnego na szerokość obszaru wyświetlania urządzenia mobilnego

Siatka będzie nadal umieszczać nowe kolumny o szerokości 100vw po prawej stronie poprzedniej, dopóki nie umieści wszystkich elementów HTML w Twoim znaczniku.

Chrome i Narzędzia deweloperskie otwarte z siatką pokazującą układ o pełnej szerokości
Narzędzia deweloperskie w Chrome pokazujące przepełnienie kolumny siatki, co powoduje pojawienie się przewijania w poziomie.

Dodaj ten kod CSS na końcu pliku app/css/index.css:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

Teraz, gdy mamy już treść wykraczającą poza widoczny obszar, musimy poinformować kontener, jak ją obsługiwać. Dodaj do zestawu reguł .stories wyróżnione wiersze kodu:

.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;
}

Chcemy przewijania poziomego, więc ustawimy overflow-x na auto. Gdy użytkownik przewija, chcemy, aby komponent delikatnie zatrzymywał się na następnej historii, dlatego użyjemy scroll-snap-type: x mandatory. Więcej informacji o tym CSS znajdziesz w sekcjach CSS Scroll Snap Points i overscroll-behavior w moim poście na blogu.

Zarówno kontener nadrzędny, jak i elementy podrzędne muszą wyrazić zgodę na przyciąganie przewijania, więc zajmijmy się tym teraz. Dodaj ten kod na końcu pliku app/css/index.css:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

Twoja aplikacja jeszcze nie działa, ale poniższy film pokazuje, co się dzieje, gdy funkcja scroll-snap-type jest włączona i wyłączona. Gdy ta funkcja jest włączona, każde przewinięcie w poziomie powoduje przejście do następnej historii. Gdy ta opcja jest wyłączona, przeglądarka używa domyślnego działania przewijania.

W ten sposób przejdziesz do listy znajomych, ale nadal mamy problem z historiami do rozwiązania.

.user

Utwórzmy układ w sekcji .user, który umieści te elementy podrzędne w odpowiednich miejscach. Aby to zrobić, użyjemy sprytnej sztuczki z nakładaniem. Tworzymy w zasadzie siatkę 1x1, w której wiersz i kolumna mają ten sam alias Grid: [story]. Każdy element siatki historii będzie próbował zająć to miejsce, co spowoduje utworzenie stosu.

Dodaj zaznaczony kod do zestawu reguł .user:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

Na końcu pliku app/css/index.css dodaj ten zestaw reguł:

.story {
  grid-area: story;
}

Teraz, bez pozycjonowania bezwzględnego, elementów pływających ani innych dyrektyw układu, które wyjmują element z przepływu, nadal jesteśmy w przepływie. Poza tym to prawie żaden kod, spójrz! Szczegółowe informacje znajdziesz w filmie i poście na blogu.

.story

Teraz musimy tylko ostylować sam element historii.

Wcześniej wspomnieliśmy, że atrybut style w każdym elemencie <article> jest częścią techniki ładowania elementów zastępczych:

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

Użyjemy właściwości background-image CSS, która pozwala określić więcej niż 1 obraz tła. Możemy je uporządkować tak, aby zdjęcie użytkownika było na górze i wyświetlało się automatycznie po załadowaniu. Aby to zrobić, umieścimy adres URL obrazu we właściwości niestandardowej (--bg) i użyjemy go w kodzie CSS, aby nałożyć go na symbol zastępczy ładowania.

Najpierw zaktualizujmy .story zestaw reguł, aby po zakończeniu wczytywania zastąpić gradient obrazem tła. Dodaj zaznaczony kod do zestawu reguł .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

Ustawienie background-size na cover zapewnia, że w obszarze wyświetlania nie będzie pustego miejsca, ponieważ wypełni go nasz obraz. Zdefiniowanie 2 obrazów tła umożliwia zastosowanie sprytnej sztuczki CSS o nazwie loading tombstone:

  • Obraz tła 1 (var(--bg)) to adres URL przekazany w kodzie HTML.
  • Obraz tła 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) to gradient wyświetlany podczas wczytywania adresu URL

Gdy obraz zostanie pobrany, CSS automatycznie zastąpi nim gradient.

Następnie dodamy trochę kodu CSS, aby usunąć niektóre zachowania, co przyspieszy działanie przeglądarki. Dodaj zaznaczony kod do zestawu reguł .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 uniemożliwia użytkownikom przypadkowe zaznaczenie tekstu.
  • touch-action: manipulation informuje przeglądarkę, że te interakcje powinny być traktowane jako zdarzenia dotyku, co zwalnia przeglądarkę z konieczności decydowania, czy klikasz adres URL, czy nie.

Na koniec dodajmy trochę kodu CSS, aby animować przejście między opowiadaniami. Dodaj wyróżniony kod do zestawu reguł .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;
  }
}

Klasa .seen zostanie dodana do relacji, która wymaga zamknięcia. Niestandardową funkcję łagodzenia (cubic-bezier(0.4, 0.0, 1,1)) zaczerpnąłem z przewodnika Easing w Material Design (przewiń do sekcji Accerlerated easing).

Jeśli masz sokoli wzrok, prawdopodobnie zauważyłeś(-aś) pointer-events: nonedeklarację i zastanawiasz się teraz, o co chodzi. To jedyna wada tego rozwiązania. Jest to konieczne, ponieważ element .seen.story będzie na wierzchu i będzie odbierać kliknięcia, mimo że jest niewidoczny. Ustawiając wartość pointer-events na none, zamieniamy historię szkła w okno i nie przechwytujemy już interakcji użytkownika. Nie jest to zbyt duży kompromis i nie jest to zbyt trudne do zarządzania w naszej usłudze porównywania cen. Nie żonglujemy z-index. Nadal mam dobre przeczucia.

JavaScript

Interakcje z komponentem Stories są dla użytkownika dość proste: dotknij prawej strony, aby przejść dalej, a lewej, aby wrócić. Proste rozwiązania dla użytkowników zwykle wymagają od deweloperów dużo pracy. Jednak w wielu przypadkach to my się tym zajmiemy.

Konfiguracja

Na początek obliczmy i zapiszmy jak najwięcej informacji. Dodaj do pliku app/js/index.js ten kod:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

Pierwsza linia kodu JavaScript pobiera i przechowuje odwołanie do głównego elementu HTML. Kolejna linia oblicza, gdzie znajduje się środek elementu, dzięki czemu możemy określić, czy kliknięcie ma spowodować przejście do przodu czy do tyłu.

Stan

Następnie tworzymy mały obiekt z pewnym stanem istotnym dla naszej logiki. W tym przypadku interesuje nas tylko bieżąca historia. W naszym znaczniku HTML możemy uzyskać do niego dostęp, pobierając pierwszego znajomego i jego najnowszą historię. Dodaj wyróżniony kod do pliku app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

Detektory

Mamy już wystarczającą logikę, aby zacząć nasłuchiwać zdarzeń użytkownika i kierować nimi.

Mysz

Zacznijmy od nasłuchiwania zdarzenia 'click' w kontenerze historii. Dodaj wyróżniony kod do pliku 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')
})

Jeśli kliknięcie nastąpi w miejscu, które nie jest elementem <article>, przerywamy działanie i nic nie robimy. Jeśli jest to artykuł, pobieramy położenie poziome myszy lub palca za pomocą funkcji clientX. Nie zaimplementowaliśmy jeszcze funkcji navigateStories, ale argument, który przyjmuje, określa, w jakim kierunku musimy się poruszać. Jeśli pozycja użytkownika jest większa niż mediana, wiemy, że musimy przejść do next, w przeciwnym razie do prev (poprzednia).

Klawiatura

Teraz posłuchajmy naciśnięć klawiszy. Jeśli naciśniesz strzałkę w dół, przejdziesz do next. Jeśli jest to strzałka w górę, przechodzimy do stanu prev.

Dodaj wyróżniony kod do pliku 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')
})

Nawigacja po relacjach

Czas na zajęcie się unikalną logiką biznesową relacji i UX, z którego stały się znane. Wygląda to na skomplikowane, ale jeśli przeanalizujesz to wiersz po wierszu, zobaczysz, że jest to całkiem proste.

Z góry zapisujemy niektóre selektory, które pomagają nam zdecydować, czy przewinąć do znajomego, czy pokazać lub ukryć historię. Ponieważ pracujemy w HTML-u, będziemy wyszukiwać w nim znajomych (użytkowników) lub historie (story).

Te zmienne pomogą nam odpowiedzieć na pytania takie jak: „czy w przypadku historii x „dalej” oznacza przejście do innej historii tego samego znajomego czy innego znajomego?”. Zrobiłem to, korzystając z zbudowanej przez nas struktury drzewa, docierając do rodziców i ich dzieci.

Dodaj ten kod na końcu pliku 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
}

Oto nasz cel dotyczący logiki biznesowej, opisany w sposób jak najbardziej zbliżony do języka naturalnego:

  • Określ, jak ma być obsługiwane kliknięcie.
    • Jeśli jest następna lub poprzednia relacja: wyświetl ją.
    • Jeśli jest to ostatnia lub pierwsza historia znajomego: pokaż nowego znajomego.
    • Jeśli w tym kierunku nie ma żadnej historii: nic nie rób.
  • Przenieś nową bieżącą relację do state

Dodaj wyróżniony kod do funkcji 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
    }
  }
}

Wypróbuj

  • Aby wyświetlić podgląd strony, kliknij Wyświetl aplikację, a następnie Pełny ekran pełny ekran.

Podsumowanie

To wszystko, czego potrzebowałem w przypadku tego komponentu. Możesz go rozbudowywać, wykorzystywać w nim dane i dostosowywać go do swoich potrzeb.