あいつの日誌β

働きながら旅しています。

Vue.js で Carousel な component を再開発して色々な学びを得たお話

f:id:okamuuu:20190504160726j:plain

あらすじ

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: "&#9654"
    },
    prevLabel: {
      type: String,
      default: "&#9664"
    }
  },
  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 でやりたい事ができるような状態ではあったのですが、それでもまだ知らなかった便利な機構が存在したので学べた事があってとても有意義な時間となりました。というわけで車輪の一部を再開発すると他の優れたエンジニアの叡智を学ぶ事ができてなかなか良い経験でした。

今回作成したソースコードは以下に置いてあります。実用的かどうかはさておき、学習する分には適度な量のソースコードだと思いますのでよかったらご覧になってください。

https://github.com/okamuuu/vue-tiny-carousel/

*1:アラビア語のような Right to left に未対応だったり細かいデザインの調整をする props を用意していません