Skip to content

手势识别

概述

手势识别是现代交互界面的重要组成部分,特别是在移动设备上。Framer Motion提供了强大的手势识别能力,可以轻松实现拖拽、缩放、旋转等复杂交互。本文将全面讲解Framer Motion的手势系统,包括基础手势、复杂手势组合以及实战应用。

基础手势

拖拽(Drag)

tsx
import { motion } from 'framer-motion';

function BasicDrag() {
  return (
    <motion.div
      drag
      className="draggable-box"
    >
      Drag Me
    </motion.div>
  );
}

单向拖拽

tsx
function DirectionalDrag() {
  return (
    <>
      <motion.div drag="x" className="box">
        Drag Horizontally
      </motion.div>
      
      <motion.div drag="y" className="box">
        Drag Vertically
      </motion.div>
    </>
  );
}

拖拽约束

tsx
function ConstrainedDrag() {
  const constraintsRef = useRef<HTMLDivElement>(null);
  
  return (
    <div ref={constraintsRef} className="drag-container">
      <motion.div
        drag
        dragConstraints={constraintsRef}
        dragElastic={0.1}
        className="draggable"
      >
        Constrained Drag
      </motion.div>
    </div>
  );
}

// 数值约束
function NumericConstraints() {
  return (
    <motion.div
      drag
      dragConstraints={{
        top: -50,
        left: -50,
        right: 50,
        bottom: 50,
      }}
      dragElastic={0.2}
    >
      Limited Range
    </motion.div>
  );
}

拖拽动量

tsx
function DragMomentum() {
  return (
    <motion.div
      drag
      dragMomentum={false}  // 禁用惯性
      className="no-momentum"
    >
      No Momentum
    </motion.div>
  );
}

function CustomMomentum() {
  return (
    <motion.div
      drag
      dragTransition={{
        bounceStiffness: 600,
        bounceDamping: 20,
      }}
    >
      Custom Momentum
    </motion.div>
  );
}

拖拽事件

基础事件

tsx
function DragEvents() {
  const [isDragging, setIsDragging] = useState(false);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  
  return (
    <motion.div
      drag
      onDragStart={() => {
        console.log('Drag started');
        setIsDragging(true);
      }}
      onDrag={(event, info) => {
        console.log('Dragging:', info.point);
        setPosition(info.point);
      }}
      onDragEnd={(event, info) => {
        console.log('Drag ended');
        setIsDragging(false);
      }}
      style={{
        backgroundColor: isDragging ? '#3b82f6' : '#e5e7eb',
      }}
    >
      Drag Me (x: {Math.round(position.x)}, y: {Math.round(position.y)})
    </motion.div>
  );
}

拖拽方向检测

tsx
function DragDirection() {
  const [direction, setDirection] = useState<'left' | 'right' | 'up' | 'down' | null>(null);
  
  return (
    <motion.div
      drag
      onDirectionLock={(axis) => console.log('Locked axis:', axis)}
      onDrag={(event, info) => {
        if (Math.abs(info.offset.x) > Math.abs(info.offset.y)) {
          setDirection(info.offset.x > 0 ? 'right' : 'left');
        } else {
          setDirection(info.offset.y > 0 ? 'down' : 'up');
        }
      }}
    >
      Direction: {direction || 'none'}
    </motion.div>
  );
}

拖拽吸附

tsx
function DragSnap() {
  return (
    <motion.div
      drag
      dragSnapToOrigin
      dragElastic={0.2}
    >
      Snap to Origin
    </motion.div>
  );
}

function CustomSnap() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const snapPoints = [
    { x: 0, y: 0 },
    { x: 100, y: 0 },
    { x: 0, y: 100 },
    { x: 100, y: 100 },
  ];
  
  return (
    <motion.div
      drag
      style={{ x: position.x, y: position.y }}
      onDragEnd={(event, info) => {
        const closest = snapPoints.reduce((prev, curr) => {
          const prevDist = Math.hypot(
            prev.x - info.point.x,
            prev.y - info.point.y
          );
          const currDist = Math.hypot(
            curr.x - info.point.x,
            curr.y - info.point.y
          );
          return currDist < prevDist ? curr : prev;
        });
        
        setPosition(closest);
      }}
    >
      Custom Snap Points
    </motion.div>
  );
}

高级拖拽技巧

拖拽克隆

