Appearance
手势识别
概述
手势识别是现代交互界面的重要组成部分,特别是在移动设备上。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的手势系统,你可以创建直观自然的用户界面,为用户提供流畅的交互体验。