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. Zuerst HTML, dann CSS und schließlich JavaScript.

In meinem Blogpost Building a Stories component (Eine Stories-Komponente erstellen) erfährst du mehr über die progressiven Verbesserungen, die beim Erstellen dieser Komponente vorgenommen wurden.

Einrichtung

  1. Klicken Sie auf Remix to Edit (Remix zum Bearbeiten), um das Projekt bearbeitbar zu machen.
  2. Öffnen Sie app/index.html.

HTML

Ich versuche immer, semantisches HTML zu verwenden. Da jeder Freund beliebig viele Stories haben kann, habe ich für jeden Freund ein <section>-Element und für jede Story ein <article>-Element verwendet. Fangen wir aber erst einmal von vorn an. Zuerst benötigen wir einen Container für unsere Stories-Komponente.

Fügen Sie Ihrer <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 darzustellen:

<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 Stories zu erstellen.
  • Das style-Attribut für jedes <article> ist Teil einer Platzhalter-Ladetechnik, über die Sie im nächsten Abschnitt mehr erfahren.

CSS

Unsere Inhalte sind bereit für die Gestaltung. Lass uns aus diesen Informationen etwas machen, mit dem die Leute interagieren möchten. Wir arbeiten heute nach dem Mobile First-Prinzip.

.stories

Für unseren <div class="stories">-Container möchten wir einen Container mit horizontalem Scrollen. Das erreichen wir durch:

  • Container in ein Raster umwandeln
  • Jedes untergeordnete Element so festlegen, dass es den Zeilen-Track ausfüllt
  • Die Breite jedes untergeordneten Elements entspricht der Breite des Viewports eines Mobilgeräts.

Im Grid werden weiterhin neue 100vw-breite Spalten rechts von der vorherigen Spalte platziert, bis alle HTML-Elemente in Ihrem Markup platziert sind.

Chrome und die Entwicklertools sind geöffnet und zeigen ein Rasterbild mit dem Layout in voller Breite.
Chrome-Entwicklertools mit Überlauf der Rasterspalte, wodurch ein horizontaler Scrollbalken entsteht.

Fügen Sie das folgende CSS am Ende von app/css/index.css hinzu:

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

Da wir jetzt Inhalte haben, die über den Darstellungsbereich hinausgehen, 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 horizontales Scrollen, daher setzen wir overflow-x auf auto. Wenn der Nutzer scrollt, soll die Komponente sanft auf der nächsten Geschichte ruhen. Dazu verwenden wir scroll-snap-type: x mandatory. Weitere Informationen zu diesem CSS finden Sie in den Abschnitten CSS Scroll Snap Points und overscroll-behavior meines Blogposts.

Sowohl der übergeordnete Container als auch die untergeordneten Elemente müssen dem Scroll-Snapping zustimmen. Das wollen wir jetzt erledigen. 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, wird bei jedem horizontalen Scrollen zur nächsten Story gesprungen. Wenn diese Option deaktiviert ist, verwendet der Browser sein Standard-Scrollverhalten.

So kannst du durch deine Freunde scrollen, aber wir müssen noch ein Problem mit den Stories beheben.

.user

Erstellen wir ein Layout im Abschnitt .user, in dem die untergeordneten Story-Elemente angeordnet werden. Wir verwenden einen praktischen Stapeltrick, um das Problem zu lösen. Wir erstellen im Grunde ein 1x1-Raster, in dem Zeile und Spalte denselben Rasteralias [story] haben. Jedes Story-Rasterelement versucht, diesen Bereich zu belegen, was zu einem Stapel führt.

Fügen Sie den hervorgehobenen Code Ihrem .user-Regelsatz hinzu:

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

Fügen Sie am Ende von app/css/index.css das folgende Regelset hinzu:

.story {
  grid-area: story;
}

Ohne absolute Positionierung, Floats oder andere Layoutanweisungen, die ein Element aus dem Fluss entfernen, sind wir immer noch im Fluss. Außerdem ist es kaum Code. Im Video und im Blogbeitrag wird das genauer aufgeschlüsselt.

.story

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

Wie bereits erwähnt, ist das Attribut style für jedes <article>-Element Teil einer Platzhalter-Ladetechnik:

<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 Nutzerbild oben steht und automatisch angezeigt wird, sobald es geladen ist. Dazu fügen wir die Bild-URL in eine benutzerdefinierte Eigenschaft (--bg) ein und verwenden sie in unserem CSS, um sie mit dem Platzhalter für das Laden zu überlagern.

Aktualisieren wir zuerst das Regelsatz .story, um ein Hintergrundbild zu verwenden, sobald es geladen wurde. Fügen Sie den hervorgehobenen Code Ihrem .story-Regelsatz 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, ist im Viewport kein leerer Bereich vorhanden, da unser Bild ihn ausfüllt. Durch die Definition von zwei Hintergrundbildern können wir einen cleveren CSS-Webtrick namens Lade-Tombstone nutzen:

  • Hintergrundbild 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 während des Ladens der URL angezeigt wird)

