Membuat Carousel Yang Sempurna, Bagian 3
() translation by (you can also view the original English article)
Ini adalah bagian ketiga dan yang terakhir dari seri tutorial Membuat Carousel Yang Sempurna. Pada bagian 1, kita menilai carousel di Netflix dan Amazon, dua dari carousel yang paling banyak digunakan di dunia. Kita menyiapkan carousel kita dan mengimplementasikan gulir sentuhan.
Kemudian pada bagian 2, kita menambahkan gulir mouse horizontal, pagination, dan indikator perkembangan. Boom.
Sekarang, di bagian akhir kita, kita akan melihat ke dalam aksesibilitas dunia keyboard yang suram dan terlupakan. Kita akan menyesuaikan kode kita untuk mengukur kembali carousel saat ukuran viewport berubah. Dan akhirnya, kita akan memberi beberapa sentuhan akhir menggunakan teknik spring physics.
Kamu dapat mengambil dimana kita meninggalkannya dengan CodePen ini.
Aksesibilitas Keyboard
Memang benar mayoritas pengguna tidak mengandalkan navigasi keyboard, jadi sayangnya terkadang kita melupakan pengguna kita yang melakukannya. Di beberapa negara, membiarkan situs web tidak dapat diakses mungkin ilegal. Tetapi yang lebih buruk lagi, ini adalah tindakan detektif.
Kabar baiknya adalah bahwa hal itu biasanya mudah diterapkan! Sebenarnya, browser melakukan sebagian besar pekerjaan untuk kita. Serius: coba tabbing melalui carousel yang telah kita buat. Karena kita telah menggunakan markup semantik, Kamu sudah bisa!
Kecuali, kamu akan melihat, tombol navigasi kita hilang. Ini karena browser tidak mengizinkan fokus pada elemen di luar viewport kita. Jadi meskipun kita telah menetapkan overflow: hidden
, kita tidak dapat menggulir halaman secara horizontal; Jika tidak, halaman pasti akan bergulir untuk menampilkan elemen dengan fokus.
Ini tidak apa-apa, dan itu akan memenuhi syarat, menurut pendapat saya, sebagai "hal yang berguna", meski tidak terlalu menyenangkan.
Carousel Netflix juga bekerja dengan cara ini. Tetapi karena sebagian besar judul mereka lamban dimuat, dan juga secara pasif keyboard mudah diakses (artinya mereka belum menulis kode apa pun secara khusus untuk menanganinya), kita sebenarnya tidak dapat memilih judul apa pun selain beberapa dari yang sudah kita muat. Ini juga terlihat mengerikan:



