Framer Motion组件封装实战
组件封装原则
在封装动画组件时,我们需要遵循以下原则:
- 可复用性:组件应该足够通用
- 可配置性:关键动画参数应该可以通过props配置
- 性能优化:避免不必要的重渲染
- 类型安全:提供完善的TypeScript类型定义
基础动画组件封装
1. 动画按钮组件
tsx
const buttonVariants = {
idle: { scale: 1 },
hover: { scale: 1.05 },
tap: { scale: 0.95 },
loading: {
scale: 1,
opacity: 0.7
}
}
export const AnimatedButton = ({
children,
variant = 'primary',
isLoading = false,
scale = 1.05,
...props
}) => {
const customVariants = useMemo(() => ({
...buttonVariants,
hover: { scale }
}), [scale]);
return (
<motion.button
variants={customVariants}
initial="idle"
animate={isLoading ? "loading" : "idle"}
whileHover="hover"
whileTap="tap"
disabled={isLoading}
style={{
padding: '10px 20px',
border: 'none',
borderRadius: '5px',
backgroundColor: variant === 'primary' ? '#1a73e8' : '#f1f3f4',
color: variant === 'primary' ? 'white' : '#3c4043',
cursor: isLoading ? 'wait' : 'pointer'
}}
{...props}
>
<motion.span
animate={isLoading ? { opacity: [1, 0.5, 1] } : { opacity: 1 }}
transition={isLoading ? {
repeat: Infinity,
duration: 1
} : undefined}
>
{children}
</motion.span>
</motion.button>
);
};2. 动画卡片组件
tsx
export const AnimatedCard = ({
children,
onClick,
isSelected,
layoutId,
className,
style
}) => {
return (
<motion.div
layoutId={layoutId}
onClick={onClick}
whileHover={{
scale: 1.02,
boxShadow: '0 5px 15px rgba(0,0,0,0.1)'
}}
animate={{
scale: isSelected ? 1.05 : 1,
borderColor: isSelected ? '#1a73e8' : '#e0e0e0'
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 20
}}
style={{
padding: '20px',
borderRadius: '10px',
border: '1px solid #e0e0e0',
cursor: onClick ? 'pointer' : 'default',
...style
}}
className={className}
>
{children}
</motion.div>
);
};3. 动画列表组件
tsx
export function AnimatedList({
items,
renderItem,
keyExtractor,
staggerDelay = 0.05
}) {
return (
<AnimatePresence>
{items.map((item, index) => (
<motion.div
key={keyExtractor(item)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.3,
delay: index * staggerDelay
}}
>
{renderItem(item, index)}
</motion.div>
))}
</AnimatePresence>
);
}高级动画组件
1. 模态框组件
tsx
export const AnimatedModal = ({
isOpen,
onClose,
children,
width = 500
}) => {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 100
}}
/>
<motion.div
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 20 }}
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width,
maxWidth: '90%',
maxHeight: '90vh',
background: 'white',
borderRadius: '10px',
zIndex: 101,
overflow: 'auto'
}}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
);
};2. 页面转场组件
tsx
export const PageTransition = ({
children,
location
}) => {
return (
<AnimatePresence mode="wait">
<motion.div
key={location}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{
duration: 0.3,
ease: "easeInOut"
}}
>
{children}
</motion.div>
</AnimatePresence>
);
};实战案例:图片画廊组件
tsx
export const ImageGallery = ({
images,
columns = 3,
gap = 20
}) => {
const [selectedImage, setSelectedImage] = useState(null);
return (
<>
<motion.div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap,
padding: gap
}}
>
<AnimatedList
items={images}
keyExtractor={item => item.id}
renderItem={(image) => (
<motion.div
layoutId={`image-${image.id}`}
onClick={() => setSelectedImage(image)}
whileHover={{ scale: 1.05 }}
style={{
position: 'relative',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: '10px',
cursor: 'pointer'
}}
>
<motion.img
src={image.url}
alt={image.title}
style={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
</motion.div>
)}
/>
</motion.div>
<AnimatedModal
isOpen={!!selectedImage}
onClose={() => setSelectedImage(null)}
>
{selectedImage && (
<motion.div layoutId={`image-${selectedImage.id}`}>
<motion.img
src={selectedImage.url}
alt={selectedImage.title}
style={{
width: '100%',
height: 'auto',
borderRadius: '10px'
}}
/>
{selectedImage.title && (
<motion.h3
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
style={{ padding: '20px' }}
>
{selectedImage.title}
</motion.h3>
)}
</motion.div>
)}
</AnimatedModal>
</>
);
};使用示例
tsx
function App() {
const images = [
{ id: '1', url: '/image1.jpg', title: '图片1' },
{ id: '2', url: '/image2.jpg', title: '图片2' },
{ id: '3', url: '/image3.jpg', title: '图片3' },
];
return (
<div>
<AnimatedButton variant="primary">
点击我
</AnimatedButton>
<AnimatedCard isSelected>
这是一个卡片
</AnimatedCard>
<ImageGallery
images={images}
columns={3}
gap={20}
/>
</div>
);
}小结
本文介绍了Framer Motion组件封装的最佳实践:
- 基础动画组件的封装
- 高级动画组件的实现
- 完整的画廊组件案例