Codelab: Membuat komponen Stories

Codelab ini mengajarkan cara membuat pengalaman seperti Instagram Stories di web. Kita akan membangun komponen sambil berjalan, dimulai dengan HTML, lalu CSS, kemudian JavaScript.

Lihat postingan blog saya Membangun komponen Stories untuk mempelajari peningkatan progresif yang dilakukan saat membangun komponen ini.

Penyiapan

  1. Klik Remix to Edit untuk membuat project dapat diedit.
  2. Buka app/index.html.

HTML

Saya selalu berupaya menggunakan HTML semantik. Karena setiap teman dapat memiliki sejumlah cerita, saya pikir akan lebih baik menggunakan elemen <section> untuk setiap teman dan elemen <article> untuk setiap cerita. Namun, mari kita mulai dari awal. Pertama, kita memerlukan penampung untuk komponen cerita.

Tambahkan elemen <div> ke <body> Anda:

<div class="stories">

</div>

Tambahkan beberapa elemen <section> untuk merepresentasikan teman:

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

Tambahkan beberapa elemen <article> untuk merepresentasikan cerita:

<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>
  • Kami menggunakan layanan gambar (picsum.com) untuk membantu membuat prototipe cerita.
  • Atribut style pada setiap <article> adalah bagian dari teknik pemuatan placeholder, yang akan Anda pelajari lebih lanjut di bagian berikutnya.

CSS

Konten kita siap untuk diberi gaya. Mari kita ubah kerangka tersebut menjadi sesuatu yang akan membuat orang ingin berinteraksi. Kami akan memprioritaskan situs seluler hari ini.

.stories

Untuk penampung <div class="stories">, kita menginginkan penampung scrolling horizontal. Kita dapat mencapainya dengan:

  • Menjadikan penampung sebagai Petak
  • Menyetel setiap anak untuk mengisi jalur baris
  • Membuat lebar setiap turunan menjadi lebar area tampilan perangkat seluler

Grid akan terus menempatkan kolom lebar 100vw baru di sebelah kanan kolom sebelumnya, hingga menempatkan semua elemen HTML dalam markup Anda.

Chrome dan DevTools terbuka dengan visual petak yang menampilkan tata letak lebar penuh
Chrome DevTools menampilkan kolom petak yang meluap, sehingga membuat scroller horizontal.

Tambahkan CSS berikut ke bagian bawah app/css/index.css:

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

Sekarang setelah kita memiliki konten yang melampaui area pandang, saatnya memberi tahu penampung tersebut cara menanganinya. Tambahkan baris kode yang ditandai ke set aturan .stories Anda:

.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;
}

Kita menginginkan scroll horizontal, jadi kita akan menyetel overflow-x ke auto. Saat pengguna men-scroll, kita ingin komponen berhenti dengan mulus di cerita berikutnya, jadi kita akan menggunakan scroll-snap-type: x mandatory. Baca selengkapnya tentang CSS ini di bagian CSS Scroll Snap Points dan overscroll-behavior di postingan blog saya.

Penampung induk dan turunan harus menyetujui penempelan scroll, jadi mari kita tangani sekarang. Tambahkan kode berikut ke bagian bawah app/css/index.css:

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

Aplikasi Anda belum berfungsi, tetapi video di bawah menunjukkan apa yang terjadi saat scroll-snap-type diaktifkan dan dinonaktifkan. Jika diaktifkan, setiap scroll horizontal akan berpindah ke artikel berikutnya. Jika dinonaktifkan, browser akan menggunakan perilaku scrolling default-nya.

Anda akan menelusuri teman, tetapi kami masih memiliki masalah dengan cerita yang harus diselesaikan.

.user

Mari kita buat tata letak di bagian .user yang mengatur elemen story turunan tersebut. Kita akan menggunakan trik penumpukan praktis untuk menyelesaikannya. Pada dasarnya, kita membuat petak 1x1 dengan baris dan kolom yang memiliki alias petak yang sama, yaitu [story], dan setiap item petak cerita akan mencoba mengklaim ruang tersebut, sehingga menghasilkan tumpukan.

