React宝可梦卡牌翻转优化:实现180°时隐藏图片需求
宝可梦卡牌翻转动画优化方案
问题背景
我开发了一个展示宝可梦卡牌的小型React应用,Card组件通过card-front和card-back两个className对应的div实现卡牌正反面。点击卡牌时会执行从0%→50% rotateY(180deg)→100% rotateY(360deg)的旋转动画,但当前图片全程可见。期望实现以下序列:
- 点击卡牌后从0°翻至180°
- 180°翻转状态下仅显示纯色背景,无图片
- 360°(动画结束)时显示新图片
目前点击后图片直接切换,翻转过程中图片始终显示。曾尝试用setTimeout包裹if (card.clicked)…逻辑,延迟时长设为动画总时长(0.5s)的一半(0.25s),但该方案较为粗糙,还导致页面中部分卡牌重复翻转,希望在React中流畅实现该序列逻辑。
核心思路
利用CSS动画关键帧控制图片显示状态,结合React状态管理同步动画与图片切换,替代粗糙的setTimeout方案:
- 为卡牌正面图片添加同步动画,在翻转到180°时隐藏,360°时恢复显示
- 限制动画过程中的重复点击,避免异常翻转
- 动画结束后再执行洗牌和状态更新,确保360°翻转完成后才展示新图片
代码修改步骤
1. 优化CSS动画与卡牌布局
修改原有动画,新增图片显隐动画,并补充3D透视效果增强真实感:
/* Card */ .card { display: flex; justify-content: center; align-items: center; background-color: var(--poke-brown); border-radius: 8px; padding: 0.5em; margin: 0 auto; width: 150px; height: 150px; /* 固定高度保证翻转对齐 */ box-shadow: 0 0 2em rgba(0, 0, 0, 0.25); cursor: pointer; } .card:hover { filter: drop-shadow(0 0 2em var(--light-bg)); } .card-inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; /* 开启3D透视 */ } .card-front, .card-back { position: absolute; width: 100%; height: 100%; backface-visibility: hidden; /* 隐藏背面元素 */ display: flex; justify-content: center; align-items: center; border-radius: 8px; } .card-back { background-color: var(--poke-brown); /* 和卡牌背景一致的纯色 */ transform: rotateY(180deg); } .card-img { max-width: 125px; height: 125px; } @keyframes shuffle { 0% { transform: rotateY(0deg); } 50% { transform: rotateY(180deg); } 100% { transform: rotateY(360deg); } } @keyframes hideImage { 0%, 100% { opacity: 1; visibility: visible; } 50% { opacity: 0; visibility: hidden; } } .card-shuffle-animation { animation: shuffle 0.5s ease forwards; } .card-shuffle-animation .card-img { animation: hideImage 0.5s ease forwards; }
2. 调整GamePage状态逻辑
添加动画状态控制,等待动画结束后再执行洗牌和状态更新:
import { useState, useEffect, useRef } from 'react'; import { Scoreboard } from './Scoreboard'; import { Card } from './Card'; function shuffleArray(array) { let newArray = [...array]; let currentIndex = newArray.length, randomIndex; while (currentIndex !== 0) { randomIndex = Math.floor(Math.random() * currentIndex); currentIndex--; [newArray[currentIndex], newArray[randomIndex]] = [newArray[randomIndex], newArray[currentIndex]]; } return newArray; } function GamePage() { const [cards, setCards] = useState([]); const [currentScore, setCurrentScore] = useState(0); const [highScore, setHighScore] = useState(0); const [isAnimating, setIsAnimating] = useState(false); const activeAnimations = useRef({}); useEffect(() => { fetch('https://pokeapi.co/api/v2/pokemon?limit=12') .then(response => response.json()) .then(data => { const cards = data.results.map((pokemon, index) => ({ id: index, image: `https://github.com/sashafirsov/pokeapi-sprites/blob/master/sprites/pokemon/other/dream-world/${index + 1}.svg?raw=true`, clicked: false })); setCards(cards); }); }, []); const handleAnimationEnd = (id) => { delete activeAnimations.current[id]; if (Object.keys(activeAnimations.current).length === 0) { setIsAnimating(false); } }; function handleCardClick(id) { if (isAnimating) return; setIsAnimating(true); activeAnimations.current[id] = true; const card = cards.find(card => card.id === id); const shuffledCards = shuffleArray([...cards]); // 等待动画完成后更新状态 setTimeout(() => { if (card.clicked) { setCards(shuffledCards.map(card => ({ ...card, clicked: false }))); setCurrentScore(0); } else { const newScore = currentScore + 1; setCards(shuffledCards.map(card => card.id === id ? { ...card, clicked: true } : card )); setCurrentScore(newScore); if (newScore > highScore) setHighScore(newScore); } setIsAnimating(false); }, 500); } return ( <> <h1>Let's Play</h1> <Scoreboard currentScore={currentScore} highScore={highScore} /> <div className='game-board'> {cards.map(card => ( <Card key={card.id} image={card.image} onClick={() => handleCardClick(card.id)} className={activeAnimations.current[card.id] ? 'card-shuffle-animation' : ''} onAnimationEnd={() => handleAnimationEnd(card.id)} /> ))} </div> </> ); } export { GamePage };
3. 更新Card组件支持动画结束事件
import PropTypes from 'prop-types'; function Card({ image, onClick, className, onAnimationEnd }) { return ( <div className={`card ${className}`} onClick={onClick} onAnimationEnd={onAnimationEnd}> <div className='card-inner'> <div className='card-front'> <img src={image} className='card-img' alt='Pokemon' /> </div> <div className='card-back'></div> </div> </div> ); } Card.propTypes = { image: PropTypes.string.isRequired, onClick: PropTypes.func.isRequired, className: PropTypes.string, onAnimationEnd: PropTypes.func.isRequired, }; Card.defaultProps = { className: '' }; export { Card };
方案说明
- 通过
hideImage动画在翻转到180°时隐藏图片,实现中间阶段仅显示纯色背景的需求 - 使用
isAnimating状态防止动画过程中重复点击,解决卡牌重复翻转问题 - 动画结束后再执行洗牌和状态更新,确保360°翻转完成后才展示新图片
- 移除原有的
animationTrigger作为key的逻辑,改用动画状态控制className,减少不必要的组件卸载重渲染
内容的提问来源于stack exchange,提问作者JackJack
相关产品推荐
相关产品推荐