tsx
function DragClone() {
  const [items, setItems] = useState([1, 2, 3]);
  const [draggedItem, setDraggedItem] = useState<number | null>(null);
  
  return (
    <div className="container">
      <div className="source">
        {items.map((item) => (
          <motion.div
            key={item}
            drag
            dragSnapToOrigin
            onDragStart={() => setDraggedItem(item)}
            onDragEnd={() => setDraggedItem(null)}
          >
            Item {item}
          </motion.div>
        ))}
      </div>
      
      <div className="target">
        {draggedItem && (
          <motion.div
            initial={{ scale: 0 }}
            animate={{ scale: 1 }}
          >
            Cloned Item {draggedItem}
          </motion.div>
        )}
      </div>
    </div>
  );
}

拖拽排序

tsx
import { useMotionValue, useTransform } from 'framer-motion';

function DragReorder() {
  const [items, setItems] = useState(['A', 'B', 'C', 'D']);
  
  const handleReorder = (dragIndex: number, hoverIndex: number) => {
    const newItems = [...items];
    const [draggedItem] = newItems.splice(dragIndex, 1);
    newItems.splice(hoverIndex, 0, draggedItem);
    setItems(newItems);
  };
  
  return (
    <div className="reorderable-list">
      {items.map((item, index) => (
        <DraggableItem
          key={item}
          item={item}
          index={index}
          onReorder={handleReorder}
        />
      ))}
    </div>
  );
}

function DraggableItem({ item, index, onReorder }: any) {
  const y = useMotionValue(0);
  
  return (
    <motion.div
      drag="y"
      style={{ y }}
      dragConstraints={{ top: 0, bottom: 0 }}
      dragElastic={0.1}
      onDrag={(event, info) => {
        const offset = Math.round(info.offset.y / 60);
        if (offset !== 0) {
          onReorder(index, index + offset);
        }
      }}
    >
      {item}
    </motion.div>
  );
}

多指拖拽

tsx
function MultiDrag() {
  const [positions, setPositions] = useState<Record<string, { x: number; y: number }>>({});
  
  const handleDrag = (id: string, info: any) => {
    setPositions(prev => ({
      ...prev,
      [id]: { x: info.point.x, y: info.point.y },
    }));
  };
  
  return (
    <div className="multi-drag-container">
      {['A', 'B', 'C'].map((id) => (
        <motion.div
          key={id}
          drag
          onDrag={(e, info) => handleDrag(id, info)}
          className="draggable"
        >
          {id}
        </motion.div>
      ))}
    </div>
  );
}

缩放和旋转

双指缩放

tsx
import { useGesture } from '@use-gesture/react';

function PinchZoom() {
  const [scale, setScale] = useState(1);
  
  const bind = useGesture({
    onPinch: ({ offset: [d] }) => {
      setScale(1 + d / 200);
    },
  });
  
  return (
    <motion.div
      {...bind()}
      animate={{ scale }}
      className="zoomable"
    >
      Pinch to Zoom
    </motion.div>
  );
}

旋转手势

tsx
function RotateGesture() {
  const [rotation, setRotation] = useState(0);
  
  const bind = useGesture({
    onDrag: ({ movement: [mx], first, memo = rotation }) => {
      if (first) return memo;
      setRotation(memo + mx / 2);
      return memo;
    },
  });
  
  return (
    <motion.div
      {...bind()}
      animate={{ rotate: rotation }}
      className="rotatable"
    >
      Drag to Rotate
    </motion.div>
  );
}

组合手势

tsx
function CombinedGestures() {
  const [{ x, y, scale, rotate }, set] = useState({
    x: 0,
    y: 0,
    scale: 1,
    rotate: 0,
  });
  
  const bind = useGesture({
    onDrag: ({ offset: [x, y] }) => {
      set(prev => ({ ...prev, x, y }));
    },
    onPinch: ({ offset: [d] }) => {
      set(prev => ({ ...prev, scale: 1 + d / 200 }));
    },
    onWheel: ({ delta: [, dy] }) => {
      set(prev => ({ ...prev, rotate: prev.rotate + dy / 10 }));
    },
  });
  
  return (
    <motion.div
      {...bind()}
      style={{ x, y, scale, rotate }}
      className="transformable"
    >
      Multi-Gesture Element
    </motion.div>
  );
}

滑动手势

滑动检测

tsx
function SwipeDetection() {
  const [direction, setDirection] = useState<string>('');
  
  return (
    <motion.div
      drag="x"
      dragConstraints={{ left: 0, right: 0 }}
      onDragEnd={(event, info) => {
        const threshold = 50;
        
        if (info.offset.x > threshold) {
          setDirection('Swiped Right');
        } else if (info.offset.x < -threshold) {
          setDirection('Swiped Left');
        }
      }}
    >
      Swipe Me: {direction}
    </motion.div>
  );
}

滑动删除

