鸿蒙APP开发:足球战术App怎么做拖拽交互?球员拖动与路线绘制

上一篇我们聊了绿茵手账的Canvas球场绘制,这篇来聊点更有意思的------拖拽交互。如果你还没体验过绿茵手账,可以去鸿蒙应用市场搜一下**「绿茵手账」**,下载下来拖动球员、画战术路线,体验一下战术板的交互。体验完了再回来看这篇文章,你会更清楚拖拽交互的实现细节。


写在前面

大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。

上一篇我们解决了"怎么画球场"的问题,这篇来解决"怎么让球员可以拖动"的问题。

这个需求在Web端很常见,比如用HTML5的draggable属性、或者用mousedown/mousemove/mouseup事件自己实现。鸿蒙端没有draggable,需要用onTouch事件自己实现拖拽逻辑。不过思路是一样的:记录起始位置,计算偏移量,实时更新位置。


这篇文章聊什么

绿茵手账的拖拽交互,核心要解决的问题是:

  1. 球员怎么拖动 --- 用触摸事件实现拖拽
  2. 路线怎么绘制 --- 在球员之间画连线
  3. 手势怎么识别 --- 区分拖动和点击

第一步:理解拖拽原理

拖拽的核心逻辑:

  1. 触摸开始(onTouchDown)--- 记录起始位置,标记正在拖动的元素
  2. 触摸移动(onTouchMove)--- 计算偏移量,更新元素位置
  3. 触摸结束(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));
}

踩坑提醒

  1. 触摸精度:手机屏幕比较小,球员标记的点击范围要足够大(至少20像素),否则很难点中。

  2. 拖动流畅性 :拖动时要实时重绘Canvas,可能会卡顿。建议用requestAnimationFrame优化。

  3. 手势冲突 :拖动和点击容易冲突,建议用移动距离阈值(如10像素)来区分。

  4. 路线编辑 :添加中间点后,路线可能会变得很奇怪。建议加一个撤销功能。

  5. 数据持久化 :战术数据要保存下来,否则退出App就没了。建议用preferenceslocalStorage


总结

这篇文章带你走了一遍拖拽交互的完整流程:

  1. 球员拖动:用触摸事件实现拖拽
  2. 路线绘制:在球员之间画连线
  3. 手势识别:区分拖动、点击和双击
  4. 中间点添加:支持曲线路线
  5. 数据保存:把战术保存下来

核心思想就一个:用触摸事件实现交互 。鸿蒙的onTouch事件和Web的mousedown/mousemove/mouseup思路一样,都是记录起始位置、计算偏移量、实时更新位置。

两篇文章下来,绿茵手账的核心功能------战术板绘制和拖拽交互------就讲完了。如果你对足球战术App开发感兴趣,可以去鸿蒙应用市场下载绿茵手账体验一下,看看实际效果。

相关推荐
陈_杨1 小时前
鸿蒙APP开发:如果你想在鸿蒙App里做属性动画,@ohos.animator怎么用
前端
陈_杨1 小时前
鸿蒙APP开发:篮球App怎么画球场?鸿蒙Canvas绘图实战
前端
colofullove1 小时前
前端工程搭建与用户访问流程设计
前端
广州华水科技2 小时前
如何利用单北斗GNSS系统实现大坝的变形监测?
前端
代码小库2 小时前
【2026前端最新面试题——day10】JavaScript 高频面试题
开发语言·前端·javascript
zzz_23682 小时前
【Spring】面试突击系列(三):Spring Web MVC 深度解析
前端·spring·面试
colofullove2 小时前
小说上传中心与异步处理进度展示设计
前端
Marst Code3 小时前
⚙️ 2026 年推荐技术方案
前端
qq_366086223 小时前
测试接口传参数时,放在Header和Body中后台接收参数的区别
java·开发语言·前端