Skip to content

Framer Motion组件封装实战 ​

组件封装原则 ​

在封装动画组件时,我们需要遵循以下原则:

  1. 可复用性:组件应该足够通用
  2. 可配置性:关键动画参数应该可以通过props配置
  3. 性能优化:避免不必要的重渲染
  4. 类型安全:提供完善的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组件封装的最佳实践:

  • 基础动画组件的封装
  • 高级动画组件的实现
  • 完整的画廊组件案例

相关资源 ​