上一篇我们聊了绿茵手账的Canvas球场绘制,这篇来聊点更有意思的------拖拽交互。如果你还没体验过绿茵手账,可以去鸿蒙应用市场搜一下**「绿茵手账」**,下载下来拖动球员、画战术路线,体验一下战术板的交互。体验完了再回来看这篇文章,你会更清楚拖拽交互的实现细节。
写在前面
大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。
上一篇我们解决了"怎么画球场"的问题,这篇来解决"怎么让球员可以拖动"的问题。
这个需求在Web端很常见,比如用HTML5的draggable属性、或者用mousedown/mousemove/mouseup事件自己实现。鸿蒙端没有draggable,需要用onTouch事件自己实现拖拽逻辑。不过思路是一样的:记录起始位置,计算偏移量,实时更新位置。
这篇文章聊什么
绿茵手账的拖拽交互,核心要解决的问题是:
- 球员怎么拖动 --- 用触摸事件实现拖拽
- 路线怎么绘制 --- 在球员之间画连线
- 手势怎么识别 --- 区分拖动和点击
第一步:理解拖拽原理
拖拽的核心逻辑:
- 触摸开始(onTouchDown)--- 记录起始位置,标记正在拖动的元素
- 触摸移动(onTouchMove)--- 计算偏移量,更新元素位置
- 触摸结束(onTouchUp)--- 清除拖动状态
关键点:
- 需要判断触摸点是否在某个球员的范围内
- 拖动时要实时重绘Canvas
- 要区分拖动和点击(移动距离小于阈值算点击)
第二步:实现球员拖动
用触摸事件实现球员拖动:
typescript
// ArkTS - 球员拖动实现
@Component
struct TacticBoard {
@State players: Player[] = DEFAULT_FORMATION;
@State draggingPlayer: Player | null = null;
@State dragOffset: { x: number; y: number } = { x: 0, y: 0 };
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(
new RenderingContextSettings(true)
);
handleTouchStart(event: TouchEvent) {
const touch = event.touches[0];
const canvasX = touch.x;
const canvasY = touch.y;
// 找到被触摸的球员
const player = this.findPlayerAt(canvasX, canvasY);
if (player) {
this.draggingPlayer = player;
const playerPos = fieldToCanvas(player.x, player.y);
this.dragOffset = {
x: canvasX - playerPos.x,
y: canvasY - playerPos.y
};
}
}
handleTouchMove(event: TouchEvent) {
if (!this.draggingPlayer) return;
const touch = event.touches[0];
const canvasX = touch.x - this.dragOffset.x;
const canvasY = touch.y - this.dragOffset.y;
// 转换成球场坐标
const fieldPos = canvasToField(canvasX, canvasY);
// 限制在球场范围内
const newX = Math.max(0, Math.min(FIELD_WIDTH, fieldPos.x));
const newY = Math.max(0, Math.min(FIELD_HEIGHT, fieldPos.y));
// 更新球员位置
const index = this.players.findIndex(p => p.id === this.draggingPlayer!.id);
if (index !== -1) {
this.players[index] = { ...this.players[index], x: newX, y: newY };
this.players = [...this.players]; // 触发更新
this.redraw();
}
}
handleTouchEnd(event: TouchEvent) {
this.draggingPlayer = null;
}
private findPlayerAt(canvasX: number, canvasY: number): Player | null {
const hitRadius = 20; // 触摸范围(像素)
for (const player of this.players) {
const pos = fieldToCanvas(player.x, player.y);
const distance = Math.sqrt(
Math.pow(canvasX - pos.x, 2) + Math.pow(canvasY - pos.y, 2)
);
if (distance <= hitRadius) {
return player;
}
}
return null;
}
redraw() {
this.drawField();
this.drawPlayers(this.players);
}
build() {
Canvas(this.context)
.width(CANVAS_WIDTH)
.height(CANVAS_HEIGHT)
.onTouch((event) => {
switch (event.type) {
case TouchType.Down:
this.handleTouchStart(event);
break;
case TouchType.Move:
this.handleTouchMove(event);
break;
case TouchType.Up:
this.handleTouchEnd(event);
break;
}
})
}
}
React对应版本:
jsx
// React - 球员拖动实现
function TacticBoard() {
const [players, setPlayers] = useState(DEFAULT_FORMATION);
const [draggingPlayer, setDraggingPlayer] = useState(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const canvasRef = useRef(null);
const handleTouchStart = (e) => {
const touch = e.touches[0];
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const canvasX = touch.clientX - rect.left;
const canvasY = touch.clientY - rect.top;
const player = findPlayerAt(canvasX, canvasY);
if (player) {
setDraggingPlayer(player);
const playerPos = fieldToCanvas(player.x, player.y);
setDragOffset({
x: canvasX - playerPos.x,
y: canvasY - playerPos.y
});
}
};
const handleTouchMove = (e) => {
if (!draggingPlayer) return;
const touch = e.touches[0];
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const canvasX = touch.clientX - rect.left - dragOffset.x;
const canvasY = touch.clientY - rect.top - dragOffset.y;
const fieldPos = canvasToField(canvasX, canvasY);
const newX = Math.max(0, Math.min(FIELD_WIDTH, fieldPos.x));
const newY = Math.max(0, Math.min(FIELD_HEIGHT, fieldPos.y));
setPlayers(prev => prev.map(p =>
p.id === draggingPlayer.id ? { ...p, x: newX, y: newY } : p
));
};
const handleTouchEnd = () => {
setDraggingPlayer(null);
};
const findPlayerAt = (canvasX, canvasY) => {
const hitRadius = 20;
for (const player of players) {
const pos = fieldToCanvas(player.x, player.y);
const distance = Math.sqrt(
Math.pow(canvasX - pos.x, 2) + Math.pow(canvasY - pos.y, 2)
);
if (distance <= hitRadius) return player;
}
return null;
};
return (
<canvas
ref={canvasRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
width={CANVAS_WIDTH}
height={CANVAS_HEIGHT}
/>
);
}
第三步:实现路线绘制
在两个球员之间画连线,表示传球或跑位路线:
typescript
// ArkTS - 路线绘制实现
@Component
struct TacticBoard {
@State routes: TacticRoute[] = [];
@State drawingRoute: boolean = false;
@State routeFrom: Player | null = null;
@State routeTo: { x: number; y: number } | null = null;
handleDoubleTap(player: Player) {
if (!this.drawingRoute) {
// 开始画路线
this.drawingRoute = true;
this.routeFrom = player;
} else {
// 结束画路线
if (this.routeFrom && player.id !== this.routeFrom.id) {
const newRoute: TacticRoute = {
id: Date.now().toString(),
fromPlayer: this.routeFrom.id,
toPlayer: player.id,
type: 'pass',
waypoints: []
};
this.routes = [...this.routes, newRoute];
}
this.drawingRoute = false;
this.routeFrom = null;
this.routeTo = null;
this.redraw();
}
}
handleTouchMove(event: TouchEvent) {
// ... 拖动逻辑 ...
// 如果正在画路线,更新临时路线终点
if (this.drawingRoute && this.routeFrom) {
const touch = event.touches[0];
this.routeTo = { x: touch.x, y: touch.y };
this.redraw();
this.drawTempRoute();
}
}
drawTempRoute() {
if (!this.routeFrom || !this.routeTo) return;
const ctx = this.context;
const from = fieldToCanvas(this.routeFrom.x, this.routeFrom.y);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(this.routeTo.x, this.routeTo.y);
ctx.stroke();
ctx.setLineDash([]);
}
}
React对应版本:
jsx
// React - 路线绘制实现
function TacticBoard() {
const [routes, setRoutes] = useState([]);
const [drawingRoute, setDrawingRoute] = useState(false);
const [routeFrom, setRouteFrom] = useState(null);
const [routeTo, setRouteTo] = useState(null);
const handleDoubleTap = (player) => {
if (!drawingRoute) {
setDrawingRoute(true);
setRouteFrom(player);
} else {
if (routeFrom && player.id !== routeFrom.id) {
const newRoute = {
id: Date.now().toString(),
fromPlayer: routeFrom.id,
toPlayer: player.id,
type: 'pass',
waypoints: []
};
setRoutes(prev => [...prev, newRoute]);
}
setDrawingRoute(false);
setRouteFrom(null);
setRouteTo(null);
redraw();
}
};
const handleTouchMove = (e) => {
// ... 拖动逻辑 ...
if (drawingRoute && routeFrom) {
const touch = e.touches[0];
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
setRouteTo({
x: touch.clientX - rect.left,
y: touch.clientY - rect.top
});
redraw();
drawTempRoute();
}
};
const drawTempRoute = () => {
if (!routeFrom || !routeTo) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const from = fieldToCanvas(routeFrom.x, routeFrom.y);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(routeTo.x, routeTo.y);
ctx.stroke();
ctx.setLineDash([]);
};
}
第四步:手势识别
区分拖动、点击和双击:
typescript
// ArkTS - 手势识别
@Component
struct TacticBoard {
private lastTapTime: number = 0;
private lastTapPlayer: Player | null = null;
private tapCount: number = 0;
private moveDistance: number = 0;
handleTouchStart(event: TouchEvent) {
const touch = event.touches[0];
const player = this.findPlayerAt(touch.x, touch.y);
if (player) {
// 检测双击
const now = Date.now();
if (this.lastTapPlayer?.id === player.id && now - this.lastTapTime < 300) {
this.tapCount++;
} else {
this.tapCount = 1;
}
this.lastTapTime = now;
this.lastTapPlayer = player;
// 开始拖动
this.draggingPlayer = player;
this.moveDistance = 0;
}
}
handleTouchMove(event: TouchEvent) {
if (!this.draggingPlayer) return;
const touch = event.touches[0];
this.moveDistance += Math.abs(touch.x - this.lastTouchX) + Math.abs(touch.y - this.lastTouchY);
this.lastTouchX = touch.x;
this.lastTouchY = touch.y;
// 如果移动距离超过阈值,认为是拖动
if (this.moveDistance > 10) {
this.isDragging = true;
// 更新球员位置...
}
}
handleTouchEnd(event: TouchEvent) {
if (this.draggingPlayer) {
if (this.tapCount >= 2 && !this.isDragging) {
// 双击:开始/结束画路线
this.handleDoubleTap(this.draggingPlayer);
} else if (!this.isDragging) {
// 单击:选中球员
this.selectedPlayer = this.draggingPlayer;
}
}
this.draggingPlayer = null;
this.isDragging = false;
}
}
React对应版本:
jsx
// React - 手势识别
function TacticBoard() {
const [lastTapTime, setLastTapTime] = useState(0);
const [lastTapPlayer, setLastTapPlayer] = useState(null);
const [tapCount, setTapCount] = useState(0);
const [moveDistance, setMoveDistance] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const handleTouchStart = (e) => {
const touch = e.touches[0];
const player = findPlayerAt(touch.clientX, touch.clientY);
if (player) {
const now = Date.now();
if (lastTapPlayer?.id === player.id && now - lastTapTime < 300) {
setTapCount(prev => prev + 1);
} else {
setTapCount(1);
}
setLastTapTime(now);
setLastTapPlayer(player);
setDraggingPlayer(player);
setMoveDistance(0);
}
};
const handleTouchMove = (e) => {
if (!draggingPlayer) return;
const touch = e.touches[0];
setMoveDistance(prev => {
const newDist = prev + Math.abs(touch.clientX - lastTouchX) + Math.abs(touch.clientY - lastTouchY);
if (newDist > 10) setIsDragging(true);
return newDist;
});
if (isDragging) {
// 更新球员位置...
}
};
const handleTouchEnd = () => {
if (draggingPlayer) {
if (tapCount >= 2 && !isDragging) {
handleDoubleTap(draggingPlayer);
} else if (!isDragging) {
setSelectedPlayer(draggingPlayer);
}
}
setDraggingPlayer(null);
setIsDragging(false);
};
}
第五步:添加路线中间点
支持在路线上添加中间点,让路线变成曲线:
typescript
// ArkTS - 添加路线中间点
@Component
struct TacticBoard {
@State editingRoute: TacticRoute | null = null;
@State addingWaypoint: boolean = false;
handleRouteTap(route: TacticRoute) {
this.editingRoute = route;
this.addingWaypoint = true;
}
handleCanvasTap(event: TouchEvent) {
if (this.addingWaypoint && this.editingRoute) {
const touch = event.touches[0];
const fieldPos = canvasToField(touch.x, touch.y);
// 添加中间点
const updatedRoute = {
...this.editingRoute,
waypoints: [...this.editingRoute.waypoints, { x: fieldPos.x, y: fieldPos.y }]
};
// 更新路线
this.routes = this.routes.map(r =>
r.id === updatedRoute.id ? updatedRoute : r
);
this.editingRoute = updatedRoute;
this.redraw();
}
}
}
React对应版本:
jsx
// React - 添加路线中间点
function TacticBoard() {
const [editingRoute, setEditingRoute] = useState(null);
const [addingWaypoint, setAddingWaypoint] = useState(false);
const handleRouteTap = (route) => {
setEditingRoute(route);
setAddingWaypoint(true);
};
const handleCanvasTap = (e) => {
if (addingWaypoint && editingRoute) {
const touch = e.touches[0];
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const fieldPos = canvasToField(
touch.clientX - rect.left,
touch.clientY - rect.top
);
const updatedRoute = {
...editingRoute,
waypoints: [...editingRoute.waypoints, { x: fieldPos.x, y: fieldPos.y }]
};
setRoutes(prev => prev.map(r =>
r.id === updatedRoute.id ? updatedRoute : r
));
setEditingRoute(updatedRoute);
redraw();
}
};
}
第六步:保存和加载战术
把战术保存下来,方便后续查看和编辑:
typescript
// ArkTS - 保存战术
interface Tactic {
id: string;
name: string;
players: Player[];
routes: TacticRoute[];
createdAt: number;
updatedAt: number;
}
async function saveTactic(tactic: Tactic) {
const pref = await preferences.getPreferences(context, 'lvdongjiepai');
const tactics: Tactic[] = JSON.parse(
await pref.get('tactics', '[]') as string
);
const existingIndex = tactics.findIndex(t => t.id === tactic.id);
if (existingIndex !== -1) {
tactics[existingIndex] = { ...tactic, updatedAt: Date.now() };
} else {
tactics.push({ ...tactic, createdAt: Date.now(), updatedAt: Date.now() });
}
await pref.put('tactics', JSON.stringify(tactics));
await pref.flush();
}
React对应版本:
jsx
// React - 保存战术
function saveTactic(tactic) {
const tactics = JSON.parse(localStorage.getItem('app_lvdongjiepai_tactics') || '[]');
const existingIndex = tactics.findIndex(t => t.id === tactic.id);
if (existingIndex !== -1) {
tactics[existingIndex] = { ...tactic, updatedAt: Date.now() };
} else {
tactics.push({ ...tactic, createdAt: Date.now(), updatedAt: Date.now() });
}
localStorage.setItem('app_lvdongjiepai_tactics', JSON.stringify(tactics));
}
踩坑提醒
-
触摸精度:手机屏幕比较小,球员标记的点击范围要足够大(至少20像素),否则很难点中。
-
拖动流畅性 :拖动时要实时重绘Canvas,可能会卡顿。建议用
requestAnimationFrame优化。 -
手势冲突 :拖动和点击容易冲突,建议用移动距离阈值(如10像素)来区分。
-
路线编辑 :添加中间点后,路线可能会变得很奇怪。建议加一个撤销功能。
-
数据持久化 :战术数据要保存下来,否则退出App就没了。建议用
preferences或localStorage。
总结
这篇文章带你走了一遍拖拽交互的完整流程:
- 球员拖动:用触摸事件实现拖拽
- 路线绘制:在球员之间画连线
- 手势识别:区分拖动、点击和双击
- 中间点添加:支持曲线路线
- 数据保存:把战术保存下来
核心思想就一个:用触摸事件实现交互 。鸿蒙的onTouch事件和Web的mousedown/mousemove/mouseup思路一样,都是记录起始位置、计算偏移量、实时更新位置。
两篇文章下来,绿茵手账的核心功能------战术板绘制和拖拽交互------就讲完了。如果你对足球战术App开发感兴趣,可以去鸿蒙应用市场下载绿茵手账体验一下,看看实际效果。