Ćwiczenia z programowania: tworzenie komponentu Relacje

Z tego kursu dowiesz się, jak tworzyć w internecie treści podobne do Instagram Stories. Będziemy tworzyć komponent na bieżąco, zaczynając od HTML, potem CSS, a na końcu JavaScript.

Informacje o postępujących ulepszeniach wprowadzanych podczas tworzenia tego komponentu znajdziesz w poście na blogu o tworzeniu komponentu Relacje.

Konfiguracja

  1. Aby udostępnić projekt do edycji, kliknij Remiksuj, aby edytować.
  2. Otwórz pokój app/index.html.

HTML

Zawsze staram się używać semantycznych znaczników HTML. Ponieważ każdy znajomy może mieć dowolną liczbę historii, 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 relacji.

Dodaj element <div> do elementu <body>:

<div class="stories">

</div>

Dodaj elementy <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 elementy <article>, aby reprezentować 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 historii używamy usługi dotyczącej obrazów (picsum.com).
  • Atrybut style w każdym elemencie <article> jest częścią metody wczytywania zastępczego, o której dowiesz się więcej w następnej sekcji.

CSS

Nasze treści są zawsze w dobrym stylu. Przekształcmy te kości w coś, z czym użytkownicy będą chcieli wchodzić w interakcje. Już dziś skupimy się na urządzeniach mobilnych.

.stories

W przypadku kontenera <div class="stories"> chcemy użyć poziomego kontenera z przewijaniem. Możemy to osiągnąć, wykonując te czynności:

  • Utworzenie kontenera jako siatki
  • Ustawienie każdego dziecka w celu wypełnienia ścieżki wiersza
  • Ustaw szerokość każdego elementu potomnego na szerokość obszaru widoku urządzenia mobilnego.

Użyta siatka będzie umieszczać nowe kolumny o szerokości 100vw po prawej stronie poprzedniej, aż do umieszczenia wszystkich elementów HTML w sprawie.

Chrome i Narzędzia deweloperskie otwierają się z wizualizacją siatki pokazującą układ na całą szerokość
Narzędzia deweloperskie w Chrome wyświetlają przepełnioną kolumnę siatki, tworząc suwak poziomy.

Dodaj ten kod CSS na dole pliku app/css/index.css:

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

Teraz, gdy mamy już zawartość wykraczającą poza widoczny obszar, musimy poinformować kontener, jak ma sobie z tym poradzić. Dodaj wyróżnione wiersze kodu do reguł .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;
}

Chcemy przewijać w poziomie, więc ustawiamy wartość overflow-x na auto. Gdy użytkownik przewija stronę, chcemy, aby komponent delikatnie przylegał do następnego artykułu, dlatego użyjemy właściwości scroll-snap-type: x mandatory. Więcej informacji na ten temat znajdziesz w postach na blogu w sekcjach Punkty zaczepienia przewijania CSSoverscroll-behavior.

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

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

Twoja aplikacja jeszcze nie działa, ale z filmu poniżej dowiesz się, co się dzieje, gdy włączysz i wyłączysz funkcję scroll-snap-type. Gdy ta opcja jest włączona, każde przewinięcie w poziomie powoduje przejście do następnego artykułu. Gdy ta opcja jest wyłączona, przeglądarka używa domyślnego zachowania przewijania.

Możesz przeglądać listę znajomych, ale wciąż mamy problem z historią do rozwiązania.

.user

Utwórzmy w sekcji .user układ, który umieści te podrzędne elementy osi czasu. Aby to zrobić, użyjemy przydatnego triku. Tworzymy w istocie siatkę 1 x 1, w której wiersz i kolumna mają ten sam alias siatki [story], a każdy element siatki w historii będzie próbował zająć tę przestrzeń, co spowoduje utworzenie stosu.

Dodaj wyróżniony kod do reguł .user:

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

Dodaj te reguły na dole pliku app/css/index.css:

.story {
  grid-area: story;
}

Bez pozycjonowania bezwzględnego, elementów pływających i innych dyrektyw układu, które wyciągają element poza przepływ, nadal jesteśmy w przepływie. Poza tym prawie nie ma kodu. Więcej informacji znajdziesz w filmie i poście na blogu.

.story

Teraz musimy nadać styl samemu elementowi historii.

Wspominaliśmy już, że atrybut style w każdym elemencie <article> jest częścią techniki ładowania zastępników:

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

Użyjemy właściwości background-image w CSS, która pozwala nam określić więcej niż 1 obraz tła. Możemy je ułożyć w takiej kolejności, by obraz użytkownika znajdował się na górze, a po wczytaniu pojawiał się automatycznie. Aby to umożliwić, umieścimy adres URL obrazu w właściwości niestandardowej (--bg) i użyjemy go w plikach CSS, aby nałożyć go na element zastępczy ładowania.

