D
P
0
← Semua artikel Read in English

Shopify, Liquid & Theme CLI

Rotasi Saat Scroll Kadang Jalan Kadang Tidak, dan Gambarnya Salah: Satu Keluhan, Dua Bug Terpisah

· · 8 menit baca
Rotasi Saat Scroll Kadang Jalan Kadang Tidak, dan Gambarnya Salah: Satu Keluhan, Dua Bug Terpisah

Laporannya masuk sebagai satu kalimat dari klien, pendek dan tanpa satu pun istilah teknis: "sometimes box appears, sometimes not rotating".

Yang dia maksud adalah section bercerita, tempat tiga kemasan sachet mengalir di galeri dan berputar pelan mengikuti scroll. Satu kalimat itu memuat dua gejala berdampingan, gambar yang salah muncul kadang-kadang, dan rotasi yang tidak selalu jalan.

Godaannya jelas: dua gejala yang datang bersamaan dari satu section terasa seperti satu bug dengan dua wajah, jadi cari satu sebab lalu anggap sisanya ikut sembuh. Ternyata bukan begitu. Dua gejala itu punya dua sebab yang saling bebas, dan yang menghubungkan keduanya cuma kalimat klien tadi.

Bentuk section-nya

Section itu galeri parallax setinggi 350vh. Kontennya sticky di 100vh dan galerinya mengalir zigzag, item ganjil rata ke satu sisi dan item genap ke sisi lainnya, dengan jarak 50vh antar item. Tiap sachet dapat rotasi saat masuk layar, awalnya lima derajat, dan belakangan sudut maksimumnya dinaikkan jadi 90 derajat.

Sebab pertama: setting yang seharusnya tidak dibuka

Tiga sachet itu bukan konten. Mereka bagian dari desain, lengkap dengan tata letak zigzag dan rotasinya. Tapi di schema, ketiganya diekspos sebagai image_picker, satu setting per sachet.

{
  "type": "image_picker",
  "id": "sachet_2",
  "label": "Sachet 2"
}

Setting seperti itu baru berarti kalau ada yang membacanya, dan pembacanya menang atas aset tema. Bentuknya kira-kira begini:

{% assign sachet = section.settings.sachet_2 %}
{% if sachet %}
  {{ sachet | image_url: width: sachet.width | image_tag }}
{% else %}
  <img src="{{ 'sachet-2.webp' | asset_url }}" alt="">
{% endif %}

Selama tidak ada yang mengisi setting itu, semuanya terlihat benar. Dugaan untuk gambar yang salah adalah salah satu setting itu terisi render kemasan kotak lewat admin. Dugaan, bukan kepastian: catatan kerjanya sendiri berhedge di titik ini, dan saya tidak punya jejak siapa yang mengubahnya dan kapan.

Yang bukan dugaan adalah kenapa itu mungkin terjadi. Setting bertipe image_picker, video, dan file_reference memang menampung media unggahan klien, dan media itu cuma hidup di JSON template versi live. Di tema yang sama pernah ada insiden terpisah yang menunjukkannya dengan mahal: klien mengunggah gambar sendiri lewat theme editor untuk setting gambar sebuah section lain, setting itu hanya ada di JSON live, salinan lokal tidak punya field-nya, lalu push dari lokal menimpa JSON live sampai settingnya terhapus dan halamannya jatuh ke fallback Liquid. Theme editor adalah tempat klien bekerja, dan setting yang saya buka di sana adalah setting yang cepat atau lambat akan diisi seseorang.

Jadi mengganti nilainya lewat admin bukan perbaikan, itu cuma mengisi ulang lubang yang masih menganga. Perbaikannya membuang pilihannya: aset sachet ditanam langsung di markup, dan setting image_picker dibuang dari schema sepenuhnya.

<img src="{{ 'sachet-2.webp' | asset_url }}" alt="">

Di catatan kebersihan schema, ketiga setting itu tercatat dibuang dengan alasan yang sama, markup-nya sudah hardcoded. Nilai lama yang terlanjur tersimpan di JSON template memang tidak ikut hilang, tapi tidak ada lagi yang membacanya, jadi nilai itu berhenti berarti.

Sebab kedua: handler yang cuma tahu perubahan

Sebab keduanya ada di handler scroll-nya sendiri, dan bentuknya dua kekurangan dalam satu fungsi. Handler itu tidak di-batch dengan requestAnimationFrame, dan tidak pernah memasang transform awal.

