Codelab: Stories-Komponente erstellen

In diesem Codelab erfahren Sie, wie Sie eine Funktion wie Instagram Stories im Web erstellen. Wir erstellen die Komponente nach und nach, beginnend mit HTML, dann CSS und zuletzt JavaScript.

In meinem Blogpost Building a Stories component (Eine Stories-Komponente erstellen) erfahren Sie mehr über die kontinuierlichen Verbesserungen, die beim Erstellen dieser Komponente vorgenommen wurden.

Einrichtung

  1. Klicke auf Remix zum Bearbeiten, um das Projekt bearbeitbar zu machen.
  2. Öffnen Sie app/index.html.

HTML

Ich verwende immer semantisches HTML. Da jeder Freund beliebig viele Geschichten haben kann, habe ich beschlossen, für jeden Freund ein <section>-Element und für jede Geschichte ein <article>-Element zu verwenden. Fangen wir aber am Anfang an. Zuerst benötigen wir einen Container für die Stories-Komponente.

Fügen Sie Ihrem <body> ein <div>-Element hinzu:

<div class="stories">

</div>

Fügen Sie einige <section>-Elemente hinzu, um Freunde darzustellen:

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

Fügen Sie einige <article>-Elemente hinzu, um Geschichten zu repräsentieren:

<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>
  • Wir verwenden einen Bilddienst (picsum.com), um Prototypen für Geschichten zu erstellen.
  • Das style-Attribut jedes <article> ist Teil einer Methode zum Laden von Platzhaltern, die im nächsten Abschnitt näher erläutert wird.

CSS

Unsere Inhalte sind bereit für den Stil. Lassen Sie uns diese Knochen in etwas verwandeln, mit dem Nutzer interagieren möchten. Heute arbeiten wir Mobile First.

.stories

Für unseren <div class="stories">-Container möchten wir einen horizontal scrollbaren Container. Dazu haben wir folgende Möglichkeiten:

  • Container in ein Raster verwandeln
  • Jedes untergeordnete Element so einstellen, dass es den Zeilen-Track füllt
  • Die Breite jedes untergeordneten Elements der Breite des Darstellungsbereichs eines Mobilgeräts anpassen

Das Raster fügt rechts neben der vorherigen Spalte fortlaufend neue Spalten mit einer Breite von 100vw hinzu, bis alle HTML-Elemente in Ihrem Markup platziert sind.

Chrome und die Entwicklertools werden mit einem Rastervisualisierung geöffnet, das das Layout in voller Breite zeigt
Chrome-Entwicklertools mit Überlauf der Rasterspalte, wodurch ein horizontaler Bildlauf aktiviert wird

Fügen Sie unten in app/css/index.css das folgende CSS hinzu:

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

Da sich unsere Inhalte jetzt über den Darstellungsbereich hinaus erstrecken, müssen wir dem Container mitteilen, wie er damit umgehen soll. Fügen Sie Ihrem .stories-Regelsatz die hervorgehobenen Codezeilen hinzu:

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

Wir möchten horizontal scrollen, also setzen wir overflow-x auf auto. Wenn der Nutzer scrollt, soll die Komponente sanft auf der nächsten Story landen. Daher verwenden wir scroll-snap-type: x mandatory. Weitere Informationen zu diesem CSS finden Sie in den Abschnitten CSS-Scroll-Snap-Punkte und overscroll-behavior meines Blogposts.

Sowohl der übergeordnete Container als auch die untergeordneten Elemente müssen dem Scroll-Snapping zustimmen. Gehen wir das jetzt an. Fügen Sie den folgenden Code am Ende von app/css/index.css ein:

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

Ihre App funktioniert noch nicht, aber im Video unten sehen Sie, was passiert, wenn scroll-snap-type aktiviert und deaktiviert ist. Wenn diese Option aktiviert ist, springt jedes horizontale Scrollen zur nächsten Story. Wenn diese Option deaktiviert ist, verwendet der Browser das Standard-Scrollverhalten.

Dadurch können Sie durch Ihre Freunde scrollen, aber wir müssen noch ein Problem mit den Stories beheben.

.user