Tambahkan kode yang ditandai ke set aturan .user Anda:

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

Tambahkan set aturan berikut ke bagian bawah app/css/index.css:

.story {
  grid-area: story;
}

Sekarang, tanpa pemosisian absolut, float, atau petunjuk tata letak lain yang mengeluarkan elemen dari alur, kita masih berada dalam alur. Selain itu, kodenya hampir tidak ada, lihat itu! Hal ini diuraikan dalam video dan postingan blog secara lebih mendetail.

.story

Sekarang kita hanya perlu menata gaya item cerita itu sendiri.

Sebelumnya, kami menyebutkan bahwa atribut style pada setiap elemen <article> adalah bagian dari teknik pemuatan placeholder:

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

Kita akan menggunakan properti background-image CSS, yang memungkinkan kita menentukan lebih dari satu gambar latar. Kita dapat mengurutkannya sehingga foto pengguna berada di atas dan akan muncul secara otomatis saat selesai dimuat. Untuk mengaktifkannya, kita akan memasukkan URL gambar ke dalam properti kustom (--bg), dan menggunakannya dalam CSS untuk menyusun dengan placeholder pemuatan.

Pertama, mari kita perbarui set aturan .story untuk mengganti gradien dengan gambar latar belakang setelah selesai dimuat. Tambahkan kode yang ditandai ke set aturan .story Anda:

.story {
  grid-area: story;

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

Menetapkan background-size ke cover memastikan tidak ada ruang kosong di viewport karena gambar kita akan mengisinya. Menentukan 2 gambar latar memungkinkan kita menarik trik web CSS yang rapi yang disebut tanda kuburan pemuatan:

  • Gambar latar 1 (var(--bg)) adalah URL yang kami teruskan inline di HTML
  • Gambar latar 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) adalah gradien yang ditampilkan saat URL sedang dimuat

CSS akan otomatis mengganti gradien dengan gambar, setelah gambar selesai didownload.

Selanjutnya, kita akan menambahkan beberapa CSS untuk menghapus beberapa perilaku, sehingga browser dapat bergerak lebih cepat. Tambahkan kode yang ditandai ke set aturan .story Anda:

.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 mencegah pengguna memilih teks secara tidak sengaja
  • touch-action: manipulation menginstruksikan browser bahwa interaksi ini harus diperlakukan sebagai peristiwa sentuh, yang membebaskan browser dari mencoba memutuskan apakah Anda mengklik URL atau tidak

Terakhir, mari tambahkan sedikit CSS untuk menganimasikan transisi antar-cerita. Tambahkan kode yang ditandai ke set aturan .story Anda:

.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;
  }
}

Class .seen akan ditambahkan ke cerita yang memerlukan pintu keluar. Saya mendapatkan fungsi easing kustom (cubic-bezier(0.4, 0.0, 1,1)) dari panduan Easing Desain Material (scroll ke bagian Easing yang dipercepat).

Jika Anda jeli, Anda mungkin melihat deklarasi pointer-events: none dan sekarang sedang berpikir keras. Saya rasa ini adalah satu-satunya kekurangan solusi ini sejauh ini. Kita memerlukan ini karena elemen .seen.story akan berada di atas dan akan menerima ketukan, meskipun tidak terlihat. Dengan menyetel pointer-events ke none, kita mengubah cerita kaca menjadi jendela, dan tidak lagi mencuri interaksi pengguna. Tidak terlalu buruk, tidak terlalu sulit untuk dikelola di CSS kami saat ini. Kita tidak sedang menyulap z-index. Saya masih merasa yakin dengan hal ini.

JavaScript

