あらすじ
Vue.js でシンプルな Carousel を作ります。すでにそういった component は一杯あるのですが、車輪の再開発を通じてお勉強しました、という趣旨の記事です。すでに vue-carousel という定番モジュールがあるのでこれを真似て作成しています。
Just Do it
以下のようにファイルを作成します。
mkdir vue-tiny-carousel && cd $_ mkdir src example touch src/{index.js,Carousel.js,Slide.vue, Navigation.vue} touch example/App.vue yarn init -y yarn add lodash.debounce yarn add -D @vue/cli
touch したファイルを以下のように修正します。
src/index.js
import Carousel from "./Carousel.vue"; import Slide from "./Slide.vue"; const install = Vue => { Vue.component("carousel", Carousel); Vue.component("slide", Slide); }; export default { install }; export { Carousel, Slide };
src/Carousel.Vue
<template> <div class="carousel"> <div ref="carousel-wrapper" class="carousel-wrapper"> <div ref="carousel-inner" class="carousel-inner" :style="{ 'transform': `translate(${offset}px, 0)`, 'transition': dragging ? 'none' : `0.4s ease transform`, 'flex-basis': `${carouselWidth}px`, }" > <slot></slot> </div> </div> <navigation :clickTargetSize=8 :nextLabel="`>`" :prevLabel="`<`" @navigationclick="handleNavigation" /> </div> </template> <script> /* eslint-disable no-console */ import debounce from "lodash.debounce" import Navigation from "./Navigation.vue"; export default { name: "carousel", components: { Navigation }, mounted() { window.addEventListener( "resize", debounce(this.onResize, this.refreshRate) ); this.setSlideCount() this.computeCarouselWidth(); }, beforeUpdate() { // this.computeCarouselWidth(); }, provide() { return { carousel: this }; }, data() { return { slideCount: 0, currentPage: 1, carouselWidth: 0, dragging: false, refreshRate: 16, }; }, computed: { offset() { return this.carouselWidth * (this.currentPage - 1) * -1 }, canAdvanceForward() { return this.slideCount - this.currentPage > 0 }, canAdvanceBackward() { return this.currentPage > 1; } }, methods: { setSlideCount() { const slots = this.$slots const tagName = "slide" const isSlide = slot => slot.tag && slot.tag.match(`^vue-component-\\d+-${tagName}$`) !== null this.slideCount = (slots && slots.default && slots.default.filter(isSlide)).length || 0 }, computeCarouselWidth() { let carouselInnerElements = this.$el.getElementsByClassName("carousel-inner"); this.carouselWidth = carouselInnerElements[0].clientWidth || 0; }, handleNavigation(direction) { if (direction && direction === "backward") { this.goPreviousPage() } else if (direction && direction === "forward") { this.goNextPage() } }, onResize() { this.computeCarouselWidth(); this.dragging = true; // force a dragging to disable animation // clear dragging after refresh rate const refreshRate = 16 setTimeout(() => { this.dragging = false; }, refreshRate); }, goToPage(page) { this.currentPage = page }, getNextPage() { return this.currentPage < this.slideCount ? this.currentPage + 1 : this.currentPage; }, getPreviousPage() { return this.currentPage > 1 ? this.currentPage - 1 : this.currentPage }, goNextPage() { this.goToPage(this.getNextPage()) }, goPreviousPage() { this.goToPage(this.getPreviousPage()) } } } </script> <style scoped> .carousel { height: 100%; display: flex; flex-direction: column; position: relative; } .carousel-wrapper { width: 100%; height: 100%; position: relative; overflow: hidden; } .carousel-inner { height: 100%; display: flex; flex-direction: row; backface-visibility: hidden; } </style>
src/Slide.Vue
<template> <div class="slide"> <slot></slot> </div> </template> <script> export default { name: "slide", } </script> <style scoped> .slide { flex-basis: inherit; flex-grow: 0; flex-shrink: 0; user-select: none; backface-visibility: hidden; -webkit-touch-callout: none; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); outline: none; } </style>
src/Navigation.Vue
<template> <div class="carousel-navigation"> <button type="button" aria-label="Previous page" class="carousel-navigation-button carousel-navigation-prev" v-on:click.prevent="triggerPageAdvance('backward')" v-bind:class="{ 'carousel-navigation--disabled': !canAdvanceBackward }" v-bind:style="`padding: ${clickTargetSize}px; margin-right: -${clickTargetSize}px;`" v-html="prevLabel"></button> <button type="button" aria-label="Next page" class="carousel-navigation-button carousel-navigation-next" v-on:click.prevent="triggerPageAdvance('forward')" v-bind:class="{ 'carousel-navigation--disabled': !canAdvanceForward }" v-bind:style="`padding: ${clickTargetSize}px; margin-left: -${clickTargetSize}px;`" v-html="nextLabel"></button> </div> </template> <script> /* eslint-disable no-console */ export default { name: "navigation", inject: ["carousel"], props: { clickTargetSize: { type: Number, default: 8 }, nextLabel: { type: String, default: "▶" }, prevLabel: { type: String, default: "◀" } }, computed: { canAdvanceForward() { return this.carousel.canAdvanceForward || false; }, canAdvanceBackward() { return this.carousel.canAdvanceBackward || false; } }, methods: { triggerPageAdvance(direction) { this.$emit("navigationclick", direction); } } }; </script> <style scoped> .carousel-navigation-button { position: absolute; top: 50%; box-sizing: border-box; color: #000; text-decoration: none; appearance: none; border: none; background-color: transparent; padding: 0; cursor: pointer; outline: none; } .carousel-navigation-button:focus { outline: 1px solid lightblue; } .carousel-navigation-next { right: 45px ; font-size: 20px; transform: translateY(-50%) translateX(100%); font-family: "system"; } .carousel-navigation-prev { left: 45px; font-size: 20px; transform: translateY(-50%) translateX(-100%); font-family: "system"; } .carousel-navigation--disabled { opacity: 0.2; cursor: default; } /* Define the "system" font family */ @font-face { font-family: system; font-style: normal; font-weight: 300; src: local(".SFNSText-Light"), local(".HelveticaNeueDeskInterface-Light"), local(".LucidaGrandeUI"), local("Ubuntu Light"), local("Segoe UI Symbol"), local("Roboto-Light"), local("DroidSans"), local("Tahoma"); } </style>
example/App.vue
<template> <div style="background: #eee;"> <Carousel style="width:100%; height: 80vh;"> <Slide> <div class="sample bg-primary"> <h1>slide 1</h1> <p>test test test</p> </div> </Slide> <Slide> <div class="sample bg-warning"> <h1>slide 2</h1> <p>test test test</p> </div> </Slide> <Slide> <div class="sample bg-danger"> <h1>slide 3</h1> <p>test test test</p> </div> </Slide> </Carousel> </div> </template> <script> import { Carousel, Slide } from '../src/index'; export default { components: { Carousel, Slide } } </script> <style> .sample { box-sizing: border-box; margin: 0; padding: 30px 60px; height: 100%; } .bg-primary { background: hsl(171, 100%, 41%) } .bg-warning { background: hsl( 48, 100%, 67%) } .bg-danger { background: hsl(348, 100%, 61%) } h1 { margin: 0; padding: 0; } </style>
動作確認
yarn vue serve example/App.vue
で動作確認をします。
解説
この Carousel では以下の事を行なっています。おそらく世の中に出回っている Carousel と同じ挙動だと思います。
- 画面に Carousel を配備する
- Carousel の内部に Slide を横一列に並べる
- Slide ひとつの横幅は親要素である Carousel と同じサイズにする
- 横一列に並んだ Slide は Carousel に対して Slide の数だけ大きくはみ出す。
- はみ出した部分は非表示にする
- 次のスライドに遷移させた場合はスライドを一つ分左にずらす
ただし、この vue-tiny-carousel
はあくまで初学者向けの教材として利用することを目的としているので細かい調整を行えるように設計していません*1。あらかじめご了承ください。
setSlideCount()
Carousel の中に Slide を複数配備して、その Slide の数をカウントします。この数字を元に offset の数などを計算します。Slide の tagName が vue-component-2-slide
という形式なので、それにマッチしたものを Slide だとカウントします。
setSlideCount() { const slots = this.$slots const tagName = "slide" const isSlide = slot => slot.tag && slot.tag.match(`^vue-component-\\d+-${tagName}$`) !== null this.slideCount = (slots && slots.default && slots.default.filter(isSlide)).length || 0 }
computeCarouselWidth
カルーセルの表示領域を計算します。window がリサイズされた時にも再計算が必要なので関数化しています。
computeCarouselWidth() { let carouselInnerElements = this.$el.getElementsByClassName("carousel-inner"); this.carouselWidth = carouselInnerElements[0].clientWidth || 0; }
lodash.debounce
リサイズのイベントはこまめに発生するので lodash.debounce
を使って関数の実行を抑制します。以下の記事に詳しい解説が載っていたのでこちらも参考にしてみてください。
https://qiita.com/waterada/items/986660d31bc107dbd91c
provide と inject
parent component である Carousel を、child component である Navigation で使いたい、というケースで便利な機構です。こちらも以下の記事に詳しい解説が載っていたのでこちらも参考にしてみてください。
https://qiita.com/kaorun343/items/397b1fa6afe96fa2b30f
まとめ
というわけで世の中には Carousel のライブラリが多数あるにも関わらず、車輪の再開発を行ってみました。再開発といっても同等の機能を求めた訳でなく、あくまで学習目的なので、厳密には車輪の再開発ではないと思いますが。
私はすでに Vue.js や Nuxt.js でやりたい事ができるような状態ではあったのですが、それでもまだ知らなかった便利な機構が存在したので学べた事があってとても有意義な時間となりました。というわけで車輪の一部を再開発すると他の優れたエンジニアの叡智を学ぶ事ができてなかなか良い経験でした。
今回作成したソースコードは以下に置いてあります。実用的かどうかはさておき、学習する分には適度な量のソースコードだと思いますのでよかったらご覧になってください。