Erstellen wir im Bereich .user ein Layout, in dem diese untergeordneten Storyelemente platziert werden. Wir verwenden einen praktischen Stacking-Trick, um das Problem zu lösen. Im Grunde erstellen wir ein Raster mit einer Größe von 1 × 1, bei dem Zeile und Spalte denselben Rasteralias [story] haben. Jedes Storyboard-Rasterelement versucht, diesen Bereich zu beanspruchen, was zu einem Stapel führt.

Fügen Sie den markierten Code zu Ihrer .user-Regel hinzu:

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

Fügen Sie unten in app/css/index.css die folgende Regel hinzu:

.story {
  grid-area: story;
}

Ohne absolute Positionierung, Floats oder andere Layout-Direktive, die ein Element aus dem Fluss herausnehmen, sind wir immer noch im Fluss. Außerdem ist es fast kein Code, sehen Sie sich das an! Im Video und im Blogpost wird das genauer erläutert.

.story

Jetzt müssen wir nur noch das Story-Element selbst stylen.

Wir haben bereits erwähnt, dass das style-Attribut jedes <article>-Elements Teil einer Platzhalterladetechnik ist:

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

Wir verwenden die CSS-Eigenschaft background-image, mit der wir mehrere Hintergrundbilder angeben können. Wir können sie so anordnen, dass das Bild des Nutzers oben angezeigt wird und automatisch erscheint, sobald das Laden abgeschlossen ist. Dazu fügen wir die Bild-URL in eine benutzerdefinierte Property (--bg) ein und verwenden sie in unserem CSS, um sie über den Ladebalken zu legen.

Aktualisieren wir zuerst die .story-Regel, um einen Farbverlauf durch ein Hintergrundbild zu ersetzen, sobald das Laden abgeschlossen ist. Fügen Sie den markierten Code zu Ihrer .story-Regel hinzu:

.story {
  grid-area: story;

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

Wenn Sie background-size auf cover festlegen, gibt es im Darstellungsbereich keinen leeren Raum, da er von unserem Bild ausgefüllt wird. Wenn wir zwei Hintergrundbilder definieren, können wir einen praktischen CSS-Webtrick namens Lade-Grabstein anwenden:

  • „Background image 1“ (var(--bg)) ist die URL, die wir inline im HTML-Code übergeben haben.
  • Hintergrundbild 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) ist ein Farbverlauf, der angezeigt wird, während die URL geladen wird)

Sobald der Download des Bildes abgeschlossen ist, wird der Farbverlauf automatisch durch das Bild ersetzt.

Als Nächstes fügen wir CSS hinzu, um bestimmte Verhaltensweisen zu entfernen, damit der Browser schneller arbeiten kann. Fügen Sie den markierten Code zu Ihrer .story-Regel hinzu:

.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 verhindert, dass Nutzer versehentlich Text auswählen
  • touch-action: manipulation weist den Browser an, diese Interaktionen als Touch-Ereignisse zu behandeln. Dadurch muss der Browser nicht mehr entscheiden, ob Sie auf eine URL klicken oder nicht.

Fügen wir abschließend ein wenig CSS hinzu, um den Übergang zwischen den Storys zu animieren. Fügen Sie den hervorgehobenen Code zu Ihren .story-Regeln hinzu:

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

Die Klasse .seen wird einer Story hinzugefügt, die einen Ausstieg benötigt. Die benutzerdefinierte Ease-Funktion (cubic-bezier(0.4, 0.0, 1,1)) habe ich im Material Design-Leitfaden für Ease-Funktionen gefunden (scrollen Sie zum Abschnitt Beschleunigte Ease-Funktion).

Wenn Sie ein scharfes Auge haben, haben Sie wahrscheinlich die pointer-events: none-Erklärung bemerkt und fragen sich jetzt, was das zu bedeuten hat. Das ist der einzige Nachteil der Lösung. Das ist notwendig, da ein .seen.story-Element oben angezeigt wird und angetippt wird, auch wenn es unsichtbar ist. Wenn wir pointer-events auf none setzen, verwandeln wir die Glasscheibe in ein Fenster und stehlen keine Nutzerinteraktionen mehr. Das ist kein allzu schlechter Kompromiss und auch nicht allzu schwierig zu handhaben in unserem Preisvergleichsportal. Wir jonglieren nicht mit z-index. Ich bin immer noch zufrieden.

JavaScript