Yang kedua itu yang bikin gejalanya kelihatan acak. Tanpa lintasan awal, keadaan rotasi salah sampai event scroll pertama datang. Kalau pengunjung mendarat di atas halaman lalu turun pelan-pelan, event scroll pertama datang jauh sebelum section-nya terlihat, jadi rotasinya sudah benar begitu sampai. Kalau halaman terbuka dengan section itu sudah berada di layar, misalnya karena browser memulihkan posisi scroll setelah tombol back ditekan, sachetnya diam lurus sampai ada yang menggerakkan scroll sedikit. Handler-nya tahu cara menanggapi perubahan, dia tidak pernah tahu keadaan.

Kekurangan yang satunya lebih gampang dijelaskan. Tanpa batching, tiap event scroll langsung mengukur geometri lalu menulis style. Di perangkat yang menembakkan banyak event scroll dalam satu frame, pekerjaan itu diulang berkali-kali untuk frame yang sama, dan semuanya kecuali yang terakhir terbuang.

Versi barunya membawa empat hal sekaligus: batching lewat requestAnimationFrame, siklus rotasi yang benar per posisi viewport, satu lintasan awal saat init, dan handler image-load supaya paint pertama sudah benar.

Siklus rotasinya punya tiga keadaan yang eksplisit. Selama sachet masih di bawah viewport sudutnya nol, selama dia terlihat sudutnya naik bertahap, dan setelah dia lewat ke atas sudutnya berhenti di nilai maksimum.

const MAX_DEG = 90;
const figures = section.querySelectorAll(".story-figure");
let ticking = false;
 
function apply() {
  const vh = window.innerHeight;
 
  figures.forEach((fig) => {
    const img = fig.querySelector("img");
    const rect = fig.getBoundingClientRect();
 
    // rect.top >= vh  : masih di bawah viewport, progres 0
    // rect.top <= 0   : sudah lewat ke atas, progres 1
    // di antaranya    : naik bertahap mengikuti posisi
    const progress = Math.min(1, Math.max(0, 1 - rect.top / vh));
 
    img.style.transform = `rotate(${(progress * MAX_DEG).toFixed(2)}deg)`;
  });
 
  ticking = false;
}
 
function onScroll() {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(apply);
}
 
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });

Clamp di baris progress itulah yang menampung ketiga keadaan tadi dalam satu ekspresi. Tanpa clamp, sachet yang sudah lewat ke atas layar akan terus diputar melewati sudut maksimum, dan yang masih jauh di bawah akan mendapat nilai negatif.

Flag ticking mengubah pola kerjanya. Berapa pun event scroll yang datang, cuma satu apply yang dijadwalkan per frame, dan pengukuran serta penulisan style terjadi di tempat yang memang disediakan browser untuk itu.

Lalu dua panggilan yang menutup gejala aslinya:

apply(); // lintasan awal, inilah yang dulu tidak ada
 
section.querySelectorAll(".story-figure img").forEach((img) => {
  if (img.complete) return;
  img.addEventListener("load", apply, { once: true });
});

Panggilan pertama membuat keadaan awal benar tanpa menunggu siapa pun menggerakkan scroll. Panggilan kedua menutup sisa yang lebih halus. Yang dibaca apply cuma rect.top, dan rect.top sebuah figure ikut bergeser kalau figure di atasnya masih berubah tinggi sementara gambar-gambarnya baru berdatangan. Begitu satu gambar selesai dimuat dan tata letaknya settle, lintasan ulang menaruh sudutnya di tempat yang benar. Lintasan onload ini memang bagian dari perbaikannya, bukan tambahan belakangan.

Zigzag di layar kecil, dan kenapa geserannya di induk

Nilai responsif section ini awalnya berjenjang: tinggi section 220vh, 280vh, lalu 350vh, lebar sachet 70vw, 50vw, lalu 36vw, dan jarak antar item galeri 30vh, 40vh, lalu 50vh. Di bawah 768px item galerinya rata tengah, jadi zigzagnya cuma aktif dari 768px ke atas.

Klien minta zigzag di mobile dan tablet muncul sama seperti di desktop, jadi aturan zigzagnya pindah ke base dan media query 768px-nya hilang. Sisanya jadi empat putaran bolak-balik.

Putaran pertama mengecilkan sachet ke 55vw di mobile dan 42vw di tablet supaya ada ruang untuk digeser. Balasan klien: jadi kecil. Putaran kedua menaikkannya lagi ke 60vw dan 44vw, dan balasannya zigzagnya kurang terlihat. Dua putaran itu menabrak dinding yang sama, karena ruang geser dan ukuran sachet berebut lebar yang sama.

