Codelab: creazione di un componente Storie

Questo codelab ti insegna a creare un'esperienza come le Storie di Instagram sul web. Lo creeremo man mano, iniziando con HTML, poi CSS e infine JavaScript.

Dai un'occhiata al mio post del blog Creare un componente di 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. Ma iniziamo dall'inizio. Innanzitutto, abbiamo bisogno di un contenitore per il nostro componente di storie.

Aggiungi un elemento <div> al <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 del segnaposto, che verrà descritta più dettagliatamente nella sezione successiva.

CSS

I nostri contenuti sono pronti per essere personalizzati. Trasformiamo queste basi in qualcosa con cui le persone vorranno interagire. Oggi lavoreremo con l'approccio mobile-first.

.stories

Per il nostro contenitore <div class="stories"> vogliamo un contenitore con scorrimento orizzontale. Possiamo farlo:

  • Creare un riquadro nel contenitore
  • Impostazione di ogni elemento secondario per riempire il canale di riga
  • Impostare la larghezza di ogni elemento secondario in base alla larghezza della visualizzazione di un dispositivo mobile

La griglia continuerà a inserire nuove colonne larghe 100vw a destra di quella precedente, finché non avrà inserito tutti gli elementi HTML nel markup.

Chrome e DevTools si aprono con una visualizzazione a griglia che mostra il layout a larghezza intera
Chrome DevTools mostra il superamento della colonna della griglia, creando un dispositivo di scorrimento orizzontale.

Aggiungi il seguente CSS alla parte inferiore 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 dire 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;
}

Poiché vogliamo lo scorrimento orizzontale, imposteremo overflow-x su auto. Quando l'utente scorre, vogliamo che il componente si fermi 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 post del blog.

Sia il contenitore principale sia i contenitori secondari devono accettare lo snap dello scorrimento, quindi vediamo come gestire questo aspetto. Aggiungi il seguente codice 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 quandoscroll-snap-type è attivato e disattivato. Se questa opzione è attivata, ogni scorrimento orizzontale passa alla storia successiva. Se viene disattivata, il browser utilizza il suo comportamento di scorrimento predefinito.

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

.user

Creiamo un layout nella sezione .user che gestisca gli elementi secondari della storia. Per risolvere il problema, utilizzeremo un pratico trucco di apilazione. Stiamo essenzialmente creando una griglia 1x1 in cui la riga e la colonna hanno lo stesso alias della griglia [story] e ogni elemento della griglia della storia cercherà di rivendicare questo spazio, resulting in a stack.

Aggiungi il codice evidenziato al tuo insieme 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 alla fine di app/css/index.css:

.story {
  grid-area: story;
}

Ora, senza posizionamento assoluto, elementi in primo piano o altre direttive di layout che rimuovano un elemento dal flusso, il flusso rimane invariato. Inoltre, è come se non ci fosse codice, guarda! Questo aspetto viene spiegato più nel dettaglio nel video e nel post del blog.

.story

Ora non ci resta che 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 ordinarli in modo che la nostra immagine dell'utente sia in alto e venga visualizzata automaticamente al termine del caricamento. Per attivare questa funzionalità, inseriremo l'URL immagine in una proprietà personalizzata (--bg) e la utilizzeremo nel CSS per sovrapporla al segnaposto di caricamento.

Innanzitutto, aggiorniamo il set di regole .story per sostituire un gradiente con un'immagine di sfondo al termine del caricamento. Aggiungi il codice evidenziato al tuo insieme 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 siano spazi vuoti nel visualizzatore perché la nostra immagine lo riempirà. La definizione di due immagini di sfondo ci consente di utilizzare un bel 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 al termine del download.

Aggiungeremo del CSS per rimuovere alcuni comportamenti, liberando il browser per consentirgli di funzionare più velocemente. Aggiungi il codice evidenziato al tuo insieme 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 ottenuto la funzione di transizione personalizzata (cubic-bezier(0.4, 0.0, 1,1)) dalla guida di Material Design sull'attenuazione (scorri fino alla sezione Transizione accelerata).

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. Ne abbiamo bisogno perché un elemento .seen.story sarà 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ù le interazioni degli utenti. Non è un compromesso troppo sfavorevole, non è troppo difficile da gestire qui nel nostro CSS al momento. Non stiamo facendo il giocoliere con z-index. Mi sento ancora bene.

JavaScript

Le interazioni di un componente di Storie sono abbastanza 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. ma ce ne occuperemo noi.

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 all'elemento HTML principale. La riga successiva calcola la posizione del 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 corrente. Nel nostro markup HTML, possiamo accedervi selezionando 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' nel 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>, abbandoniamo l'operazione e 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 viene specificato indica la direzione in cui dobbiamo andare. Se la posizione dell'utente è superiore alla mediana, sappiamo che dobbiamo andare a next, altrimenti a prev (precedente).

Tastiera

Ora ascoltiamo le pressioni dei tasti. Se viene premuta la Freccia giù, andiamo a next. Se è la 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

È arrivato il momento di affrontare la logica commerciale unica delle Storie e l'esperienza utente per cui sono diventate famose. Sembra complicato, ma se lo analizzi riga per riga, scoprirai che è abbastanza comprensibile.

Innanzitutto, nascondiamo alcuni selettori che ci aiutano a decidere se scorrere fino a un amico o mostrare/nascondere una storia. Dato che stiamo lavorando con HTML, eseguiremo query per verificare la presenza di amici (utenti) o storie (story).

Queste variabili ci aiuteranno a rispondere a domande come "data la storia x, "successiva" significa passare a un'altra storia dello stesso amico o di un amico diverso?" Ho utilizzato la struttura ad albero che abbiamo creato per raggiungere genitori e figli.

Aggiungi il seguente codice 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 esiste una storia successiva/precedente: mostrala
    • Se è l'ultima/la prima storia dell'amico: mostra un nuovo amico
    • Se non esiste una storia in quella direzione, non fare nulla
  • Metti da parte la nuova storia attuale 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 A schermo intero schermo intero.

Conclusione

Ecco un riepilogo delle mie esigenze relative al componente. Non esitare a migliorarla, a utilizzarla con i dati e, in generale, a farla tua.