Najpierw zaktualizuj reguły .story, aby zastąpić gradient obrazem tła. Zrobisz to po zakończeniu wczytywania. Dodaj wyróżniony kod do 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 wartości background-size na cover sprawia, że w widoku nie ma pustego miejsca, ponieważ obraz wypełnia cały obszar. Zdefiniowanie 2 obrazów tła umożliwia nam użycie fajnego triku CSS o nazwie loading tombstone (ładowanie nagrobka):

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

Po zakończeniu pobierania obrazu CSS automatycznie zastąpi gradient obrazem.

Dodamy kod CSS, by usunąć pewne zachowania i zwiększyć szybkość działania 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 zaznaczanie tekstu
  • touch-action: manipulation informuje przeglądarkę, że te interakcje powinny być traktowane jako zdarzenia dotykowe, co uwalnia przeglądarkę od próby określenia, czy klikasz adres URL.

Na koniec dodamy trochę kodu CSS, aby animować przejście między opowieściami. 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 historii, która wymaga zakończenia. Funkcję wygładzania niestandardowego (cubic-bezier(0.4, 0.0, 1,1)) uzyskałem z instrukcji wygładzania w Material Design (przewiń do sekcji Wygładzanie przyspieszone).

Jeśli masz bystry wzrok, prawdopodobnie zauważysz deklarację pointer-events: nonei zaczniesz się zastanawiać, co to oznacza. To chyba jedyna wada tego rozwiązania. Potrzebujemy tego, ponieważ element .seen.story będzie widoczny na górze i będzie otrzymywać kliknięcia, mimo że jest niewidoczny. Ustawiając pointer-events na none, zmieniamy szklankę w okno i nie powodujemy już kradzieży interakcji z użytkownikiem. To nie jest zbyt duży kompromis, a zarządzanie w naszej usłudze porównywania cen nie jest obecnie zbyt trudne. Nie żonglujemy urządzeniem z-index. Nadal jestem zadowolony z tego.

JavaScript

Interakcje z komponentem Stories są dla użytkownika bardzo proste: do przesuwania do przodu należy dotykać prawej strony, a do cofnięcia – lewej. Proste rzeczy dla użytkowników często wymagają od deweloperów sporo pracy. My zajmiemy się większością z nich.

Konfiguracja

Na początek zapiszmy i przechowujemy 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)

Pierwszy wiersz kodu JavaScript pobiera i przechowuje odwołanie do głównego elementu HTML. W następnej linii znajduje się środek elementu, dzięki czemu możemy zdecydować, czy kliknięcie ma przejść do przodu czy do tyłu.

Stan

Następnie tworzymy mały obiekt z niektórymi stanami odpowiednimi dla naszej logiki. W tym przypadku interesuje nas tylko bieżąca historia. W naszym znaczniku HTML możemy uzyskać dostęp do pierwszego znajomego i jego najnowszej 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
}

Detektory

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

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 nie dotyczy elementu <article>, nie wykonujemy żadnych działań. Jeśli jest to artykuł, clientX przechwytuje pozycję poziomą myszy lub palca. Nie wdrożyliśmy jeszcze funkcji navigateStories, ale jej argument określa kierunek, w którym powinniśmy iść. Jeśli pozycja użytkownika jest wyższa niż mediana, wiemy, że musimy przejść do next. W przeciwnym razie prev (poprzednio).

Klawiatura

Teraz posłuchajmy, jak działają klawisze. Po naciśnięciu strzałki w dół przechodzimy do next. Jeśli jest to strzałka w górę, przechodzimy do 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 w relacjach

Czas zająć się wyjątkową logiką biznesową historii i UX, z którego są sławne. Wydaje się to ciężkie i skomplikowane, ale myślę, że jeśli po kolei pisze się kolejne linijki, uda Ci się zwięźle.

Na początku wczytujemy selektory, które pomagają nam zdecydować, czy przewinąć do znajomego, czy pokazać lub ukryć historię. Pracujemy nad kodem HTML, więc zapytamy go o obecność znajomych (użytkowników) lub artykułów (artykułów).

Te zmienne pomogą nam uzyskać odpowiedzi na pytania w rodzaju „Czy w przypadku danej relacji x polecenie „dalej” oznacza przejście do innej relacji tego samego znajomego czy do relacji innego znajomego?”. Zrobiliśmy to, korzystając z naszej hierarchicznej struktury, która obejmuje rodziców i ich dzieci.

Dodaj ten kod na dole 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 związany z logiką biznesową, maksymalnie zbliżony do języka naturalnego:

  • Zdecyduj, jak obsłużyć kliknięcie.
    • Jeśli jest następny lub poprzedni artykuł: pokaż go.
    • Jeśli jest to ostatnia/pierwsza historia znajomego: pokaż nowego znajomego
    • Jeśli nie ma żadnej historii, która mogłaby być wykorzystana w tym kierunku: nie rób nic.
  • Ukryj nową bieżącą relację w 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ę. Następnie kliknij Pełny ekranpełny ekran.

Podsumowanie

To już nasze ogólne potrzeby dotyczące tego komponentu. Możesz go rozwijać, uzupełniać o dane i ogółem wykorzystywać do własnych celów.