CSS ersetzt den Farbverlauf automatisch durch das Bild, sobald das Bild heruntergeladen wurde.

Als Nächstes fügen wir etwas CSS hinzu, um einige Verhaltensweisen zu entfernen und den Browser zu entlasten. Fügen Sie den hervorgehobenen Code Ihrem .story-Regelsatz 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. So muss der Browser nicht mehr entscheiden, ob Sie auf eine URL klicken oder nicht.

Fügen wir zum Schluss noch etwas CSS hinzu, um den Übergang zwischen den Stories zu animieren. Fügen Sie den hervorgehobenen Code zu Ihrem .story-Regelsatz 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 Geschichte hinzugefügt, die einen Exit benötigt. Die benutzerdefinierte Easing-Funktion (cubic-bezier(0.4, 0.0, 1,1)) stammt aus dem Easing-Leitfaden von Material Design (scrollen Sie zum Abschnitt Accelerated easing).

Wenn du genau hingesehen hast, ist dir die pointer-events: none-Deklaration wahrscheinlich aufgefallen und du fragst dich jetzt, was das bedeutet. Das ist meiner Meinung nach der einzige Nachteil der Lösung. Das ist erforderlich, weil ein .seen.story-Element oben liegt und Berührungen empfängt, obwohl es unsichtbar ist. Wenn wir pointer-events auf none setzen, wird die Glas-Story zu einem Fenster und es werden keine Nutzerinteraktionen mehr gestohlen. Das ist kein schlechter Kompromiss und lässt sich in unserem Preisvergleichsportal derzeit gut verwalten. Wir jonglieren nicht mit z-index. Ich bin immer noch zuversichtlich.

JavaScript

Die Interaktionen einer Stories-Komponente sind für den Nutzer ganz einfach: Tippen Sie rechts, um vorwärts zu gehen, und links, um zurückzugehen. Was für Nutzer einfach ist, ist für Entwickler oft mit viel Arbeit verbunden. Wir kümmern uns aber um vieles.

Einrichtung

Zuerst berechnen und speichern wir 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 JavaScript-Zeile wird ein Verweis auf das primäre HTML-Element „root“ abgerufen und gespeichert. In der nächsten Zeile wird berechnet, wo sich die Mitte des Elements befindet, damit wir entscheiden können, ob ein Tippen vorwärts oder rückwärts erfolgen 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 Geschichte interessiert. In unserem HTML-Markup können wir darauf zugreifen, indem wir den ersten Freund und seine letzte 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 weiterzuleiten.

Maus

Wir beginnen damit, auf das 'click'-Ereignis in unserem Stories-Container zu warten. 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
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

Wenn ein Klick erfolgt und es sich nicht um ein <article>-Element handelt, wird die Ausführung abgebrochen und nichts unternommen. Handelt es sich um einen Artikel, erfassen wir die horizontale Position der Maus oder des Fingers mit clientX. Wir haben navigateStories noch nicht implementiert, aber das Argument, das dafür angegeben wird, gibt die Richtung an, in die wir gehen müssen. Wenn die Nutzerposition größer als der Median ist, müssen wir zu next navigieren, andernfalls zu prev (vorherige).

Tastatur

Jetzt müssen wir auf Tastendrücke warten. Wenn der Abwärtspfeil gedrückt wird, wird zu next navigiert. Wenn es sich um den Aufwärtspfeil handelt, gehen wir zu prev.

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
}

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 bekannt sind. Das sieht vielleicht etwas kompliziert aus, aber wenn du es Zeile für Zeile durchgehst, wirst du feststellen, dass es ganz einfach ist.

Zuerst speichern wir einige Selektoren, die uns helfen, zu entscheiden, ob wir zu einem Freund scrollen oder eine Story ein- oder ausblenden. Da wir mit dem HTML-Code arbeiten, fragen wir ihn nach Freunden (Nutzern) oder Stories (Story) ab.

Mithilfe dieser Variablen können wir Fragen wie „Bedeutet ‚Weiter‘ bei Story X, dass ich zur nächsten Story desselben Freundes oder zu einer Story eines anderen Freundes weitergeleitet werde?“ beantworten. Ich habe das mithilfe der von uns erstellten Baumstruktur getan und Eltern und ihre Kinder erreicht.

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
}

So sieht unser Ziel für die Geschäftslogik aus, so nah wie möglich an der natürlichen Sprache:

  • Entscheiden Sie, wie mit dem Tippen umgegangen werden soll.
    • Wenn es eine nächste/vorherige Story gibt, zeige diese an.
    • Wenn es die letzte/erste Story des Freundes ist: Zeige einen neuen Freund an.
    • Wenn es in dieser Richtung keine Geschichte gibt, die Sie aufrufen können, unternehmen Sie nichts.
  • Die aktuelle Geschichte in state speichern

Fügen Sie den hervorgehobenen Code in Ihre 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 alles, was ich mit der Komponente machen wollte. Sie können es gerne weiterentwickeln, datengestützt optimieren und ganz allgemein an Ihre Bedürfnisse anpassen.