Putaran ketiga berhenti menukar keduanya. Sachetnya digeser sampai keluar batas, dan galerinya yang memotong.

@media (max-width: 900px) {
  .story-figure {
    transform: translateX(18%);
  }
 
  .story-figure:nth-child(2n) {
    transform: translateX(-18%);
  }
}

Galeri pembungkusnya sudah punya overflow: hidden, jadi bagian yang meluber terpotong rapi. Sachet yang kelihatan terpotong di tepi itulah yang bikin zigzagnya terbaca tegas, dan ukurannya tidak dikecilkan sedikit pun. Di desktop nilainya dibiarkan natural.

Yang penting di sini adalah pilihan elemennya. Geseran itu ditaruh di elemen figure induknya, bukan di img di dalamnya, karena transform milik img sudah dikuasai JavaScript: handler rotasi menimpanya lewat inline style tiap frame. Menyentuh img berarti melawan animasinya. Dengan menaruhnya di induk, dua transform itu hidup di dua elemen berbeda dan menumpuk sendiri tanpa saling berebut.

Putaran keempat menaikkan ukurannya lagi ke 65vw di mobile dan 48vw di tablet, dengan persen bleed dibiarkan tetap supaya proporsi zigzagnya tidak ikut berubah. Itu keadaan finalnya.

Satu perbaikan di section yang sama yang tidak bertahan

Sebelum urusan zigzag, ada perbaikan lain di section ini yang umurnya pendek. Rotasi sachet terasa jumpy di mobile karena transform per gambar diperbarui terus mengikuti scroll, jadi rotasinya dimatikan di sana. Caranya lewat pengecekan matchMedia('(max-width: 767px)'), fungsi apply yang early-return kalau mobile atau kalau pengguna minta gerakan dikurangi, dan will-change: transform yang dipindah ke media query 768px ke atas, karena tidak ada gunanya memesan layer GPU di viewport yang memang tidak menganimasikan apa-apa.

Klien minta itu direvert, dan rotasi dihidupkan lagi di mobile. Konstanta isMobile dibuang, early-return-nya balik jadi cuma menjaga preferensi gerakan dan galeri yang kosong, dan will-change: transform balik ke rule base untuk semua viewport. Yang tetap tinggal adalah cross-fade fase keduanya, dan itu memang tidak pernah jadi soal karena dia digerakkan lewat kelas CSS, bukan lewat transform yang ditulis ulang tiap frame.

Bug ketiga, sebab ketiga

Section yang sama juga pernah punya IntersectionObserver fase yang tidak fire. Implementasi awalnya mengamati section luarnya yang setinggi 350vh dengan threshold 0,2, padahal 20 persen dari 350vh sama dengan 70vh, dan 70vh bukan titik yang akurat untuk memicu pas konten sticky-nya berada di tengah viewport. Perbaikannya mengamati panel sticky 100vh-nya, dengan threshold 0 dan rootMargin -5%, plus timer fallback empat detik untuk kasus observer-nya tidak fire padahal section-nya jelas ada di layar.

Sebabnya beda, perbaikannya beda, dan tempatnya sama persis. Itu poin yang sama dengan yang di atas, cuma dari arah lain.

Yang saya bawa pulang

Kalau sebuah gambar memang bagian dari desain dan bukan konten yang boleh diganti, tempatnya di markup, bukan di schema. Setting yang saya buka di theme editor adalah setting yang suatu saat akan diisi, dan media hasil unggahan lewat sana cuma hidup di JSON template versi live, di mana dia rapuh dengan cara yang lain lagi.

Pelajaran keduanya soal cara membaca laporan. Satu kalimat keluhan bisa memuat dua bug yang tidak berhubungan, dan begitu ketemu satu sebab yang meyakinkan untuk sebagian gejalanya, gampang sekali berhenti mencari terlalu cepat. Pecah dulu kalimatnya jadi gejala-gejala terpisah, lalu pastikan tiap gejala punya sebabnya sendiri.

Dan yang terakhir: handler scroll cuma menjelaskan perubahan, dia tidak pernah menjelaskan keadaan. Selama tidak ada lintasan awal yang dijalankan saat init, animasi berbasis scroll akan selalu punya jendela di mana tampilannya bohong, dan jendela itu tepat berada di detik pertama yang dilihat pengunjung. Pola yang sama pernah muncul di modul lain di tema yang sama, kanvasnya kosong sampai pengunjung scroll, dan perbaikannya sebentuk juga: onload per gambar yang memanggil requestAnimationFrame, supaya frame nol tergambar begitu decode selesai.