【React】react-tinder-cardを使ってtinderみたいなスワイプ機能を実装する
背景
ReactとTypeScriptを使ったアプリでtinderみたいなスワイプ機能を実装しようと調査していたところreact-tinder-cardというライブラリがありましたので実際に使ってみたメモを残します。
インストール
npm install --save 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 と組み合わせて使います
※ライブラリから参照
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)
}
}))
最後に
ライブラリを使っていると簡単に実装ができる反面どのような処理で動いているのかブラックボックスになりがちなのでわからない箇所は都度追って調査する必要があると思いました。
react-tinder-cardの実装記事をあまり見かけなかったのでこの記事がどなたかの参考になりましたら幸いです。
Discussion