HarmonyOS鸿蒙Next中实现高性能“跟随光标”的悬停交互效果

HarmonyOS鸿蒙Next中实现高性能“跟随光标”的悬停交互效果

如何实现“跟随光标”的这种悬停交互效果?

3 回复

实现效果

实现效果

使用场景

在平板或智慧屏上,通过手指滑动即可精准瞄准图标,避免手指遮挡视线。或者为运动障碍用户提供“慢速光标模式”,帮助他们更准确地操作界面元素。

实现思路

第一步:创建一个覆盖全屏的透明容器,利用 PanGesture 监听手指的移动坐标。

第二步:使用 overlay 属性在手指位置绘制一个“瞄准镜”光圈。

几何碰撞检测(核心难点):ArkUI 中父组件无法直接获取子组件渲染后的动态绝对坐标(除非通过繁琐的 componentUtils 异步查询)。

高性能解法:假设网格布局规则固定(如 Grid 3列),通过数学公式 (index % columns, Math.floor(index / columns)) 反推每个子组件所在的坐标区域。

完整实现代码

// 定义子组件的数据模型
class GridItemModel {
  id: string;
  title: string;
  icon: Resource;
  color: string;

  constructor(id: string, title: string, icon: Resource, color: string) {
    this.id = id;
    this.title = title;
    this.icon = icon;
    this.color = color;
  }
}

@Entry
@Component
struct HoverCursorPage {
  @State private gridData: GridItemModel[] = [];
  // 光标当前的坐标
  @State private cursorX: number = -100;
  @State private cursorY: number = -100;
  // 当前光标悬停到的组件ID
  @State private activeItemId: string = "";

  // 布局常量配置
  private readonly CURSOR_SIZE: number = 40;
  private readonly ITEM_SIZE: number = 90;
  private readonly GAP: number = 15;
  private readonly COLUMNS: number = 3;

  aboutToAppear(): void {
    // 初始化模拟数据
    const icons = [
      $r('app.media.startIcon'), $r('app.media.startIcon'),
      $r('app.media.startIcon'), $r('app.media.startIcon'),
      $r('app.media.startIcon'), $r('app.media.startIcon'),
      $r('app.media.startIcon'), $r('app.media.startIcon'),
      $r('app.media.startIcon')
    ];
    const colors = ['#FF5252', '#448AFF', '#69F0AE', '#E040FB', '#FFAB40'];

    for (let i = 0; i < 9; i++) {
      this.gridData.push(new GridItemModel(
        `item_${i}`,
        `应用 ${i + 1}`,
        icons[i % icons.length],
        colors[i % colors.length]
      ));
    }
  }

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      // 1. 背景层:内容展示区
      Column() {
        Text("滑动手指移动光标,悬停触发交互")
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 50, bottom: 20 })
          .fontColor('#333')

        Grid() {
          ForEach(this.gridData, (item: GridItemModel) => {
            GridItem() {
              this.ItemBuilder(item)
            }
          }, (item: GridItemModel) => item.id)
        }
        .columnsTemplate(`1fr 1fr 1fr`)
        .rowsGap(this.GAP)
        .columnsGap(this.GAP)
        .width('90%')
        .height('60%')
        .padding(10)
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#F1F3F5')

      // 2. 交互层:手势拦截与光标显示
      Column()
        .width('100%')
        .height('100%')
        .hitTestBehavior(HitTestMode.Transparent)
        .gesture(
          PanGesture({ direction: PanDirection.All })
            .onActionStart((event: GestureEvent) => {
              this.updateCursorAndHover(event.fingerList[0].localX, event.fingerList[0].localY);
            })
            .onActionUpdate((event: GestureEvent) => {
              this.updateCursorAndHover(event.fingerList[0].localX, event.fingerList[0].localY);
            })
            .onActionEnd(() => {
              // 手指离开,光标隐藏,状态重置
              this.cursorX = -1000;
              this.cursorY = -1000;
              this.activeItemId = "";
            })
        )
        .overlay(
          // 使用 @Builder 封装 overlay 内容
          this.CursorOverlayBuilder()
        )
    }
  }

  /**
   * 核心逻辑:更新光标位置并计算悬停目标
   * 使用数学反推代替组件坐标查询,保证高性能
   */
  private updateCursorAndHover(x: number, y: number) {
    this.cursorX = x;
    this.cursorY = y;

    // 假设 Grid 的起始 Y 坐标约为 120 (顶部文字 + margin)
    const gridStartY = 110;
    const itemTotalSize = this.ITEM_SIZE + this.GAP;

    if (y < gridStartY || y > gridStartY + 3 * itemTotalSize || x < 20 || x > (vp2px(100) - 20)) { // 简单边界检查
      // 仅重置状态,不做复杂计算
      this.activeItemId = "";
      return;
    }

    const relativeY = y - gridStartY;
    const colIndex = Math.floor(x / itemTotalSize);
    const rowIndex = Math.floor(relativeY / itemTotalSize);

    // 检查是否点击到了 GAP 缝隙中
    const modX = x % itemTotalSize;
    const modY = relativeY % itemTotalSize;
    const inGap = modX > this.ITEM_SIZE || modY > this.ITEM_SIZE;

    if (colIndex >= 0 && colIndex < this.COLUMNS && rowIndex >= 0 && !inGap) {
      const index = rowIndex * this.COLUMNS + colIndex;
      if (index >= 0 && index < this.gridData.length) {
        const targetId = this.gridData[index].id;
        if (this.activeItemId !== targetId) {
          this.activeItemId = targetId;
        }
      }
    } else {
      this.activeItemId = "";
    }
  }

  /**
   * 自定义 Overlay 构建器
   * 解决 CircleAttribute 类型错误问题
   */
  @Builder
  CursorOverlayBuilder() {
    // 仅当光标坐标有效时渲染
    if (this.cursorX > -100) {
      Circle()
        .width(this.CURSOR_SIZE)
        .height(this.CURSOR_SIZE)
        .fill(Color.Transparent)
        .stroke(this.activeItemId.length > 0 ? '#0A59F7' : '#999') // 悬停变色
        .strokeWidth(3)
        .position({
          x: this.cursorX - this.CURSOR_SIZE / 2,
          y: this.cursorY - this.CURSOR_SIZE / 2
        })
        .shadow({ radius: 8, color: '#40000000' })
        .animation({ duration: 50, curve: Curve.EaseOut })
    }
  }

  /**
   * 子组件构建器
   * 根据 activeItemId 动态改变样式
   */
  @Builder
  ItemBuilder(item: GridItemModel) {
    Column() {
      Image(item.icon)
        .width(40)
        .height(40)
        .objectFit(ImageFit.Contain)
        .margin({ bottom: 5 })

      Text(item.title)
        .fontSize(14)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Medium)
    }
    .width('100%')
    .height('100%')
    .backgroundColor(item.color)
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
    // 根据 ID 匹配触发样式变化
    .scale(this.activeItemId === item.id ? { x: 1.1, y: 1.1 } : { x: 1.0, y: 1.0 })
    .shadow(this.activeItemId === item.id ?
      { radius: 15, color: item.color, offsetX: 0, offsetY: 5 } :
      { radius: 0 })
    .animation({ duration: 200, curve: Curve.FastOutSlowIn })
    .renderGroup(true)
  }
}