tsx
function SwipeToDelete() {
  const [items, setItems] = useState([1, 2, 3, 4, 5]);
  
  const handleSwipe = (id: number, info: any) => {
    if (Math.abs(info.offset.x) > 200) {
      setItems(items.filter(item => item !== id));
    }
  };
  
  return (
    <div className="swipe-list">
      {items.map((item) => (
        <motion.div
          key={item}
          drag="x"
          dragConstraints={{ left: 0, right: 0 }}
          onDragEnd={(e, info) => handleSwipe(item, info)}
          className="swipe-item"
        >
          <span>Item {item}</span>
          <motion.div
            className="delete-indicator"
            animate={{
              opacity: Math.abs(info.offset.x) / 200,
            }}
          >
            Delete
          </motion.div>
        </motion.div>
      ))}
    </div>
  );
}

卡片滑动

tsx
function SwipeCards() {
  const [cards, setCards] = useState([1, 2, 3, 4, 5]);
  const [exitX, setExitX] = useState<number>(0);
  
  const handleDragEnd = (event: any, info: any) => {
    if (Math.abs(info.offset.x) > 100) {
      setExitX(info.offset.x > 0 ? 200 : -200);
      setTimeout(() => {
        setCards(prev => prev.slice(1));
        setExitX(0);
      }, 200);
    }
  };
  
  return (
    <div className="card-stack">
      {cards.map((card, index) => (
        <motion.div
          key={card}
          drag={index === cards.length - 1 ? "x" : false}
          dragConstraints={{ left: 0, right: 0 }}
          onDragEnd={handleDragEnd}
          initial={{ scale: 1 - index * 0.05, y: index * 10 }}
          animate={{
            scale: 1 - index * 0.05,
            y: index * 10,
            x: index === cards.length - 1 ? exitX : 0,
          }}
          className="card"
        >
          Card {card}
        </motion.div>
      ))}
    </div>
  );
}

实战案例

1. 图片查看器

tsx
function ImageViewer({ images }: { images: string[] }) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [scale, setScale] = useState(1);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  
  const bind = useGesture({
    onDrag: ({ offset: [x, y] }) => {
      setPosition({ x, y });
    },
    onPinch: ({ offset: [d] }) => {
      setScale(Math.max(1, 1 + d / 200));
    },
    onWheel: ({ delta: [, dy] }) => {
      setScale(prev => Math.max(1, prev - dy / 1000));
    },
  });
  
  return (
    <div className="image-viewer">
      <motion.img
        {...bind()}
        src={images[currentIndex]}
        style={{
          x: scale > 1 ? position.x : 0,
          y: scale > 1 ? position.y : 0,
          scale,
        }}
        onDoubleClick={() => setScale(scale === 1 ? 2 : 1)}
      />
      
      <div className="controls">
        <button onClick={() => setCurrentIndex(i => Math.max(0, i - 1))}>
          Previous
        </button>
        <button onClick={() => setScale(1)}>Reset</button>
        <button onClick={() => setCurrentIndex(i => Math.min(images.length - 1, i + 1))}>
          Next
        </button>
      </div>
    </div>
  );
}

2. 拖拽看板

tsx
interface Task {
  id: number;
  title: string;
  column: string;
}

function DragBoard() {
  const [tasks, setTasks] = useState<Task[]>([
    { id: 1, title: 'Task 1', column: 'todo' },
    { id: 2, title: 'Task 2', column: 'todo' },
    { id: 3, title: 'Task 3', column: 'doing' },
  ]);
  
  const columns = ['todo', 'doing', 'done'];
  
  const handleDrop = (taskId: number, newColumn: string) => {
    setTasks(tasks.map(task =>
      task.id === taskId ? { ...task, column: newColumn } : task
    ));
  };
  
  return (
    <div className="board">
      {columns.map(column => (
        <div key={column} className="column">
          <h3>{column}</h3>
          
          {tasks
            .filter(task => task.column === column)
            .map(task => (
              <DraggableTask
                key={task.id}
                task={task}
                onDrop={handleDrop}
              />
            ))}
        </div>
      ))}
    </div>
  );
}

function DraggableTask({ task, onDrop }: any) {
  return (
    <motion.div
      drag
      dragSnapToOrigin
      onDragEnd={(event, info) => {
        const dropColumn = getColumnAt(info.point);
        if (dropColumn) {
          onDrop(task.id, dropColumn);
        }
      }}
      whileDrag={{ scale: 1.05, opacity: 0.8 }}
      className="task"
    >
      {task.title}
    </motion.div>
  );
}

3. 滑动菜单

