😺

Vueでスライドを左右に無限ループ

2024/02/25に公開

はじめに

なぜ作ろうと思ったのか

私が技術向上のために制作しているブログの中に積みたいと考えたからです。
ネット上には、無限ループするスライドショーはよく見かけますが、スワイプやボタンで前後に行くものが見当たらなかったので、自分なりに作ってみました。
私の拙いコードでよければ参考にしていただけると幸いです。

どんなもの?

  • 左右に永遠スワイプできる
  • 有限のimageをループする
  • なるべく汎用的なもの

https://github.com/rakuda20211215/loop_slider/tree/main/src/components

使用ツール

Vue3 (composition api <setup>)
gsap

gsapを使うとスクロール後の処理等が簡単に書けるので使いました。

npm install gsap

本文

流れ

  1. slotで受け取ったノードの一番最初が中心になるように並べ替え
  2. 指定した秒間でスライドショー開始
  3. 「最後のノードを最初に持ってくる」を永遠繰り返す
  4. タッチやマウスダウンを検出した段階でインターバル停止
  5. 手動スライド後に画面中央に被っているノードを中心として、入れ替える
  6. インターバル再開
  7. あとは 3 - 6 を繰り返す

Autoでスライドする処理

// transition-group の親ノード
const slides = ref(null)
let count = 0
let customOnMounted = () => {
  count++
  if (count >= props.numItems) {
    slidesClassName = []

    Array.from(slides.value.children).forEach((slide, index) => {
      let uniqueName = `slide-${index}`
      slide.classList.add('slide', uniqueName)
      slidesClassName.push(uniqueName)
    })

    adjustCenterByClassName(slidesClassName[0], 0)
    autoSlide()
  }
}
<transition-group appear @after-enter="customOnMounted">
    <slot></slot>
</transition-group>

マウント時の処理は

onMounted

では、データベースから取得してイメージを表示する場合などに対応しきれないので、

<transition-group>

で、v-for内のノードがレンダリングされるたびに関数を呼び出し、
最後に到達した際に処理を行うようにしています。

もっと、スマートなやり方があれば知りたいです。

ここでの処理の内容は

  • 各ノードに対してユニークなクラス名を割りつける
  • 各クラス名を配列として保存 (ノード自体の位置を入れ替えるため、最初の順番を保存しておくと後々インジケーターを追加する場合などに重宝するから)
  • センターに合わせ、スライドショー開始

そして、センター合わせの要となるのがこいつら

// targetのノードまでスクロールする
let gsapScrollTo = (target, duration, indexInSlides) => {
  gsap.to(slides.value, {
    duration: duration,
    scrollTo: { x: target, offsetX: slideOffsetX(slides.value.children[0]) },
    onComplete: (index) => {
      // スライド完了後の処理

      replaceImage(index)
      // インターバルが停止中の場合は再開
      if (intervalId.value === null) {
        // スライドショーを開始
        autoSlide()
      }

      totalDistanceX.value = 0
    },
    onCompleteParams: [indexInSlides],
  })
}
// 現状の順番内のindexで指定されたノードを中心にする
let replaceImage = (index) => {
  let slideLen = slides.value.children.length
  let centerNum = Math.floor(slideLen / 2)
  if (index !== null) {
    // 与えられたindexがセンターより前か後かで、±1する
    for (let i = index; i != centerNum; i += i < centerNum ? 1 : -1) {
      // 後の場合は、indexのノードが中心に来るまで最初のノードを最後に持ってくる
      // 前の場合は、その逆をする
      if (i > centerNum) {
        slides.value.append(slides.value.children[0])
      } else {
        slides.value.prepend(slides.value.children[slideLen - 1])
      }
    }
  }
  // ノードを入れ替えると、画面内のノードが入れ替わってしまうので、
  // ターゲットのノードを中心に持ってくる
  let targetElement = slides.value.children[centerNum]
  gsap.to(slides.value, { duration: 0, scrollTo: { x: targetElement, offsetX: slideOffsetX(targetElement) } })
}
// 与えられたノードが中心となる場合の画面左端からの距離
let slideOffsetX = (slideElement) => {
  return (window.innerWidth - slideElement.clientWidth) / 2
}

次に、今回の一番の肝であるスライド処理

