光标样式:自定义鼠标光标形状(Cursor)(75)

在鸿蒙(HarmonyOS)PC 端和平板端应用中,自定义鼠标光标(Cursor)是提供精准操作反馈和打造专业级桌面体验的重要一环。开发者可以通过系统内置样式(setCursor)和自定义图像资源(setCustomCursor)两种方式来控制光标形状。

以下是实现光标样式自定义的核心策略与代码示例:

一、 使用系统内置光标样式(setCursor)

对于常见的交互场景,鸿蒙提供了丰富的内置光标样式(如手型、十字准星、文本输入、方向箭头等)。官方推荐通过 getUIContext().getCursorController() 获取实例来控制光标,以避免 UI 上下文不明确的问题。

核心代码示例:

javascript 复制代码
import { pointer } from '@kit.InputKit';

Row()
  .width(200)
  .height(200)
  .backgroundColor(Color.Green)
  .onHover((flag: boolean) => {
    if (flag) {
      // 鼠标悬停时,更改为系统内置的手型光标
      this.getUIContext().getCursorController().setCursor(pointer.PointerStyle.HAND);
    } else {
      // 鼠标离开时,恢复为默认的箭头光标
      this.getUIContext().getCursorController().restoreDefault();
    }
  })

二、 自定义图像光标(setCustomCursor)

当系统内置样式无法满足需求时(例如:设计软件中的画笔、取色器、缩放手柄等),可以使用 pointer.setCustomCursor 加载自定义的图像资源。此功能从 API version 15 开始支持。

核心代码示例:

javascript 复制代码
import { image } from '@kit.ImageKit';
import { pointer } from '@kit.InputKit';
import { window } from '@kit.ArkUI';

// 1. 加载自定义光标资源(如 SVG 或 PNG)
getContext().resourceManager.getMediaContent($r("app.media.custom_cursor")).then((buffer) => {
  const svgBuffer: ArrayBuffer = buffer.slice(0);
  let imgSource: image.ImageSource = image.createImageSource(svgBuffer);
  
  // 2. 设置光标的解码尺寸(最大限制为 256 x 256px)
  let decodingOptions: image.DecodingOptions = { desiredSize: { width: 32, height: 32 } };
  
  imgSource.createPixelMap(decodingOptions).then((pixelMap) => {
    window.getLastWindow(getContext(), (error, win) => {
      if (error) return;
      let windowId = win.getWindowProperties().id;
      
      // 3. 配置自定义光标(设置焦点坐标,即点击触发的精准位置)
      let customCursor: pointer.CustomCursor = {
        pixelMap: pixelMap,
        focusX: 0, // 焦点水平坐标
        focusY: 0  // 焦点垂直坐标
      };
      
      let config: pointer.CursorConfig = {
        followSystem: false // 是否跟随系统设置调整大小
      };
      
      // 4. 应用自定义光标
      pointer.setCustomCursor(windowId, customCursor, config).then(() => {
        console.log('自定义光标设置成功');
      });
    });
  });
});

三、 全局光标的显示与隐藏(setPointerVisible)

在某些沉浸式场景下(如全屏视频播放、3D 游戏或演示模式),需要完全隐藏鼠标光标。可以通过 setPointerVisible 接口进行全局控制。

核心代码示例:

javascript 复制代码
import { pointer } from '@kit.InputKit';

// 进入全屏播放时隐藏光标
pointer.setPointerVisible(false, (error) => {
  if (error) {
    console.error(`隐藏光标失败: ${JSON.stringify(error)}`);
    return;
  }
  console.log('光标已隐藏');
});

// 退出全屏时恢复显示
pointer.setPointerVisible(true, (error) => {
  if (error) {
    console.error(`显示光标失败: ${JSON.stringify(error)}`);
  }
});

桌面级光标交互开发建议

  1. 及时恢复默认状态 :自定义光标通常具有极强的场景指向性。务必在组件的 onHover(false) 回调中调用 restoreDefault(),防止光标移出区域后仍保持特殊样式。
  2. 精准设置焦点(FocusX/FocusY) :在自定义图像光标时,务必根据图像内容合理设置 focusXfocusY。例如,准星光标的焦点应在正中心,而画笔光标的焦点应在笔尖处。
  3. 注意性能与资源释放 :自定义光标依赖 PixelMap,在页面销毁或光标不再使用时,应主动调用 pixelMap.release() 释放内存,避免内存泄漏。
  4. 状态变更时的重设机制:当应用窗口布局改变、页面跳转或光标移出再回到窗口时,系统可能会将光标切换回默认样式。开发者需要在相关生命周期或回调中重新设置自定义光标样式。