Die Interaktionen mit einer Stories-Komponente sind für Nutzer recht einfach: Tippen Sie nach rechts, um vorwärts zu gehen, und nach links, um zurückzugehen. Einfache Dinge für Nutzer sind für Entwickler oft harte Arbeit. Wir übernehmen aber einen Großteil davon.

Einrichtung

Berechnen und speichern wir zuerst so viele Informationen wie möglich. Fügen Sie den folgenden Code zu app/js/index.js hinzu:

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

In der ersten Zeile von JavaScript wird ein Verweis auf das Stammelement des primären HTML-Elements abgerufen und gespeichert. In der nächsten Zeile wird berechnet, wo sich die Mitte unseres Elements befindet, damit wir entscheiden können, ob ein Tippen vor- oder zurückgehen soll.

Status

Als Nächstes erstellen wir ein kleines Objekt mit einem für unsere Logik relevanten Status. In diesem Fall sind wir nur an der aktuellen Meldung interessiert. In unserem HTML-Markup können wir darauf zugreifen, indem wir den ersten Freund und seine neueste Story abrufen. Fügen Sie den hervorgehobenen Code zu app/js/index.js hinzu:

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

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

Listener

Wir haben jetzt genug Logik, um auf Nutzerereignisse zu warten und sie zu leiten.

Maus

Hören wir uns zuerst das Ereignis 'click' in unserem Stories-Container an. Fügen Sie den markierten Code zu app/js/index.js hinzu:

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')
})

Wenn ein Klick erfolgt, der nicht auf einem <article>-Element erfolgt, wird nichts unternommen. Wenn es sich um einen Artikel handelt, erfassen wir mit clientX die horizontale Position der Maus oder des Fingers. Wir haben navigateStories noch nicht implementiert, aber das Argument, das es nimmt, gibt an, in welche Richtung wir gehen müssen. Wenn diese Nutzerposition größer als der Median ist, wissen wir, dass wir zu next wechseln müssen, andernfalls zu prev (vorher).

Tastatur

Jetzt wollen wir auf Tastatureingaben achten. Wenn der Abwärtspfeil gedrückt wird, gelangen wir zu next. Wenn es der Pfeil nach oben ist, gehen wir zu prev.

Fügen Sie den markierten Code zu app/js/index.js hinzu:

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')
})

Navigation in Stories

Es ist an der Zeit, sich mit der einzigartigen Geschäftslogik von Stories und der UX zu befassen, für die sie berühmt geworden sind. Das sieht etwas unübersichtlich und kompliziert aus, aber wenn Sie es Zeile für Zeile durchgehen, werden Sie feststellen, dass es ziemlich verständlich ist.

Vorne haben wir einige Auswahlschaltflächen, mit denen wir entscheiden können, ob wir zu einem Freund scrollen oder eine Story anzeigen oder ausblenden möchten. Da wir mit HTML arbeiten, fragen wir darin nach Freunden (Nutzern) oder Geschichten (Story).

Mit diesen Variablen können wir Fragen wie „Bedeutet ‚Weiter‘ bei Story X, dass ich zu einer anderen Story desselben Freundes oder zu einer Story eines anderen Freundes wechsele?“ beantworten. Ich habe dazu die von uns erstellte Struktur genutzt, um Eltern und ihre Kinder anzusprechen.

Fügen Sie den folgenden Code am Ende von app/js/index.js ein:

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
}

Hier ist unser Geschäftslogikziel, so natürlich wie möglich formuliert:

  • Entscheiden Sie, wie mit dem Tippen umgegangen werden soll.
    • Wenn es eine nächste/vorherige Story gibt: Diese Story anzeigen
    • Wenn es sich um die letzte oder erste Story des Freundes handelt: Einen neuen Freund anzeigen
    • Wenn es in dieser Richtung keine Geschichte gibt, unternehmen Sie nichts.
  • Neue aktuelle Story in state speichern

Fügen Sie den hervorgehobenen Code in die navigateStories-Funktion ein:

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

Jetzt ausprobieren

  • Wenn Sie sich eine Vorschau der Website ansehen möchten, drücken Sie App ansehen und dann Vollbild Vollbild.

Fazit

Das war es mit meinen Anforderungen an die Komponente. Sie können sie gerne erweitern, mit Daten optimieren und im Allgemeinen zu Ihrer eigenen machen.