🙆

【React】react-tinder-cardを使ってtinderみたいなスワイプ機能を実装する

2022/07/19に公開

背景

ReactとTypeScriptを使ったアプリでtinderみたいなスワイプ機能を実装しようと調査していたところreact-tinder-cardというライブラリがありましたので実際に使ってみたメモを残します。

インストール

npm install --save react-tinder-card

https://www.npmjs.com/package/react-tinder-card

実装してみた

各関数の役割などはコメントアウトしています。

react-tinder-cardはボタンでのスワイプと手動でのスワイプの両方に対応しており、手動でスワイプする際はswiped > outOfFrameの順番で関数が発火し、ボタンでスワイプする際はswipe > swiped > outOfFrameの順番で関数が発火します。swipedという関数がcurrentIndexを更新する責務を持っています。

import React, { useState, useRef, useMemo } from 'react'
import TinderCard from 'react-tinder-card'
import styled from '@emotion/styled'
import {
  ButtonIconThumbDown,
  ButtonIconThumbUp,
  ButtonIconUndo,
  Progressbar
} from '../../ui'
import { ProductItem } from '../../../utilities/stripeClient'
import { delay } from '../../../utilities'

export const DiagnoseTinderSwipe: React.VFC<TinderSwipeProps> = ({
  db,
  changePendingDiagnose
}) => {
  const [lastDirection, setLastDirection] = useState<string>()
  const [currentIndex, setCurrentIndex] = useState<number>(db.length - 1)
  const [percent, setPercent] = useState<number>(0)

  const currentIndexRef = useRef(currentIndex)
  /**
   * dbのlengthだけRefを生成する
   * TinderSwipeを通すことでswipeメソッドとrestoreCardメソッドを付与する(useImperativeHandle)
   */
  const childRefs = useMemo<any>(
    () =>
      Array(db.length)
        .fill(0)
        .map((i) => React.createRef()),
    [db.length]
  )
  /**
   * プログレスバーの進捗率を計算・表示する
   */
  const progressbarCalclation = (val: number) => {
    const result = 1 - (val + 1) / db.length
    setPercent(result)
  }
  /**
   * state(currentIndex)を更新し連動している
   * useRef(currentIndexRef)も更新する
   */
  const updateCurrentIndex = async (val: number) => {
    setCurrentIndex(val)
    currentIndexRef.current = val 
    progressbarCalclation(val)
    if (currentIndexRef.current === -1) {
      await delay(300) // NOTE:progressbarのアニメーションを待つ
      changePendingDiagnose()
    }
  }
  /**
   * goback可能かを判定する
   * DBが5の場合3の時はgobackできない
   * 初手gobackを不可にするために設置している
   */
  const canGoBack = currentIndex < db.length - 1
  /**
   * スワイプ可能かを判定する
   * DBが5の場合3,2,1,0,-1と減っていく
   */
  const canSwipe = currentIndex >= 0
  /**
   * ボタンを押下してスワイプした時に発火する
   * currentIndexを+1する
   */
  const goBack = async () => {
    if (!canGoBack) return
    const newIndex = currentIndex + 1
    updateCurrentIndex(newIndex)
    await childRefs[newIndex].current.restoreCard()
  }
  /**
   * ボタンを押下してスワイプした時に発火する
   * ライブラリのonSwipeメソッドを叩く=ローカルのswipeメソッドを叩く
   */
  const swipe = async (direction: string) => {
    if (canSwipe && currentIndex < db.length) {
      await childRefs[currentIndex].current.swipe(direction)
    }
  }
  /**
   * 1,手動でのスワイプした時に発火する
   * 2,ボタンを押下してスワイプした時に発火する(条件2の時swipe関数も発火する)
   * currentIndexを-1減らす
   */
  const swiped = (direction: string, index: number) => {
    setLastDirection(direction)
    updateCurrentIndex(index - 1)
  }
  /**
   * 1,手動でのスワイプした時に発火する
   * 2,ボタンを押下してスワイプした時に発火する(条件2の時swipe関数も発火する)
   */
  const outOfFrame = (index: number) => {
    currentIndexRef.current >= index && childRefs[index].current.restoreCard()
  }

  return (
    <CommonWrapper>
      <TinderSwipeContainer>
        <Progressbar width={100} percent={percent} />
        <CardContainer>
          {db.map((character, index) => (
            <CustomTinderCard
              ref={childRefs[index]}
              key={character.product.name}
              onSwipe={(dir) => swiped(dir, index)}
              onCardLeftScreen={() => outOfFrame(index)}
            >
              <Card
                style={{
                  backgroundImage: `url(${character.product.images[0]})`
                }}
              >
                <Title>{character.product.name}</Title>
              </Card>
            </CustomTinderCard>
          ))}
        </CardContainer>
        <ButtonWrapper>
          <ButtonIconThumbDown size="large" onClick={() => swipe('left')} />
          <ButtonIconUndo size="large" onClick={goBack} />
          <ButtonIconThumbUp size="large" onClick={() => swipe('right')} />
        </ButtonWrapper>
      </TinderSwipeContainer>
    </CommonWrapper>
  )
}

実装で躓いたところ

ライブラリのデモコードから拝借してきたswipe関数とoutOfFrame関数に対して「なぜRefオブジェクトから用意した覚えのないメソッドを叩けているのだろう」と疑問に思いました。

const swipe = async (direction: string) => {
    if (canSwipe && currentIndex < db.length) {
      await childRefs[currentIndex].current.swipe(direction)
    }
  }
  
  const outOfFrame = (index: number) => {
    currentIndexRef.current >= index && childRefs[index].current.restoreCard()
  }

これはuseImperativeHandleというhooksを使用してRefオブジェクトにメソッドを付与する(カスタマイズする)ことで上記の実装を実現しているそうです。しかし公式ドキュメントにも記載がある通りあまり推奨されていない模様です。

useImperativeHandle は ref が使われた時に親コンポーネントに渡されるインスタンス値をカスタマイズするのに使います。いつもの話ですが、ref を使った手続き的なコードはほとんどの場合に避けるべきです。useImperativeHandle は forwardRef と組み合わせて使います

https://ja.reactjs.org/docs/hooks-reference.html#useimperativehandle

※ライブラリから参照

React.useImperativeHandle(ref, () => ({
    async swipe (dir = 'right') {
      if (onSwipe) onSwipe(dir)
      const power = 1000
      const disturbance = (Math.random() - 0.5) * 100
      if (dir === 'right') {
        await animateOut(element.current, { x: power, y: disturbance }, true)
      } else if (dir === 'left') {
        await animateOut(element.current, { x: -power, y: disturbance }, true)
      } else if (dir === 'up') {
        await animateOut(element.current, { x: disturbance, y: power }, true)
      } else if (dir === 'down') {
        await animateOut(element.current, { x: disturbance, y: -power }, true)
      }
      element.current.style.display = 'none'
      if (onCardLeftScreen) onCardLeftScreen(dir)
    },
    async restoreCard () {
      element.current.style.display = 'block'
      await animateBack(element.current)
    }
  }))

https://github.com/3DJakob/react-tinder-card-demo/blob/master/src/react-tinder-card/index.js

最後に

ライブラリを使っていると簡単に実装ができる反面どのような処理で動いているのかブラックボックスになりがちなのでわからない箇所は都度追って調査する必要があると思いました。

react-tinder-cardの実装記事をあまり見かけなかったのでこの記事がどなたかの参考になりましたら幸いです。

参考記事

Discussion