Codelab: creazione di un componente Storie

Questo codelab ti insegna come creare un'esperienza come le Storie Instagram sul web. Creeremo il componente man mano che procediamo, iniziando con HTML, poi CSS, poi JavaScript.

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

Configurazione

  1. Fai clic su Remix 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 utile utilizzare un elemento <section> per ogni amico e un elemento <article> per ogni storia. Cominciamo dall'inizio. Prima di tutto, abbiamo bisogno di un container per il componente Stories.

Aggiungi un elemento <div> a <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>
  • Stiamo usando 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 parleremo più avanti nella prossima sezione.

CSS

I nostri contenuti sono pronti per lo stile. Trasformiamo quelle ossa in qualcosa con cui la gente vorrà interagire. Oggi lavoreremo principalmente per i dispositivi mobili.

.stories

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

  • Impostazione del container come Griglia
  • Impostazione di ogni elemento secondario in modo che occupi la traccia della riga
  • Rendere la larghezza di ogni elemento secondario la larghezza dell'area visibile di un dispositivo mobile

La griglia continuerà a posizionare nuove colonne di 100vw a destra di quella precedente, finché non vengono posizionati tutti gli elementi HTML nel markup.

Chrome e DevTools si aprono con una griglia che mostra il layout a larghezza intera
Chrome DevTools mostra l'overflow delle colonne della griglia, che crea uno scorrimento orizzontale.

Aggiungi il seguente CSS in fondo a 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 dire 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 poggia delicatamente sulla storia successiva, quindi useremo scroll-snap-type: x mandatory. Scopri di più su questo CSS nelle sezioni Snap Points di scorrimento CSS e overscroll-behavior del mio post del blog.

Sia il contenitore principale sia i publisher secondari accettino di scorrere a scatto, quindi gestiamolo 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 seguente mostra cosa succede quando l'app scroll-snap-type è attivata e disattivata. Quando questa opzione è abilitata, ogni scorrimento orizzontale si aggancia alla storia successiva. Quando è disattivato, il browser usa il comportamento di scorrimento predefinito.

Questo ti indurrà a scorrere i tuoi amici, ma abbiamo ancora un problema con le storie da risolvere.

.user

Creiamo un layout nella sezione .user che esegua il wrangling degli elementi secondari della storia. Utilizzeremo un pratico trucco dell'impilamento per risolvere questo problema. Essenzialmente stiamo creando una griglia 1 x 1 in cui la riga e la colonna hanno lo stesso alias griglia di [story] e ogni elemento della griglia della storia proverà a rivendicare questo spazio, generando 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 la serie di regole seguente in fondo a app/css/index.css:

.story {
  grid-area: story;
}

Ora, senza posizionamento assoluto, float o altre istruzioni di layout che eliminano il flusso di un elemento, siamo ancora in movimento. Inoltre, è come un semplice 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 accennato al fatto che l'attributo style su ogni elemento <article> fa parte di una tecnica di caricamento segnaposto:

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

Useremo la proprietà background-image del CSS, che ci consente di specificare più di un'immagine di sfondo. Possiamo ordinarle in modo che l'immagine dell'utente sia in alto e venga visualizzata automaticamente al termine del caricamento. Per attivare questa funzionalità, inseriamo l'URL immagine in una proprietà personalizzata (--bg) e lo utilizzeremo nel nostro CSS per sovrapporre il segnaposto di caricamento.

Innanzitutto, aggiorniamo la serie di regole .story per sostituire un gradiente con un'immagine di sfondo al termine del caricamento. Aggiungi il codice evidenziato al tuo 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 assicura che non ci siano spazi vuoti nel viewport perché l'immagine lo riempirà. La definizione di 2 immagini di sfondo ci consente di eseguire un trucco web CSS efficace chiamato tombstone di caricamento:

  • L'immagine di sfondo 1 (var(--bg)) è l'URL che abbiamo inserito 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

Al termine del download dell'immagine, CSS sostituirà automaticamente il gradiente con l'immagine.