tsx
function SlideMenu() {
  const [isOpen, setIsOpen] = useState(false);
  const dragX = useMotionValue(0);
  
  useEffect(() => {
    const unsubscribe = dragX.onChange((latest) => {
      if (latest > 100) {
        setIsOpen(true);
      } else if (latest < -100) {
        setIsOpen(false);
      }
    });
    
    return () => unsubscribe();
  }, [dragX]);
  
  return (
    <div className="slide-menu-container">
      <motion.div
        className="menu"
        animate={{ x: isOpen ? 0 : -250 }}
        transition={{ type: "spring", stiffness: 300, damping: 30 }}
      >
        <ul>
          <li>Menu Item 1</li>
          <li>Menu Item 2</li>
          <li>Menu Item 3</li>
        </ul>
      </motion.div>
      
      <motion.div
        className="handle"
        drag="x"
        dragConstraints={{ left: 0, right: 200 }}
        dragElastic={0.2}
        style={{ x: dragX }}
        onDragEnd={(e, info) => {
          if (info.offset.x > 100) {
            setIsOpen(true);
          } else if (info.offset.x < -100) {
            setIsOpen(false);
          }
        }}
      >
        <span>☰</span>
      </motion.div>
    </div>
  );
}

4. 3D旋转卡片

tsx
function Rotating3DCard() {
  const [rotateX, setRotateX] = useState(0);
  const [rotateY, setRotateY] = useState(0);
  
  const handleDrag = (event: any, info: any) => {
    setRotateX(-info.offset.y / 5);
    setRotateY(info.offset.x / 5);
  };
  
  return (
    <motion.div
      drag
      dragConstraints={{ top: 0, left: 0, right: 0, bottom: 0 }}
      dragElastic={0.1}
      onDrag={handleDrag}
      onDragEnd={() => {
        setRotateX(0);
        setRotateY(0);
      }}
      animate={{
        rotateX,
        rotateY,
      }}
      style={{
        transformStyle: 'preserve-3d',
        perspective: 1000,
      }}
      className="card-3d"
    >
      <div className="card-front">Front</div>
      <div className="card-back">Back</div>
    </motion.div>
  );
}

5. 弹性拖拽

tsx
function ElasticDrag() {
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  
  const constraintsRef = useRef<HTMLDivElement>(null);
  
  return (
    <div ref={constraintsRef} className="container">
      <motion.div
        drag
        dragConstraints={constraintsRef}
        dragElastic={0.5}
        dragTransition={{
          bounceStiffness: 600,
          bounceDamping: 20,
        }}
        style={{ x, y }}
        whileDrag={{ scale: 1.1 }}
        className="elastic-ball"
      />
    </div>
  );
}

性能优化

使用useMotionValue

tsx
function OptimizedDrag() {
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  
  // 不会触发重渲染
  useEffect(() => {
    const unsubscribeX = x.onChange(latest => {
      console.log('X changed:', latest);
    });
    
    const unsubscribeY = y.onChange(latest => {
      console.log('Y changed:', latest);
    });
    
    return () => {
      unsubscribeX();
      unsubscribeY();
    };
  }, [x, y]);
  
  return (
    <motion.div
      drag
      style={{ x, y }}
    >
      Optimized Drag
    </motion.div>
  );
}

减少重渲染

tsx
// ✅ 使用useMotionValue避免重渲染
function Optimized() {
  const x = useMotionValue(0);
  
  return (
    <motion.div drag="x" style={{ x }}>
      Optimized
    </motion.div>
  );
}

// ❌ 每次拖拽都会触发重渲染
function Unoptimized() {
  const [x, setX] = useState(0);
  
  return (
    <motion.div
      drag="x"
      onDrag={(e, info) => setX(info.offset.x)}
      style={{ x }}
    >
      Unoptimized
    </motion.div>
  );
}

最佳实践总结

性能优化

✅ 使用useMotionValue避免重渲染
✅ 合理设置dragElastic值
✅ 使用dragConstraints限制范围
✅ 避免在onDrag中执行昂贵操作
✅ 使用will-change提示
✅ 测试移动设备性能

用户体验

✅ 提供视觉反馈(whileDrag)
✅ 设置合理的拖拽阻力
✅ 为手势提供明确的提示
✅ 支持键盘操作替代方案
✅ 测试不同屏幕尺寸

可访问性

✅ 提供非手势操作替代方案
✅ 确保触摸目标足够大
✅ 为手势添加ARIA标签
✅ 测试辅助技术兼容性
✅ 支持键盘导航

手势识别是现代交互的核心。通过掌握Framer Motion的手势系统,你可以创建直观自然的用户界面,为用户提供流畅的交互体验。