autoSlide
let autoSlide = () => {
  intervalId.value = setInterval(() => {
    adjustCenter(1)
  }, 6000)
}
let adjustCenter = (place = 0, duration = 0.5) => {
  let windowOffsetCenter = window.innerWidth / 2
  let slideElements = slides.value.children
  let slideLen = slideElements.length
  for (let index = 0; index < slideLen; index++) {
    let element = slideElements[index]
    // スライドの親要素の左端(画面外)から画面中央までの距離
    let slidesOffsetCenter = slides.value.scrollLeft + windowOffsetCenter
    // 左端のノードから順番に「各ノードの中央」までの距離と「画面中央」までの距離を比較し、
    // 最初に画面中央を超えた際に処理を行う
    if (slidesOffsetCenter =< element.offsetLeft + element.clientWidth) {
      let targetClassName = getSlidesClassName(element)
      // 引数のplaceでさらに次のスライドか前のスライドに行くかを決める
      if (place > 0 && index < slideElements.length - 1) {
        targetClassName = getSlidesClassName(slideElements[(index + 1) % slideLen])
        index++
      } else if (place < 0 && index > 0) {
        targetClassName = getSlidesClassName(slideElements[(index - 1) % slideLen])
        index--
      }
      // 指定されたindexまで移動
      gsapScrollTo('.' + targetClassName, duration, index)

      break
    }
  }
}

手動でスライドする処理

template
<div
  ref="slides"
  class="slides"
  v-on="{
    mousedown: () => {
      if (!validScrollTouch) {
        validScrollMouse = true
      }
    },
    mouseup: validScrollMouse ? scrollCancel : undefined,
    pointercancel: validScrollMouse ? scrollCancel : undefined,
    pointerleave: validScrollMouse ? scrollCancel : undefined,
    mousemove: validScrollMouse ? scroll : undefined,
    touchstart: () => {
      if (!validScrollMouse) {
        validScrollTouch = true
      }
    },
    touchend: validScrollTouch ? scrollCancel : undefined,
    touchCancel: validScrollTouch ? scrollCancel : undefined,
    touchmove: validScrollTouch ? scroll : undefined,
    selectstart: (e) => {
      e.preventDefault()
    },
  }"
>
   <transition-group appear @after-enter="customOnMounted">
     <slot></slot>
   </transition-group>
</div>
setup
let scroll = (e) => {
  // 手動スクロールと同時にインターバル停止
  clearInterval(intervalId.value)
  if (intervalId.value) intervalId.value = null
  getSpeed(e)
}
// 指やマウスの動きに合わせてスクロールとスクロールの速度取得
let getSpeed = (e) => {
  let x = 0
  if (e.type == 'mousemove') {
    x = e.clientX
  } else if (e.type == 'touchmove') {
    x = e.changedTouches[0].pageX
  }

  if (mouseX > 0) {
    let diff = mouseX - x
    let elapsedTime = Date.now() - startTime
    // 30msずつスピード計測
    if (elapsedTime > 30) {
      distanceX = 0
      startTime = Date.now()
      elapsedTime = 1
    }
    distanceX += diff
    totalDistanceX.value += diff
    spead = Math.floor((distanceX / elapsedTime) * 100)
    let leftOffset = Math.abs(slides.value.scrollLeft) + diff
    // 縦スクロール禁止
    e.preventDefault()
    // 指またはマウスとおなじ距離移動
    slides.value.scrollTo(leftOffset, 0)
  } else {
    startTime = Date.now()
  }
  mouseX = x
}

let scrollCancel = (e) => {
  goToNextPage.value = false
  // スクロールの移動距離によって、クリックかスクロールか分岐
  if (Math.abs(totalDistanceX.value) > 1) {
    if (Math.abs(spead) > 200) {
      adjustCenter(spead)
    } else {
      adjustCenter()
    }
  } else {
    // タッチ判定で次ページへの移動を許可
    goToNextPage.value = true
  }

  spead = 0
  validScrollMouse.value = false
  validScrollTouch.value = false
  mouseX = -10
}

以上

その他

a-tagやvue-routerを使用する場合は親側で処理する必要があります。

const clickNextPage = (e) => {
  if (!goToNextPage.value) e.preventDefault()
}

さらに、v-modelで次ページへ行くかどうかのbool変数を渡す必要があります。

<ImageSlider :num-items="colors.length" v-model:goToNextPage="goToNextPage">

<a :href="" @click="clickNextPage" draggable="false"></a>

おわりに

無限ループ処理としてはありふれた考えのものですが、実際に作ってみると割と面倒でした。
「もっとこうした方が良いんじゃない?」があれば遠慮なくコメントください。

Discussion