Interaksi komponen Cerita cukup sederhana bagi pengguna: ketuk di kanan untuk maju, ketuk di kiri untuk kembali. Hal-hal sederhana bagi pengguna cenderung sulit dikerjakan oleh developer. Namun, kami akan menangani sebagian besar prosesnya.

Penyiapan

Untuk memulai, mari kita hitung dan simpan sebanyak mungkin informasi yang kita bisa. Tambahkan kode berikut ke app/js/index.js:

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

Baris pertama JavaScript mengambil dan menyimpan referensi ke root elemen HTML utama kita. Baris berikutnya menghitung posisi tengah elemen, sehingga kita dapat memutuskan apakah ketukan akan bergerak maju atau mundur.

Negara Bagian

Selanjutnya, kita membuat objek kecil dengan beberapa status yang relevan dengan logika kita. Dalam kasus ini, kita hanya tertarik dengan cerita saat ini. Dalam markup HTML, kita dapat mengaksesnya dengan mengambil teman pertama dan cerita terbarunya. Tambahkan kode yang disorot ke app/js/index.js Anda:

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

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

Pemroses

Kita sekarang memiliki logika yang cukup untuk mulai memproses dan mengarahkan peristiwa pengguna.

Tikus

Mari kita mulai dengan memproses peristiwa 'click' di penampung cerita. Tambahkan kode yang ditandai ke 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')
})

Jika terjadi klik dan bukan pada elemen <article>, kita akan membatalkan dan tidak melakukan apa pun. Jika berupa artikel, kita mengambil posisi horizontal mouse atau jari dengan clientX. Kita belum menerapkan navigateStories, tetapi argumen yang diperlukan menentukan arah yang harus kita tuju. Jika posisi pengguna tersebut lebih besar dari median, kita tahu bahwa kita harus menavigasi ke next, jika tidak prev (sebelumnya).

Keyboard

Sekarang, mari kita dengarkan penekanan tombol keyboard. Jika Panah Bawah ditekan, kita akan membuka next. Jika tombolnya adalah Panah Atas, kita akan membuka prev.

Tambahkan kode yang ditandai ke 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')
})

Navigasi Stories

Saatnya menangani logika bisnis unik cerita dan UX yang membuat cerita menjadi terkenal. Mungkin terlihat rumit, tetapi jika Anda membacanya baris demi baris, Anda akan mendapati bahwa panduan ini cukup mudah dipahami.

Di awal, kita menyimpan beberapa pemilih yang membantu kita memutuskan apakah akan men-scroll ke teman atau menampilkan/menyembunyikan story. Karena kita bekerja di HTML, kita akan mengkuerinya untuk mengetahui kehadiran teman (pengguna) atau cerita (story).

Variabel ini akan membantu kami menjawab pertanyaan seperti, "jika diberi cerita x, apakah "berikutnya" berarti beralih ke cerita lain dari teman yang sama atau ke teman yang berbeda?" Saya melakukannya dengan menggunakan struktur pohon yang kami buat, menjangkau induk dan turunannya.

Tambahkan kode berikut ke bagian bawah 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
}

Berikut tujuan logika bisnis kita, yang sedekat mungkin dengan bahasa alami:

  • Tentukan cara menangani ketukan
    • Jika ada cerita berikutnya/sebelumnya: tampilkan cerita tersebut
    • Jika itu adalah cerita terakhir/pertama teman: tampilkan teman baru
    • Jika tidak ada cerita yang dapat dituju ke arah tersebut: jangan lakukan apa pun
  • Simpan artikel saat ini yang baru ke state

Tambahkan kode yang ditandai ke fungsi navigateStories Anda:

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
    }
  }
}

Cobalah

  • Untuk melihat pratinjau situs, tekan Lihat Aplikasi. Kemudian, tekan Layar Penuh layar penuh.

Kesimpulan

Itulah ringkasan kebutuhan saya dengan komponen tersebut. Jangan ragu untuk mengembangkannya, menggunakan data untuk menggerakkannya, dan menjadikannya milik Anda.