更多关于HarmonyOS鸿蒙Next中实现高性能“跟随光标”的悬停交互效果的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


在HarmonyOS Next中,实现高性能“跟随光标”悬停交互效果,主要利用ArkUI的触摸事件处理能力。通过onHover事件监听组件悬停状态,结合@State装饰器动态更新组件位置或样式。使用Canvas或自定义组件绘制跟随效果,通过translateposition属性实现平滑移动。避免频繁UI更新,可采用@Reusable装饰器优化组件复用,或使用LazyForEach减少渲染开销。

在HarmonyOS Next中实现高性能的“跟随光标”悬停交互效果,核心在于利用ArkUI的手势事件处理动画能力,并注意性能优化。

1. 核心实现步骤:

  • 监听手势事件: 在需要跟随的组件上,使用GestureGroupPanGesture来监听手指在屏幕上的移动事件(onActionUpdate)。这是获取光标(手指)实时位置的关键。
  • 更新目标位置: 在手势事件的回调函数中,获取到手指的坐标(offsetX, offsetY),并动态更新跟随组件(例如一个圆形、图标或自定义组件)的positiontranslate属性。
  • 应用平滑动画: 直接更新位置会导致移动生硬。应使用animateTo或组件自带的动画能力(如.animation属性修饰器),为目标位置的变化添加平滑的过渡动画。这是实现“跟随”而非“跳跃”感的关键。

2. 关键代码结构示例(ArkTS):

@Entry
@Component
struct FollowCursorExample {
  @State targetX: number = 0
  @State targetY: number = 0

  build() {
    Column() {
      // 跟随的光标元素,例如一个圆
      Circle({ width: 50, height: 50 })
        .fill(Color.Blue)
        .position({ x: this.targetX, y: this.targetY })
        .animation({ duration: 100, curve: Curve.EaseOut }) // 添加平滑动画

      // 可交互区域,监听手势
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor(Color.Transparent)
        .gesture(
          PanGesture()
            .onActionUpdate((event: GestureEvent) => {
              // 更新目标位置,动画会自动平滑过渡
              animateTo({
                duration: 50, // 更短的动画时长使跟随更灵敏
                curve: Curve.Friction
              }, () => {
                this.targetX = event.offsetX - 25 // 减去半径以居中
                this.targetY = event.offsetY - 25
              })
            })
        )
    }
    .width('100%')
    .height('100%')
  }
}

3. 性能优化要点:

  • 减少布局嵌套与重绘: 跟随组件应尽量简单,避免在频繁的位置更新中触发复杂的布局计算。使用position进行绝对定位通常比影响布局流的方案更高效。
  • 动画曲线与时长: 选择合适的动画曲线(如Curve.EaseOut, Curve.Friction)和较短的动画时长(如50-150ms),可以在视觉流畅度和跟手性之间取得最佳平衡。
  • 避免频繁状态更新: 如果不需要每一帧的精确坐标,可以考虑对手势事件进行节流(throttle),但通常ArkUI的动画系统已能很好处理。
  • 使用硬件加速: 确保跟随组件的渲染层能够启用GPU加速。简单的图形、不透明的颜色以及使用系统提供的动画API通常都能满足。

总结: HarmonyOS Next通过ArkUI提供了声明式且高性能的动画与手势API组合。实现跟随光标效果的关键是将手势事件坐标的实时获取与组件属性的平滑动画绑定,并遵循性能最佳实践,即可获得流畅的交互体验。

回到顶部