Ć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.

Aby dowiedzieć się więcej o ulepszeniach wprowadzonych podczas tworzenia tego komponentu, przeczytaj ten post na blogu.

Konfiguracja

  1. Kliknij Remixuj do edycji, aby umożliwić edycję projektu.
  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 obiektu <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>
  • Korzystamy z usługi dotyczącej obrazów (picsum.com), aby tworzyć prototypy historii.
  • Atrybut style w każdym elemencie <article> jest częścią techniki wczytywania za pomocą placeholderów, o której dowiesz się więcej w następnej sekcji.

CSS

Treści są gotowe do stylizacji. Przekształcmy te kości w coś, z czym użytkownicy będą chcieli wchodzić w interakcje. Dzisiaj będziemy pracować nad optymalizacją pod kątem urządzeń 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 przewijania poziomego, więc ustawimy overflow-x na auto. Gdy użytkownik przewija ekran, chcemy, aby komponent delikatnie przesuwał się na następną historię, dlatego użyjemy scroll-snap-type: x mandatory. Więcej informacji na ten temat znajdziesz w postach na blogu w sekcjach Punkty zaczepienia przewijania CSSoverscroll-behavior.

Aby włączyć przewijanie z przyspieszeniem, musisz wyrazić zgodę zarówno w kontenerze nadrzędnym, jak i w kontenerach podrzędnych. 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 na filmie poniżej możesz zobaczyć, co się dzieje, gdy włączysz i wyłączysz funkcję scroll-snap-type. Gdy ta opcja jest włączona, przewijanie poziome powoduje przejście do następnej historii. Gdy ta opcja jest wyłączona, przeglądarka używa domyślnego zachowania przewijania.

Dzięki temu będziesz mieć dostęp do listy znajomych, ale nadal mamy problem z historią.

.user

Utwórzmy układ w sekcji .user, 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 z przepływu, 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 ustawić w takim porządku, aby zdjęcie użytkownika było na górze i po załadowaniu wyświetlało 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. 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):

  • Background image 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.

Następnie dodamy trochę kodu CSS, aby usunąć niektóre zachowania, co pozwoli przeglądarce działać szybciej. 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));

  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 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 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ę na 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 rzucamy się z poziomu na poziom 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 obliczmy i przechowujmy 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. Następny wiersz oblicza środek elementu, dzięki czemu możemy zdecydować, czy kliknięcie ma przesunąć element 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 tagach 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ąco dużo logiki, 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. Funkcji navigateStories jeszcze nie wdrożyliśmy, ale jej argument określa, w jakim kierunku mamy się poruszać. Jeśli pozycja użytkownika jest większa niż mediana, wiemy, że musimy przejść do next, w przeciwnym razie do prev (poprzedni).

Klawiatura

Teraz posłuchajmy, jak działają klawisze. Jeśli naciśniesz strzałkę w dół, przejdziesz 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 na zajęcie się unikalną logiką biznesową relacji i UX, z których słyną. Wygląda to na skomplikowane, ale jeśli przeczytasz to zdanie po zdaniu, powinno być zrozumiałe.

Na początku wczytujemy selektory, które pomagają nam zdecydować, czy przewinąć do znajomego, czy pokazać lub ukryć historię. Ponieważ pracujemy w języku HTML, będziemy wysyłać zapytania o obecność znajomych (użytkowników) lub relacji (relacji).

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 biznesowy, sformułowany w jak najbardziej zbliżony do języka naturalnego sposób:

  • Zdecyduj, jak obsłużyć kliknięcie.
    • Jeśli jest kolejna/poprzednia relacja: wyświetl tę relację.
    • 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 ekran pełny ekran.

Podsumowanie

To wszystko, co chciałam powiedzieć na temat tego komponentu. Możesz go rozwijać, wykorzystywać w nim dane i ogółem robić z nim, co chcesz.