Codelab: creazione di un componente Storie

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

Leggi il mio post del blog Building a Stories component per scoprire i miglioramenti 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. 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 di ogni <article> fa parte di una tecnica di caricamento dei segnaposto, di cui parleremo più nel dettaglio nella sezione successiva.

CSS

I nostri contenuti sono pronti per lo stile. Trasformiamo questi scheletri in qualcosa con cui le persone vorranno interagire. Oggi lavoreremo con priorità ai contenuti per 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
  • Impostando ogni bambino in modo che riempia la traccia della riga
  • Impostando la larghezza di ogni figlio sulla larghezza della finestra del 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 visualizzazione a griglia che mostra il layout a larghezza intera
Chrome DevTools che mostra l'overflow della colonna della griglia, creando una barra di scorrimento 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 contenitore come gestirli. Aggiungi le righe di codice evidenziate al 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 impostiamo overflow-x su auto. Quando l'utente scorre, vogliamo che il componente si appoggi delicatamente alla storia successiva, quindi utilizzeremo scroll-snap-type: x mandatory. Scopri di più su questo CSS nelle sezioni CSS Scroll Snap Points e overscroll-behavior del mio post del blog.

Per attivare lo scorrimento agganciato, è necessario che sia il contenitore principale sia i contenitori secondari siano d'accordo, quindi gestiamo la cosa ora. Aggiungi il seguente codice in fondo a app/css/index.css:

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

La tua app non funziona ancora, ma il video qui sotto mostra cosa succede quando scroll-snap-type è attivato e disattivato. Se abilitata, ogni scorrimento orizzontale passa alla storia successiva. Se disattivata, il browser utilizza il comportamento di scorrimento predefinito.

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

.user

Creiamo un layout nella sezione .user che organizzi gli elementi secondari della storia. Per risolvere il 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 della griglia [story] e ogni elemento della griglia delle storie tenterà di rivendicare quello spazio, creando una pila.

Aggiungi il codice evidenziato al set di regole .user:

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

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

.story {
  grid-area: story;
}

Ora, senza posizionamento assoluto, float o altre direttive di layout che estraggono un elemento dal flusso, siamo ancora nel flusso. Inoltre, è quasi assente il codice, guarda! Questo aspetto viene analizzato in modo più dettagliato nel video e nel post del blog.

.story

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

In precedenza abbiamo detto 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 ordinarli in modo che la foto dell'utente sia in alto e venga visualizzata automaticamente al termine del caricamento. Per attivarlo, inseriremo l'URL dell'immagine in una proprietà personalizzata (--bg) e lo utilizzeremo all'interno del CSS per sovrapporlo al segnaposto di caricamento.

Innanzitutto, aggiorniamo il set di regole .story per sostituire un gradiente con un'immagine di sfondo una volta caricato. 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 background-size su cover garantisce che non ci sia spazio vuoto nella viewport perché la nostra immagine lo riempirà. La definizione di due immagini di sfondo ci consente di utilizzare un trucco CSS per il web chiamato segnaposto di caricamento:

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

Una volta scaricata l'immagine, il CSS sostituirà automaticamente il gradiente con l'immagine.

Successivamente, aggiungeremo del CSS per rimuovere alcuni comportamenti, consentendo al browser di 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 tocco, il che libera il browser dal tentativo di 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 deve essere chiusa. Ho ottenuto la funzione di accelerazione personalizzata (cubic-bezier(0.4, 0.0, 1,1)) dalla guida Easing di Material Design (scorri fino alla sezione Accelerazione).

Se hai un occhio attento, probabilmente avrai notato la dichiarazione pointer-events: none e ti starai chiedendo di cosa si tratta. Direi che questo è l'unico svantaggio della soluzione finora. Abbiamo bisogno di questo perché un elemento .seen.story sarà in primo piano e riceverà tocchi, anche se è invisibile. Impostando pointer-events su none, trasformiamo la storia del bicchiere in una finestra e non rubiamo più interazioni degli utenti. Non è un compromesso troppo negativo, non è troppo difficile da gestire nel nostro CSS in questo momento. Non stiamo facendo giocoleria con z-index. Sono ancora convinto di questa decisione.

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 difficili per gli sviluppatori. Tuttavia, ci occuperemo di gran parte del lavoro.

Configurazione

Per iniziare, calcoliamo e memorizziamo 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 recupera e memorizza un riferimento alla radice dell'elemento HTML principale. La riga successiva calcola il centro dell'elemento, in modo da poter decidere se un tocco deve andare avanti o indietro.

Stato

Successivamente creiamo un piccolo oggetto con uno stato pertinente alla nostra logica. In questo caso, ci interessa solo la storia attuale. Nel nostro markup HTML, possiamo accedervi prendendo il primo amico e la sua storia più recente. 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
}

Listener

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

Topo

Iniziamo ascoltando l'evento 'click' nel 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 si trova 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 specifica la direzione da seguire. Se la posizione dell'utente è maggiore della mediana, sappiamo che dobbiamo andare a next, altrimenti prev (precedente).

Tastiera

Ora ascoltiamo la pressione dei tasti della tastiera. Se viene premuta la Freccia giù, si passa 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 delle storie

È il momento di affrontare la logica aziendale unica delle storie e la UX per cui sono diventate famose. Sembra complicato, ma se lo leggi riga per riga, ti accorgerai che è abbastanza semplice.

Inizialmente, memorizziamo alcuni selettori che ci aiutano a decidere se scorrere fino a un amico o mostrare/nascondere una storia. Poiché stiamo lavorando sull'HTML, lo interrogheremo 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 altro amico?" L'ho fatto utilizzando la struttura ad albero che abbiamo creato, raggiungendo genitori e figli.

Aggiungi il seguente codice in fondo a 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:

  • Decidere come gestire il tocco
    • Se è presente una storia successiva/precedente, mostrala.
    • Se è l'ultima/prima storia dell'amico: mostra un nuovo amico
    • Se non c'è una storia da visualizzare in quella direzione, non fare nulla.
  • Salva 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 è tutto ciò che mi serviva per il componente. Sentiti libero di ampliare il modello, basarlo sui dati e personalizzarlo.