四、 多区域光标状态隔离与嵌套处理

在复杂的嵌套布局中(例如:地图应用中的地图区域、可拖拽的侧边栏、普通文本区),鼠标在不同区域移动时会频繁触发光标切换。为了防止状态混乱,建议将光标控制逻辑与组件的 onHover 深度绑定,并确保内层组件离开时能正确恢复外层状态。

核心代码示例:

javascript 复制代码
Column() {
  // 外层普通区域
  Text('普通文本区域')
    .onHover((isHover) => {
      if (isHover) {
        this.getUIContext().getCursorController().setCursor(pointer.PointerStyle.TEXT);
      } else {
        this.getUIContext().getCursorController().restoreDefault();
      }
    })

  // 内层拖拽调整区域
  Divider()
    .height(4)
    .onHover((isHover) => {
      if (isHover) {
        // 内层组件悬停时,覆盖外层光标
        this.getUIContext().getCursorController().setCursor(pointer.PointerStyle.RESIZE_VERTICAL);
      } else {
        // 离开内层组件时,恢复默认或交由外层处理
        this.getUIContext().getCursorController().restoreDefault();
      }
    })
}

五、 沉浸式场景下的光标自动隐藏与显现

在全屏视频播放、3D 渲染或演示模式下,光标长时间静止会遮挡视线。鸿蒙支持通过 setPointerVisible 结合定时器,实现"移动时显示,静止 N 秒后自动隐藏"的沉浸式体验。

核心代码示例:

javascript 复制代码
import { pointer } from '@kit.InputKit';

@State private hideCursorTimer: number = -1;

// 在鼠标移动或悬停时触发
.onHover((isHover) => {
  if (isHover && this.isFullScreen) {
    // 1. 显示光标
    pointer.setPointerVisible(true);
    
    // 2. 清除上一次的隐藏定时器
    if (this.hideCursorTimer !== -1) clearTimeout(this.hideCursorTimer);
    
    // 3. 设置新的隐藏定时器(例如 3 秒无操作后隐藏)
    this.hideCursorTimer = setTimeout(() => {
      pointer.setPointerVisible(false);
    }, 3000);
  }
})

六、 动态调整自定义光标焦点(FocusX/FocusY)

在某些特殊交互中(如:根据缩放级别改变画笔粗细,或切换不同的取色器模式),光标的图像可能发生变化,此时必须同步更新 focusXfocusY,否则会导致"点击位置"与"视觉光标位置"发生偏移。

核心代码示例:

javascript 复制代码
// 假设用户切换了画笔大小,需要重新加载光标
private async updateBrushCursor(size: number) {
  const pixelMap = await this.generateBrushPixelMap(size); // 动态生成不同大小的画笔图像
  
  const customCursor: pointer.CustomCursor = {
    pixelMap: pixelMap,
    focusX: size / 2, // 【关键】焦点始终保持在画笔正中心
    focusY: size / 2  
  };
  
  const config: pointer.CursorConfig = { followSystem: false };
  const windowId = this.getWindowId();
  
  await pointer.setCustomCursor(windowId, customCursor, config);
}

七、 构建全局光标状态机(CursorStateManager)

在大型 PC 应用中,如果在各个页面分散调用 setCursorrestoreDefault,极易导致光标状态残留(例如:从拖拽页面跳转到新页面,光标依然是拖拽手型)。建议封装一个全局的光标状态管理类,统一管理当前应用的光标上下文。

核心代码示例:

javascript 复制代码
export class CursorStateManager {
  private static currentStyle: pointer.PointerStyle = pointer.PointerStyle.DEFAULT;
  private static uiContext: UIContext;

  static init(context: UIContext) {
    this.uiContext = context;
  }

  // 安全地设置光标
  static set(style: pointer.PointerStyle) {
    if (this.currentStyle !== style) {
      this.currentStyle = style;
      this.uiContext.getCursorController().setCursor(style);
    }
  }

  // 安全地恢复默认光标
  static restore() {
    if (this.currentStyle !== pointer.PointerStyle.DEFAULT) {
      this.currentStyle = pointer.PointerStyle.DEFAULT;
      this.uiContext.getCursorController().restoreDefault();
    }
  }
}

// 在组件中使用,避免重复调用和状态错乱
Row()
  .onHover((isHover) => {
    if (isHover) CursorStateManager.set(pointer.PointerStyle.HAND);
    else CursorStateManager.restore();
  })

八、 结合鼠标事件与修饰键的联动控制

在 PC 端,光标样式往往需要与鼠标的按键状态(如按下左键)或键盘修饰键(如按住 ShiftCtrl)联动。通过 onMouse 事件,可以捕获这些复合状态,从而动态切换光标。