Successivamente aggiungeremo del codice CSS per rimuovere alcuni comportamenti, liberando il browser per muoversi più velocemente. Aggiungi il codice evidenziato al tuo 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 del testo
  • touch-action: manipulation indica al browser che queste interazioni devono essere trattate come eventi touch, in modo da liberare il browser dal tentativo di decidere se fare clic o meno su un URL.

Infine, aggiungiamo un po' di CSS per animare la transizione tra le storie. Aggiungi il codice evidenziato al tuo 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 ottenuto la funzione di easing personalizzato (cubic-bezier(0.4, 0.0, 1,1)) dalla guida di Easing di Material Design (scorri fino alla sezione Easingaggio sincronizzato).

Se hai un occhio attento, probabilmente hai notato la dichiarazione pointer-events: none e ti stai grattando la testa in questo momento. Direi che questo è l'unico svantaggio della soluzione finora. Abbiamo bisogno di questa funzione perché un elemento .seen.story sarà in primo piano e riceverà i tocchi, anche se è invisibile. Se imposti pointer-events su none, trasformiamo la storia di vetro in una finestra e non sottraiamo altre interazioni degli utenti. Né un compromesso, né troppo difficile da gestire ora nel nostro CSS. Non stiamo destando tra z-index. Mi sento ancora bene.

JavaScript

Le interazioni di un componente Storie sono piuttosto semplici per l'utente: tocca a destra per andare avanti e a sinistra per tornare indietro. Le cose semplici per gli utenti tendono a essere un lavoro duro per gli sviluppatori. Ce ne occuperemo moltissime, però.

Configurazione

Per iniziare, comprendiamo e archiviamo il maggior numero di informazioni possibile. Aggiungi il codice seguente a app/js/index.js:

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

La nostra prima riga di JavaScript recupera e memorizza un riferimento alla radice dell'elemento HTML principale. La riga successiva calcola la posizione del centro dell'elemento, così possiamo decidere se toccare per 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 attuale. Nel markup HTML possiamo accedervi recuperando 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 abbastanza logica per iniziare ad ascoltare gli eventi degli utenti e indirizzarli.

Topo

Iniziamo ascoltando l'evento 'click' nel nostro contenitore delle 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>, abbandoneremo l'operazione. Se si tratta di un articolo, afferriamo la posizione orizzontale del mouse o del dito con clientX. Non abbiamo ancora implementato navigateStories, ma l'argomento che prende specifica la direzione da prendere. Se la posizione dell'utente è superiore alla mediana, sappiamo di dover andare su next, altrimenti prev (precedente).

Tastiera

Ora ascoltiamo le pressioni della tastiera. Se viene premuta la Freccia giù, passiamo a next. Se si tratta della Freccia su, vai 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

È giunto il momento di affrontare l'esclusiva logica di business delle storie e dell'UX per cui sono diventate famosi. Sembra grossa e complicata, ma credo che, riga per riga, ti accorgerai che è abbastanza digeribile.

Innanzitutto, mostriamo alcuni selettori che ci aiutano a decidere se scorrere fino a un amico o mostrare/nascondere una notizia. Dal momento che l'HTML è il posto in cui lavoriamo, effettueremo query per verificare la presenza di amici (utenti) o storie (storia).

Queste variabili ci aiuteranno a rispondere a domande quali "per la storia x, se "successivo" significa passare a un'altra storia dallo stesso amico o da un amico diverso?". L'ho fatto utilizzando la struttura ad albero che abbiamo costruito, raggiungendo i genitori e i loro 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 per la logica di business, il più vicino possibile al linguaggio naturale:

  • Decidi come gestire il tocco.
    • Se è presente una storia successiva/precedente: mostrala
    • Se è l'ultima o la prima storia dell'amico: mostra un nuovo amico
    • Se non c'è alcuna notizia a cui puntare in quella direzione: non fare nulla
  • Archivia la nuova storia attuale su 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 Schermo intero schermo intero.

Conclusione

Questa è la conclusione per le mie esigenze legate al componente. Puoi basarci su questo approccio, guidalo con i dati e, in generale, rendilo tuo!