Kita bisa berbuat lebih baik.
Tangani event focus
Untuk melakukan ini, kita akan mendengarkan event focus
yang menyala pada item apapun di carousel tersebut. Saat sebuah item menerima fokus, kita akan menanyakannya untuk posisinya. Kemudian, kita akan memeriksa bahwa pada sliderX
dan sliderVisibleWidth
untuk melihat apakah item itu ada di dalam window yang terlihat. Jika tidak, kita akan membuat paginasi dengan menggunakan kode yang sama dengan yang kita tulis di bagian 2.
Di akhir fungsi carousel
, tambahkan event listener ini:
1 |
slider.addEventListener('focus', onFocus, true); |
Anda akan melihat bahwa kita telah memberikan parameter ketiga, true
. Daripada menambahkan event listener ke setiap item, kita dapat menggunakan apa yang dikenal sebagai event delegation untuk mendengarkan event hanya pada satu elemen, induknya langsung. Event focus
tidak bergelembung, jadi true
yang mengatakan kepada event listener untuk mendengarkan tahap penangkapan, tahap dimana event terjadi pada setiap elemen dari window
sampai ke target (dalam hal ini, item yang menerima fokus).
Di atas kumpulan event listener kita, tambahkan fungsi onFocus
:
1 |
function onFocus(e) { |
2 |
}
|
Kita akan bekerja dalam fungsi ini untuk sisa bagian ini.
Kita perlu mengukur offset left
dan right
item dan memeriksa apakah kedua titik berada di luar area yang saat ini dapat dilihat.
Item disediakan oleh parameter event target
, dan kita dapat mengukurnya dengan getBoundingClientRect
:
1 |
const { left, right } = e.target.getBoundingClientRect(); |
left
dan right
relatif terhadap viewport, bukan slider. Jadi kita perlu membuat kontainer carousel offset left untuk memperhitungkannya. Dalam contoh kita, ini akan menjadi 0
, tapi untuk membuat carousel yang kuat, seharusnya akun ditempatkan di mana saja.
1 |
const carouselLeft = container.getBoundingClientRect().left; |
Sekarang, kita dapat melakukan pemeriksaan sederhana untuk melihat apakah item berada di luar area yang terlihat oleh slider dan dipaginasi ke arah itu:
1 |
if (left < carouselLeft) { |
2 |
gotoPrev(); |
3 |
} else if (right > carouselLeft + sliderVisibleWidth) { |
4 |
gotoNext(); |
5 |
}
|
Sekarang, saat kita tab disekitar, carousel dengan percaya diri berkepentingan dengan fokus keyboard kita! Cukup beberapa baris kode untuk menunjukkan lebih banyak cinta kepada pengguna kita.
Mengukur Kembali Carousel
Kamu mungkin telah memperhatikan saat kamu mengikuti tutorial ini bahwa jika kamu mengubah ukuran viewport browser-mu, carousel tidak dipaginasi dengan benar lagi. Ini karena kita mengukur lebarnya relatif terhadap area yang terlihat sekali saja, pada saat inisialisasi.
Untuk memastikan carousel kita berperilaku benar, kita perlu mengganti beberapa kode pengukuran dengan event listener baru yang menyala saat windows
diubah ukurannya.
Sekarang, menjelang awal fungsi carousel
-mu, tepat setelah baris di mana kita mendefinisikan progressBar
, kita ingin mengganti tiga dari pengukuran const
ini dengan let
, karena kita akan mengubahnya saat perubahan viewport:
1 |
const totalItemsWidth = getTotalItemsWidth(items); |
2 |
const maxXOffset = 0; |
3 |
|
4 |
let minXOffset = 0; |
5 |
let sliderVisibleWidth = 0; |
6 |
let clampXOffset; |
Kemudian, kita bisa memindahkan logika yang sebelumnya menghitung nilai-nilai ini ke fungsi measureCarousel
baru:
1 |
function measureCarousel() { |
2 |
sliderVisibleWidth = slider.offsetWidth; |
3 |
minXOffset = - (totalItemsWidth - sliderVisibleWidth); |
4 |
clampXOffset = clamp(minXOffset, maxXOffset); |
5 |
}
|
Kita ingin segera memanggil fungsi ini sehingga kita tetap menetapkan nilai inisialisasinya. Pada baris berikutnya, panggil measureCarousel
:
1 |
measureCarousel(); |
Carousel seharusnya bekerja persis seperti sebelumnya. Untuk memperbarui pada perubahan ukuran window, kita cukup menambahkan event listener ini di bagian akhir fungsi carousel
kita:
1 |
window.addEventListener('resize', measureCarousel); |
Sekarang, jika kamu mengubah ukuran carousel dan mencoba paginasi, itu akan terus bekerja seperti yang diharapkan.
Catatan Tentang Performa
Perlu dipertimbangkan bahwa di dunia nyata, kamu mungkin memiliki banyak carousel di halaman yang sama, mengalikan dampak kinerja dari kode pengukuran ini dengan jumlah tersebut.
Seperti yang telah kita bahas secara singkat di bagian 2, tidaklah bijaksana untuk melakukan perhitungan berat lebih sering dari yang kamu inginkan. Dengan event pointer dan scroll, kita mengatakan bahwa kamu ingin melakukan sekali per frame untuk membantu mempertahankan 60fps. Mengubah ukuran event sedikit berbeda karena seluruh dokumen akan berubah, mungkin momen sumber daya yang paling intensif akan ditemui pada halaman web.
Kita tidak perlu mengukur kembali carouse tersebut sampai pengguna selesai mengubah ukuran jendela, karena mereka tidak akan berinteraksi dengannya saat ini. Kita bisa membungkus fungsi measureCarousel
kita dalam fungsi khusus yang disebut debounce.
Fungsi debounce pada dasarnya mengatakan: "Hanya nyalakan fungsi ini jika tidak dipanggil lebih dari x
milidetik." Kamu dapat membaca lebih lanjut tentang debounce pada David Walsh's excellent primer, dan juga mengambil beberapa contoh kode.
Sentuhan Akhir
Sejauh ini, kita telah menciptakan sebuah korsel yang cukup bagus. Ini mudah diakses, ini beranimasi dengan baik, ia bekerja dengan sentuhan dan mouse, dan ini memberikan fleksibilitas desain yang hebat seperti carousel yang tidak bisa bergulir.
Tetapi ini bukan seri tutorial "Membuat Carousel Yang Cukup Bagus". Sudah waktunya kita pamer sedikit, dan untuk melakukan itu, kita punya senjata rahasia. Springs.
Kita akan menambahkan dua interaksi dengan springs. Satu untuk disentuh, dan satu untuk paginasi. Mereka berdua akan membiarkan pengguna tahu, dengan cara yang menyenangkan dan ceria, bahwa mereka telah sampai di akhir carousel.
Spring Sentuhan
Pertama, mari tambahkan tarikan style iOS saat pengguna mencoba menggulir slider melewati batas-batasnya. Saat ini, kita menutupi gulir sentuhan menggunakan clampXOffset
. Sebagai gantinya, mari kita ganti ini dengan beberapa kode yang menerapkan tarikan saat offset dihitung berada di luar batas-batasnya.
Pertama, kita perlu mengimpor spring kita. Ada transformer yang disebut nonlinearSpring
yang menerapkan kekuatan yang meningkat secara eksponensial terhadap jumlah yang kita berikan, menuju origin
. Yang berarti semakin jauh kita menarik slider, semakin akan ditarik kembali. Kita bisa mengimpornya seperti ini:
1 |
const { applyOffset, clamp, nonlinearSpring, pipe } = transform; |
Dalam fungsi defineDragDirection
, kita memiliki kode ini:
1 |
action.output(pipe( |
2 |
({ x }) => x, |
3 |
applyOffset(action.x.get(), sliderX.get()), |
4 |
clampXOffset, |
5 |
(v) => sliderX.set(v) |
6 |
));
|
Tepat di atas itu, mari kita buat dua spring kita, satu untuk setiap batas gulir carousel:
1 |
const elasticity = 5; |
2 |
const tugLeft = nonlinearSpring(elasticity, maxXOffset); |
3 |
const tugRight = nonlinearSpring(elasticity, minXOffset); |
Memutuskan nilai elasticity
adalah masalah bermain-main saja dan melihat apa yang terasa benar. Terlalu rendah angka, dan spring akan terasa terlalu kaku. Terlalu tinggi dan kamu tidak akan melihat tarikannya, atau lebih buruk lagi, ini akan mendorong slider lebih jauh dari jari pengguna!
Sekarang kita hanya perlu menulis sebuah fungsi sederhana yang akan menerapkan salah satu spring ini jika nilai yang diberikan berada di luar kisaran yang diizinkan:
1 |
const applySpring = (v) => { |
2 |
if (v > maxXOffset) return tugLeft(v); |
3 |
if (v < minXOffset) return tugRight(v); |
4 |
return v; |
5 |
};
|
Kita bisa mengganti clampXOffset
pada kode diatas dengan applySpring
. Sekarang, jika kamu menarik slider melewati batas-batasnya, itu akan tertarik kembali!
Namun, ketika kita melepaskan spring, itu semacam bentakan yang begitu saja kembali ke tempatnya. Kita ingin mengubah fungsi stopTouchScroll
kita, yang saat ini menangani gulir momentum, untuk memeriksa apakah slider masih berada di luar rentang yang diizinkan dan, jika demikian, terapkan spring dengan aksi physics
sebagai gantinya.
Spring Physics
Aksi physics
juga mampu memodelkan spring. Kita hanya perlu menyediakannya dengan properti spring
dan to
.
Di stopTouchScroll
, pindahkan inisialisasi gulir physics
yang ada ke sepotong logika yang memastikan kita berada dalam batas gulir:
1 |
const currentX = sliderX.get(); |
2 |
|
3 |
if (currentX < minXOffset || currentX > maxXOffset) { |
4 |
|
5 |
} else { |
6 |
action = physics({ |
7 |
from: currentX, |
8 |
velocity: sliderX.getVelocity(), |
9 |
friction: 0.2 |
10 |
}).output(pipe( |
11 |
clampXOffset, |
12 |
(v) => sliderX.set(v) |
13 |
)).start(); |
14 |
}
|
Dalam ketentuan pertama dari pernyataan if
, kita tahu bahwa slider berada di luar batas gulir, jadi kita bisa menambahkan spring kita:
1 |
action = physics({ |
2 |
from: currentX, |
3 |
to: (currentX < minXOffset) ? minXOffset : maxXOffset, |
4 |
spring: 800, |
5 |
friction: 0.92 |
6 |
}).output((v) => sliderX.set(v)) |
7 |
.start(); |
Kita ingin membuat spring yang terasa tajam dan responsif. Saya telah memilih nilai spring
yang relatif tinggi untuk memiliki "pop" yang rapat, dan saya telah menurunkan firction
nya menjadi 0.92
sehingga memungkinkan sedikit pantulan. Kamu bisa mengeset ini ke 1
untuk menghilangkan pantulan sepenuhnya.
Sebagai sedikit pekerjaan rumah, cobalah mengganti clampXOffset
dalam fungsi output
dari physics
gulir dengan fungsi yang memicu spring yang sama saat x offset mencapai batas-batasnya. Daripada saat ini tiba-tiba berhenti, cobalah membuatnya terpental dengan lembut di akhir.
Paginasi Spring
Sentuhan pengguna selalu mendapatkan perhatian spring, bukan? Mari berbagi cinta itu dengan pengguna desktop dengan mendeteksi kapan carousel berada pada batas gulirnya, dan memiliki tangkapan indikatif untuk menunjukkan dengan jelas dan percaya diri kepada pengguna bahwa mereka berada di akhir.
Pertama, kami ingin menonaktifkan tombol paginasi saat batasnya tercapai. Pertama mari kita tambahkan aturan CSS yang menonjolkan tombol untuk menunjukkan bahwa mereka disabled
. Pada aturan button
, tambahkan:
1 |
transition: background 200ms linear; |
2 |
|
3 |
&.disabled { |
4 |
background: #eee; |
5 |
}
|
Kita menggunakan class di sini daripada atribut disabled
yang lebih semantik karena kita masih ingin menangkap event klik, yang dimana, seperti namanya, disabled
akan diblok.
Tambahkan kelas disabled
ini ke tombol Prev, karena setiap carousel mulai hidup dengan offset 0
:
1 |
<button class="prev disabled">Prev</button> |
Menuju bagian atas carousel
, buat fungsi baru yang disebut checkNavButtonStatus
. Kita ingin fungsi ini hanya memeriksa nilai yang diberikan terhadap minXOffset
dan maxXOffset
dan set tombol class disabled
sebagaimana yang demikian:
1 |
function checkNavButtonStatus(x) { |
2 |
if (x <= minXOffset) { |
3 |
nextButton.classList.add('disabled'); |
4 |
} else { |
5 |
nextButton.classList.remove('disabled'); |
6 |
|
7 |
if (x >= maxXOffset) { |
8 |
prevButton.classList.add('disabled'); |
9 |
} else { |
10 |
prevButton.classList.remove('disabled'); |
11 |
}
|
12 |
}
|
13 |
}
|
Ini akan menggoda untuk memanggi ini setiap kali sliderX
berubah. Jika kita melakukannya, tombolnya akan mulai berkedip setiap kali ada spring di sekitar batas gulir. Ini juga akan menyebabkan tindakan yang aneh jika salah satu tombol ditekan saat salah satu animasi spring. Tarikan "gulir akhir" harus selalu menyala jika kita berada di ujung carousel, sekalipun ada animasi spring yang menariknya dari ujung yang mutlak.
Jadi kita perlu lebih selektif tentang kapan harus memanggil fungsi ini. Tampaknya masuk akal untuk menyebutnya:
Pada baris terakhir onWheel
, tambahkan checkNavButtonStatus(newX);
.
Pada baris terakhir goto
, tambahkan checkNavButtonStatus(targetX);
.
Dan akhirnya, pada akhir determineDragDirection
, dan dalam ketentuan gulir momentum (kode di dalam else
) dari stopTouchScroll
, ganti:
1 |
(v) => sliderX.set(v) |
Dengan:
1 |
(v) => { |
2 |
sliderX.set(v); |
3 |
checkNavButtonStatus(v); |
4 |
} |
Sekarang yang tersisa adalah mengubah gotoPrev
dan gotoNext
untuk memeriksa tombol pemicu classList untuk di disabled
dan hanya membuat paginasi jika itu tidak ada:
1 |
const gotoNext = (e) => !e.target.classList.contains('disabled') |
2 |
? goto(1) |
3 |
: notifyEnd(-1, maxXOffset); |
4 |
|
5 |
const gotoPrev = (e) => !e.target.classList.contains('disabled') |
6 |
? goto(-1) |
7 |
: notifyEnd(1, minXOffset); |
Fungsi notifyEnd
hanyalah spring physics
lainnya, dan sepertinya terlihat seperti ini:
1 |
function notifyEnd(delta, targetOffset) { |
2 |
if (action) action.stop(); |
3 |
action = physics({ |
4 |
from: sliderX.get(), |
5 |
to: targetOffset, |
6 |
velocity: 2000 * delta, |
7 |
spring: 300, |
8 |
friction: 0.9 |
9 |
}) |
10 |
.output((v) => sliderX.set(v)) |
11 |
.start(); |
12 |
} |
Miliki permainan dengan itu, dan sekali lagi, sesuaikan parameter physics
yang kamu sukai.
Hanya ada satu bug kecil yang tersisa. Ketika slider springs melampaui batas paling kiri, progress bar menjadi terbalik. Kita dengan cepat dapat memperbaikinya dengan mengganti:
1 |
progressBarRenderer.set('scaleX', progress); |
Dengan:
1 |
progressBarRenderer.set('scaleX', Math.max(progress, 0)); |
Kita bisa mencegah ini terpental ke arah lain, tetapi secara pribadi saya pikir itu cukup keren yang itu mencerminkan gerakan spring. Ini hanya tampak aneh ketika membalik ke dalam.
Bersihkan Dirimu Sendiri
Dengan aplikasi satu-halaman, website akan tahan lebih lama dalam sesi pengguna. Sering, bahkan ketika perubahan "halaman", kita masih menjalankan runtime JS sama seperti pada beban awal. Kita tidak dapat mengandalkan daftar yang bersih setiap kali pengguna mengklik link, dan itu berarti kita harus membersihkannya setelah diri kita untuk mencegah event listener menembak mati elemen.
Di React, kode ini ditempatkan di metode componentWillLeave
. Vue menggunakan beforeDestroy
. Ini adalah implementasi JS yang murni, tetapi kita masih dapat menyediakan metode destroy yang akan bekerja sama dalam framework.
Sejauh ini, fungsi carousel
kita belum mengembalikan apa-apa. Mari kita ubah itu.
Pertama, ubah akhir baris, pada baris itu panggil carousel
, ke:
1 |
const destroyCarousel = carousel(document.querySelector('.container')); |
Kita hanya akan mengembalikan satu hal dari carousel
, fungsi yang tidak mengikat semua event listener kita. Pada akhir fungsi carousel
, tuliskan:
1 |
return () => { |
2 |
container.removeEventListener('touchstart', startTouchScroll); |
3 |
container.removeEventListener('wheel', onWheel); |
4 |
nextButton.removeEventListener('click', gotoNext); |
5 |
prevButton.removeEventListener('click', gotoPrev); |
6 |
slider.removeEventListener('focus', onFocus); |
7 |
window.removeEventListener('resize', measureCarousel); |
8 |
};
|
Sekarang, jika kamu memanggil destroyCarousel
dan mencoba untuk bermain dengan carousel, tidak ada yang terjadi! Hal ini hampir menjadi sedikit sedih untuk melihatnya seperti ini.
Dan Itu Saja
Wah. Itu banyak! Seberapa jauh kita telah datang. kamu dapat melihat produk akhirnya pada CodePen ini. Pada bagian akhir ini, kita telah menambahkan aksesibilitas keyboard, mengukur ulang carousel ketika perubahan viewport, beberapa penambahan yang menyenangkan dengan spring physics, dan langkah yang memilukan tetapi perlu langkah untuk meruntuhkan semuanya lagi.
Saya harap kamu menikmati tutorial ini sebanyak yang saya senang menulisnya. Saya akan senang mendengar pemikiranmu dengan cara lebih lanjut kita bisa memperbaiki aksesibilitas, atau menambahkan sedikit sentuhan yang lebih menyenangkan.