核心代码示例:

javascript 复制代码
import { MouseEvent, Action, Button } from '@kit.InputKit';

Rectangle()
  .width(300).height(200)
  .onMouse((event: MouseEvent) => {
    // 当按下左键且按住 Shift 键时,切换为十字准星(常用于框选)
    if (event.action === Action.BUTTON_DOWN && event.button === Button.LEFT) {
      if (event.shiftKey) {
        this.getUIContext().getCursorController().setCursor(pointer.PointerStyle.CROSS);
      }
    } 
    // 释放按键时恢复默认
    else if (event.action === Action.BUTTON_UP) {
      this.getUIContext().getCursorController().restoreDefault();
    }
  })

九、 拖拽交互的光标反馈(onDrag)

在文件管理器或看板应用中,当用户拖拽元素时,系统默认的拖拽反馈可能不够直观。可以在 onDragStart 时将光标切换为"抓取/禁止"状态,并在 onDragEnd 时恢复。

核心代码示例:

javascript 复制代码
Column()
  .onDragStart(() => {
    // 拖拽开始时,切换为"移动/抓取"光标
    this.getUIContext().getCursorController().setCursor(pointer.PointerStyle.MOVE);
    return new DragItem('拖拽的数据');
  })
  .onDragEnd(() => {
    // 拖拽结束时,务必恢复默认光标
    this.getUIContext().getCursorController().restoreDefault();
  })

十、 文本编辑区的光标与底层控制器联动

在富文本编辑器或代码编辑器中,除了视觉上的光标(Caret),还需要控制底层的输入光标(Pointer)。结合 API 23 新增的底层能力,可以实现更精细的文本交互。

核心代码示例:

javascript 复制代码
// 当进入文本编辑模式时
TextInput({ controller: this.textController })
  .onFocus(() => {
    // 视觉指针切换为文本输入样式
    this.getUIContext().getCursorController().setCursor(pointer.PointerStyle.TEXT);
  })
  .onBlur(() => {
    this.getUIContext().getCursorController().restoreDefault();
  })

// 在自定义工具栏中,通过控制器直接操作底层光标(如模拟退格删除)
Button('删除前一字符')
  .onClick(() => {
    // 直接调用底层控制器,无需手动截取字符串,避免光标抖动
    this.textController.deleteBackward(); 
  })

十一、 游戏与 3D 场景的光标锁定(Pointer Lock)

在 3D 渲染、全景视频或第一人称游戏中,需要鼠标无限移动且不被屏幕边界限制。鸿蒙支持通过 setPointerLock 锁定光标,使其在物理鼠标移动时仅触发相对坐标变化,而不影响系统级绝对坐标。

核心代码示例:

javascript 复制代码
// 进入 3D 场景时锁定光标
Button('进入沉浸模式')
  .onClick(async () => {
    try {
      const windowClass = await window.getLastWindow(getContext(this));
      // 锁定光标
      await windowClass.setPointerLock(true);
      console.info('光标已锁定,可自由旋转视角');
    } catch (error) {
      console.error('锁定光标失败', error);
    }
  })

// 退出沉浸模式时解锁
Button('退出沉浸模式')
  .onClick(async () => {
    const windowClass = await window.getLastWindow(getContext(this));
    await windowClass.setPointerLock(false);
    this.getUIContext().getCursorController().restoreDefault();
  })

补充建议

  1. 光标与焦点的解耦:视觉光标(Pointer)代表"鼠标在哪",而焦点(Focus)代表"键盘输入给谁"。在 PC 开发中,不要将两者混淆。例如,鼠标悬停在按钮上改变了光标,但不应该自动让按钮获取键盘焦点(除非用户明确点击)。
  2. 全局状态兜底 :在 3D 场景或全屏应用中,如果应用意外崩溃或失去焦点(如 Alt+Tab 切出),系统通常会自动解锁光标。但在应用重新激活(onForeground)时,务必检查并重置光标状态,防止出现"光标不可见"或"光标被锁死"的极端 Bug。
  3. 多窗口光标隔离 :在多窗口(如分屏、画中画)场景下,每个窗口拥有独立的 Window 实例。自定义光标或锁定光标时,必须获取当前活动窗口的 ID 进行操作,避免误改其他后台窗口的光标状态。
  4. 性能与内存监控 :频繁切换自定义光标(如在列表中快速滑动时)会产生大量的 PixelMap 创建与销毁开销。务必复用 PixelMap 对象,并在组件销毁(aboutToDisappear)时统一释放资源,防止内存泄漏。