Codelab: creazione di un componente Storie

Questo codelab ti insegna come creare un'esperienza simile alle storie di Instagram sul web. Creeremo il componente man mano, iniziando con HTML, poi CSS e infine JavaScript.

Dai un'occhiata al mio blog post Creazione di un componente Storie per scoprire i potenziamenti progressivi apportati durante la creazione di questo componente.

Configurazione

  1. Fai clic su Remixa per modificare per rendere il progetto modificabile.
  2. Apri app/index.html.

HTML

Cerco sempre di utilizzare l'HTML semantico. Poiché ogni amico può avere un numero qualsiasi di storie, ho pensato che fosse significativo utilizzare un elemento <section> per ogni amico e un elemento <article> per ogni storia. Ma iniziamo dall'inizio. Innanzitutto, abbiamo bisogno di un contenitore per il nostro componente delle storie.

Aggiungi un elemento <div> al tuo <body>:

<div class="stories">

</div>

Aggiungi alcuni elementi <section> per rappresentare gli amici:

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

Aggiungi alcuni elementi <article> per rappresentare le storie:

<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>
  • Utilizziamo un servizio di immagini (picsum.com) per creare prototipi di storie.
  • L'attributo style su ogni <article> fa parte di una tecnica di caricamento dei segnaposto, di cui scoprirai di più nella sezione successiva.

CSS

I nostri contenuti sono pronti per lo stile. Trasformiamo questa struttura in qualcosa con cui le persone vorranno interagire. Oggi lavoreremo con un approccio incentrato sui dispositivi mobili.

.stories

Per il nostro container <div class="stories">, vogliamo un container a scorrimento orizzontale. Possiamo raggiungere questo obiettivo:

  • Trasformare il contenitore in una griglia
  • Impostazione di ogni elemento secondario per riempire la traccia della riga
  • Impostare la larghezza di ogni elemento secondario sulla larghezza della visualizzazione di un dispositivo mobile

La griglia continuerà a posizionare nuove colonne di larghezza 100vw a destra della precedente, finché non avrà posizionato tutti gli elementi HTML nel markup.

Chrome e DevTools aperti con una griglia che mostra il layout a larghezza intera
Chrome DevTools mostra l'overflow delle colonne della griglia, creando uno scroller orizzontale.

Aggiungi il seguente CSS alla fine di app/css/index.css:

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

Ora che i contenuti si estendono oltre l'area visibile, è il momento di indicare al container come gestirli. Aggiungi le righe di codice evidenziate al tuo set di regole .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;
}

Vogliamo lo scorrimento orizzontale, quindi imposteremo overflow-x su auto. Quando l'utente scorre, vogliamo che il componente si posizioni delicatamente sulla storia successiva, quindi utilizzeremo scroll-snap-type: x mandatory. Scopri di più su questo CSS nelle sezioni Punti di aggancio dello scorrimento CSS e overscroll-behavior del mio blog post.

Sia il contenitore principale che i contenitori secondari devono accettare l'allineamento dello scorrimento, quindi occupiamoci di questo aspetto ora. Aggiungi il codice seguente alla fine di app/css/index.css:

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

La tua app non funziona ancora, ma il video di seguito mostra cosa succede quando scroll-snap-type è abilitato e disabilitato. Se questa opzione è attiva, ogni scorrimento orizzontale si allinea alla storia successiva. Se l'opzione è disattivata, il browser utilizza il comportamento di scorrimento predefinito.

In questo modo potrai scorrere i tuoi amici, ma abbiamo ancora un problema con le storie da risolvere.

.user

Creiamo un layout nella sezione .user che organizzi gli elementi secondari della storia. Per risolvere questo problema, utilizzeremo un pratico trucco di impilamento. In sostanza, stiamo creando una griglia 1x1 in cui la riga e la colonna hanno lo stesso alias di griglia [story] e ogni elemento della griglia della storia tenterà di rivendicare quello spazio, creando uno stack.

Aggiungi il codice evidenziato al tuo set di regole .user:

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

Aggiungi il seguente set di regole in fondo a app/css/index.css:

.story {
  grid-area: story;
}

Ora, senza posizionamento assoluto, elementi mobili o altre istruzioni di layout che portano un elemento fuori dal flusso, siamo ancora nel flusso. Inoltre, il codice è davvero minimo, guarda qui. Questo aspetto viene analizzato in modo più dettagliato nel video e nel blog post.

.story

Ora dobbiamo solo applicare lo stile all'elemento della storia.

In precedenza abbiamo accennato al fatto che l'attributo style di ogni elemento <article> fa parte di una tecnica di caricamento dei segnaposto:

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

Utilizzeremo la proprietà background-image di CSS, che ci consente di specificare più di un'immagine di sfondo. Possiamo disporli in un ordine tale che l'immagine dell'utente si trovi in alto e venga visualizzata automaticamente al termine del caricamento. Per abilitare questa funzionalità, inseriremo l'URL dell'immagine in una proprietà personalizzata (--bg) e lo utilizzeremo all'interno del nostro CSS per creare un livello con il segnaposto di caricamento.

Innanzitutto, aggiorniamo il set di regole .story per sostituire un gradiente con un'immagine di sfondo una volta completato il caricamento. Aggiungi il codice evidenziato al set di regole .story:

.story {
  grid-area: story;

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

L'impostazione di background-size su cover garantisce che non ci sia spazio vuoto nell'area visibile perché l'immagine la riempirà. La definizione di due immagini di sfondo ci consente di utilizzare un trucco web CSS chiamato loading tombstone:

  • L'immagine di sfondo 1 (var(--bg)) è l'URL che abbiamo passato in linea nel codice HTML
  • Immagine di sfondo 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) è un gradiente da mostrare durante il caricamento dell'URL)

Il CSS sostituirà automaticamente il gradiente con l'immagine, una volta completato il download dell'immagine.

Successivamente, aggiungeremo un po' di CSS per rimuovere alcuni comportamenti, liberando il browser per muoversi più velocemente. Aggiungi il codice evidenziato al set di regole .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 impedisce agli utenti di selezionare accidentalmente il testo
  • touch-action: manipulation indica al browser che queste interazioni devono essere trattate come eventi touch, il che evita al browser di dover decidere se stai facendo clic su un URL o meno

Infine, aggiungiamo un po' di CSS per animare la transizione tra le storie. Aggiungi il codice evidenziato al set di regole .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;
  }
}

La classe .seen verrà aggiunta a una storia che richiede un'uscita. Ho preso la funzione di easing personalizzata (cubic-bezier(0.4, 0.0, 1,1)) dalla guida Easing di Material Design (scorri fino alla sezione Easing accelerato).

Se hai un occhio attento, probabilmente hai notato la dichiarazione pointer-events: none e ora ti stai grattando la testa. Direi che questo è l'unico svantaggio della soluzione finora. Questo è necessario perché un elemento .seen.story si troverà in alto e riceverà i tocchi, anche se è invisibile. Impostando pointer-events su none, trasformiamo la storia di vetro in una finestra e non rubiamo più interazioni degli utenti. Non è un compromesso troppo negativo e non è troppo difficile da gestire qui nel nostro CSS al momento. Non stiamo facendo giocoleria con z-index. Sono ancora ottimista.

JavaScript

Le interazioni di un componente Storie sono piuttosto semplici per l'utente: tocca a destra per andare avanti, tocca a sinistra per tornare indietro. Le cose semplici per gli utenti tendono a essere un lavoro difficile per gli sviluppatori. Tuttavia, ci occuperemo noi di gran parte del lavoro.

Configurazione

Per iniziare, calcoliamo e archiviamo quante più informazioni possibili. Aggiungi il codice seguente a app/js/index.js:

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

La prima riga di JavaScript acquisisce e memorizza un riferimento alla radice dell'elemento HTML principale. La riga successiva calcola dove si trova il centro del nostro elemento, in modo da poter decidere se un tocco deve andare avanti o indietro.

Stato

Poi creiamo un piccolo oggetto con uno stato pertinente alla nostra logica. In questo caso, ci interessa solo la storia corrente. Nel nostro markup HTML, possiamo accedervi recuperando il primo amico e la sua storia più recente. Aggiungi il codice evidenziato al tuo app/js/index.js:

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

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

Listener

Ora abbiamo una logica sufficiente per iniziare ad ascoltare gli eventi utente e indirizzarli.

Topo

Iniziamo ascoltando l'evento 'click' sul nostro contenitore di storie. Aggiungi il codice evidenziato a 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')
})

Se si verifica un clic e non è su un elemento <article>, non facciamo nulla. Se si tratta di un articolo, acquisiamo la posizione orizzontale del mouse o del dito con clientX. Non abbiamo ancora implementato navigateStories, ma l'argomento che accetta specifica la direzione in cui dobbiamo andare. Se la posizione dell'utente è maggiore della mediana, sappiamo che dobbiamo andare a next, altrimenti a prev (precedente).

Tastiera

Ora, ascoltiamo la pressione dei tasti della tastiera. Se viene premuta la Freccia giù, passiamo a next. Se è la Freccia su, andiamo a prev.

Aggiungi il codice evidenziato a 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')
})

Navigazione nelle storie

È il momento di affrontare la logica di business unica delle storie e l'UX per cui sono diventate famose. Sembra un po' complicato, ma se lo esamini riga per riga, vedrai che è abbastanza comprensibile.

Inizialmente, memorizziamo alcuni selettori che ci aiutano a decidere se scorrere fino a un amico o mostrare/nascondere una storia. Poiché lavoriamo nell'HTML, eseguiremo query per verificare la presenza di amici (utenti) o storie (storia).

Queste variabili ci aiuteranno a rispondere a domande come: "Data la storia x, 'avanti' significa passare a un'altra storia dello stesso amico o di un amico diverso?" L'ho fatto utilizzando la struttura ad albero che abbiamo creato, raggiungendo gli elementi principali e i relativi elementi secondari.

Aggiungi il codice seguente alla fine di 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
}

Ecco il nostro obiettivo di logica di business, il più vicino possibile al linguaggio naturale:

  • Decidi come gestire il tocco
    • Se è presente una storia successiva/precedente: mostra la storia
    • Se è l'ultima/la prima storia dell'amico: mostra un nuovo amico
    • Se non è presente una storia in quella direzione: non fare nulla
  • Archivia la nuova storia corrente in state

Aggiungi il codice evidenziato alla funzione 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
    }
  }
}

Prova

  • Per visualizzare l'anteprima del sito, premi Visualizza app, quindi premi Schermo intero schermo intero.

Conclusione

Questo è un riepilogo delle esigenze che avevo con il componente. Sentiti libero di svilupparlo, di guidarlo con i dati e, in generale, di farlo tuo.