HarmonyOS 数据可视化实战:封装一个可复用的 3D 热点词球卡片组件

HarmonyOS 数据可视化实战:封装一个可复用的 3D 热点词球卡片组件

做内容型页面时,常见的诉求有两类:一类是信息要足够直观,另一类是交互要有记忆点。普通标签列表虽然稳定,但很难做出停留感;纯视觉动效虽然吸睛,却经常难以复用。

最近我用 ArkUI 封装了一个可复用的 TopicSphereCard 组件,目标很明确:

  • 词条不是平铺,而是以 3D 球面的形式动态分布
  • 组件支持自动旋转、手势拖拽和惯性减速
  • 点击任意词条后,卡片详情区同步切换
  • 结构上可直接复用到热点页、兴趣推荐页、活动会场页

如何把一个看起来偏效果型的词球交互,真正封装成可复用的鸿蒙卡片组件。

如果你正在做 HarmonyOS 的资讯页、标签页、热点页,或者希望给首页增加一块更有表现力的内容入口,这个方案会比较合适。

为什么要把词球做成组件,而不是直接写在页面里

很多交互在第一版实现时都能跑,但真正进入业务以后,很快就会暴露出几个问题:

  1. 页面代码同时承载数据、动画、样式和点击联动,职责过重。
  2. 换一个业务场景,比如从"热点词条"切到"兴趣标签",几乎只能复制整页。
  3. 后续如果要接接口、改皮肤、加埋点、调交互,维护成本会迅速上升。

这也是我这次封装 TopicSphereCard 的出发点。

组件应该只关心这些事情:

  • 把词条排布到球面上
  • 维护旋转动画与手势交互
  • 提供统一的卡片容器和详情联动

而这些事情不应该由组件决定:

  • 词条数据来自哪里
  • 页面如何布局
  • 详情区是否要换成别的业务内容

也就是说,我们封装的不是一个"页面",而是一块可以独立生长的交互能力。

在进入代码之前,先看一下这个组件最终应具备的能力边界:

  • 输入一组词条数据
  • 组件内部自动完成球面初始化
  • 组件内部维护旋转状态与选中状态
  • 外部页面只负责传数据和摆放位置

这个边界一旦清楚,后面的实现就会非常顺。

先把数据模型定清楚:业务字段和空间字段放在一起

词球看起来像一个视觉组件,但本质上它是"数据驱动动画"的结果。

对于单个词条,我们至少需要两类信息:

  • 业务信息:标题、分类、热度、摘要、标签
  • 空间信息:xyzalpha

建议把这两部分直接放在同一个对象里,而不是拆成两份。这样做的好处是:一个对象既能参与旋转计算,也能直接渲染 UI,避免中间再做映射。

ts 复制代码
@ObservedV2
export class SphereTopic {
  @Trace x: number = 0;
  @Trace y: number = 0;
  @Trace z: number = 0;
  @Trace alpha: number = 0.5;

  id: string = '';
  title: string = '';
  summary: string = '';
  category: string = '';
  heat: string = '';
  momentum: string = '';
  color: string = '#FFFFFF';
  fontSize: number = 18;
  tags: string[] = [];
  insights: string[] = [];

  constructor(options: {
    id: string;
    title: string;
    summary: string;
    category: string;
    heat: string;
    momentum: string;
    color?: string;
    fontSize?: number;
    tags?: string[];
    insights?: string[];
  }) {
    this.id = options.id;
    this.title = options.title;
    this.summary = options.summary;
    this.category = options.category;
    this.heat = options.heat;
    this.momentum = options.momentum;
    this.color = options.color ?? '#FFFFFF';
    this.fontSize = options.fontSize ?? 18;
    this.tags = options.tags ?? [];
    this.insights = options.insights ?? [];
  }
}

这里有两个细节值得强调。

第一,@ObservedV2@Trace 不是装饰性的写法,它们决定了坐标变化能否及时驱动视图刷新。

第二,alpha 最好也放到模型里,因为它和 z 深度是强关联的,后面可以直接拿来控制透明度和字号层次。

如果你后面想把这个组件做成完全通用的词球,只要保持这个数据模型不变,业务字段继续加就行,旋转逻辑不需要动。

词球的核心其实不在 UI,而在球面排布和双轴旋转

很多人第一次做词球时,会把注意力放在渐变、玻璃拟态、卡片圆角这些"看得见"的部分。但真正决定这个组件是不是词球的,是下面两段能力:

  • 初始时,词条能不能均匀分布到球面上
  • 运行时,词条能不能在 X/Y 两个轴上稳定旋转

1. 先做球面初始化

核心思路是通过球面均匀采样,为每个词条生成一组三维坐标:

ts 复制代码
private initSphere() {
  this.topics.forEach((item, index) => {
    const ratio = -1 + (2 * (index + 1) - 1) / this.topics.length;
    const polar = Math.acos(ratio);
    const azimuth = polar * Math.sqrt(this.topics.length * Math.PI);

    item.x = this.radius * Math.sin(polar) * Math.cos(azimuth) + this.centerX;
    item.y = this.radius * Math.sin(polar) * Math.sin(azimuth) + this.centerY;
    item.z = this.radius * Math.cos(polar);
    item.alpha = (item.z + this.radius) / (2 * this.radius);
  });
}

这段代码的价值在于,它不是"随机撒点",而是真正让标签围绕球面均匀展开。

其中:

  • polar 对应极角
  • azimuth 对应方位角
  • x / y / z 是词条在球面上的空间坐标
  • alpha 用来表达远近层次

如果省掉这一步,词球很容易做成"乱飞的标签堆";而只要这一步稳了,后面的旋转基本就顺了。

2. 再做绕 X、Y 轴旋转

绕 X 轴旋转:

ts 复制代码
private rotateX() {
  const cos = Math.cos(this.angleX);
  const sin = Math.sin(this.angleX);

  this.topics.forEach((item) => {
    const nextY = (item.y - this.centerY) * cos - item.z * sin + this.centerY;
    const nextZ = item.z * cos + (item.y - this.centerY) * sin;

    item.y = nextY;
    item.z = nextZ;
    item.alpha = (item.z + this.radius) / (2 * this.radius);
  });
}

绕 Y 轴旋转:

ts 复制代码
private rotateY() {
  const cos = Math.cos(this.angleY);
  const sin = Math.sin(this.angleY);

  this.topics.forEach((item) => {
    const nextX = (item.x - this.centerX) * cos - item.z * sin + this.centerX;
    const nextZ = item.z * cos + (item.x - this.centerX) * sin;

    item.x = nextX;
    item.z = nextZ;
    item.alpha = (item.z + this.radius) / (2 * this.radius);
  });
}

这两段看起来是数学公式,实际上决定的是整个组件的"质感"。

因为在词球场景里,视觉层次并不是来自复杂特效,而是来自这几个值的联动:

  • z 越靠前,透明度越高
  • z 越靠前,字号可以适当更大
  • 前景词条和背景词条自然形成层级

所以后面渲染时,我会直接这样处理:

ts 复制代码
.fontSize(item.fontSize + item.alpha * 4)
.opacity(0.38 + item.alpha * 0.62)

这不是重特效,但性价比非常高。对词球这种组件来说,轻量级联动往往比花哨动画更有效。

交互体验怎么做顺:自动旋转、拖拽控制、惯性减速都要收进组件内部

如果词球只是静态旋转,它更像一个观赏组件;只有当用户能接管它,它才真的有"交互价值"。

我在封装时,把交互拆成了三层:

  1. 自动旋转,保证组件默认有生命力
  2. 拖拽旋转,让用户直接干预词球方向
  3. 惯性减速,让手势结束后的过渡更自然

自动旋转

自动旋转本身很简单,关键是谁来管理它

如果你希望组件可复用,那定时器的启动和销毁必须内聚在组件内部。

ts 复制代码
private startAutoRotate() {
  this.stopAutoRotate();
  this.rotateTimer = setInterval(() => {
    this.rotateX();
    this.rotateY();
  }, 17);
}

private stopAutoRotate() {
  if (this.rotateTimer >= 0) {
    clearInterval(this.rotateTimer);
    this.rotateTimer = -1;
  }
}

17ms 基本就是 60fps 左右的节奏,表现和性能比较平衡。

拖拽旋转

用户拖动时,我不会直接把位移映射成坐标,而是先映射成角速度:

ts 复制代码
private onPanUpdate(event: GestureEvent) {
  const offsetX = -event.offsetX;
  const offsetY = -event.offsetY;

  const nextAngleY = offsetX > 0
    ? Math.min(this.radius * 0.0024, offsetX * 0.001)
    : Math.max(-this.radius * 0.0024, offsetX * 0.001);

  const nextAngleX = offsetY > 0
    ? Math.min(this.radius * 0.0024, offsetY * 0.001)
    : Math.max(-this.radius * 0.0024, offsetY * 0.001);

  if (nextAngleX !== 0 && nextAngleY !== 0) {
    this.angleX = nextAngleX;
    this.angleY = nextAngleY;
  }

  this.rotateX();
  this.rotateY();
}

这么做有两个目的:

  • 拖拽越快,旋转反馈越明显
  • 通过 Math.min / Math.max 给角速度设上限,防止出现过度甩动

如果不做上限控制,词球会非常容易"发疯"。

惯性减速

只靠自动旋转和手势更新,用户松手后的体验会很硬,所以我又加了一层惯性:

ts 复制代码
private startInertia() {
  this.stopInertia();
  this.inertiaTimer = setInterval(() => {
    const nextAngleX = this.angleX * 0.82;
    const nextAngleY = this.angleY * 0.82;

    if (Math.abs(nextAngleX) <= this.minAngle && Math.abs(nextAngleY) <= this.minAngle) {
      this.stopInertia();
      return;
    }

    this.angleX = nextAngleX;
    this.angleY = nextAngleY;
  }, 120);
}

衰减系数 0.82 不一定是唯一答案,但这个量级已经足够让交互显得顺滑,不会有明显的断档感。

手势接管时的正确时机

真正影响手感的,往往不是旋转公式,而是状态切换是否自然。我的处理方式是:

  • 手指按下时,暂停自动旋转
  • 开始拖拽时,停止惯性动画
  • 拖拽更新时,实时根据位移调整角速度
  • 手势结束后,先进入惯性减速,再恢复自动旋转

这一步建议一定放在组件内部,否则外部页面会被迫知道太多实现细节。

组件实现:封装一个真正可复用的 TopicSphereCard

上面的算法和交互都准备好之后,组件封装就比较自然了。

我把它整理成这样一个结构:

ts 复制代码
@ComponentV2
export struct TopicSphereCard {
  @Param title: string = '今日热点';
  @Param subtitle: string = '拖拽旋转,点击词条查看详情';
  @Param topics: SphereTopic[] = [];
	// 根据设备进行动态计算,这里是示意
  radius: number = 112;
  centerX: number = 156;
  centerY: number = 152;
  angleX: number = Math.PI / 360;
  angleY: number = Math.PI / 420;
  minAngle: number = Math.PI / 900;
  rotateTimer: number = -1;
  inertiaTimer: number = -1;

  @Local currentTopic: SphereTopic | null = null;

  aboutToAppear(): void {
    this.initSphere();
    if (this.topics.length > 0) {
      this.currentTopic = this.topics[0];
    }
    this.startAutoRotate();
  }

  aboutToDisappear(): void {
    this.stopAutoRotate();
    this.stopInertia();
  }

  private initSphere() {
    this.topics.forEach((item, index) => {
      const ratio = -1 + (2 * (index + 1) - 1) / this.topics.length;
      const polar = Math.acos(ratio);
      const azimuth = polar * Math.sqrt(this.topics.length * Math.PI);

      item.x = this.radius * Math.sin(polar) * Math.cos(azimuth) + this.centerX;
      item.y = this.radius * Math.sin(polar) * Math.sin(azimuth) + this.centerY;
      item.z = this.radius * Math.cos(polar);
      item.alpha = (item.z + this.radius) / (2 * this.radius);
    });
  }

  private rotateX() {
    const cos = Math.cos(this.angleX);
    const sin = Math.sin(this.angleX);
    this.topics.forEach((item) => {
      const nextY = (item.y - this.centerY) * cos - item.z * sin + this.centerY;
      const nextZ = item.z * cos + (item.y - this.centerY) * sin;
      item.y = nextY;
      item.z = nextZ;
      item.alpha = (item.z + this.radius) / (2 * this.radius);
    });
  }

  private rotateY() {
    const cos = Math.cos(this.angleY);
    const sin = Math.sin(this.angleY);
    this.topics.forEach((item) => {
      const nextX = (item.x - this.centerX) * cos - item.z * sin + this.centerX;
      const nextZ = item.z * cos + (item.x - this.centerX) * sin;
      item.x = nextX;
      item.z = nextZ;
      item.alpha = (item.z + this.radius) / (2 * this.radius);
    });
  }

  private onPanUpdate(event: GestureEvent) {
    const offsetX = -event.offsetX;
    const offsetY = -event.offsetY;

    const nextAngleY = offsetX > 0
      ? Math.min(this.radius * 0.0024, offsetX * 0.001)
      : Math.max(-this.radius * 0.0024, offsetX * 0.001);
    const nextAngleX = offsetY > 0
      ? Math.min(this.radius * 0.0024, offsetY * 0.001)
      : Math.max(-this.radius * 0.0024, offsetY * 0.001);

    if (nextAngleX !== 0 && nextAngleY !== 0) {
      this.angleX = nextAngleX;
      this.angleY = nextAngleY;
    }

    this.rotateX();
    this.rotateY();
  }

  private startAutoRotate() {
    this.stopAutoRotate();
    this.rotateTimer = setInterval(() => {
      this.rotateX();
      this.rotateY();
    }, 17);
  }

  private stopAutoRotate() {
    if (this.rotateTimer >= 0) {
      clearInterval(this.rotateTimer);
      this.rotateTimer = -1;
    }
  }

  private startInertia() {
    this.stopInertia();
    this.inertiaTimer = setInterval(() => {
      const nextAngleX = this.angleX * 0.82;
      const nextAngleY = this.angleY * 0.82;

      if (Math.abs(nextAngleX) <= this.minAngle && Math.abs(nextAngleY) <= this.minAngle) {
        this.stopInertia();
        return;
      }

      this.angleX = nextAngleX;
      this.angleY = nextAngleY;
    }, 120);
  }

  private stopInertia() {
    if (this.inertiaTimer >= 0) {
      clearInterval(this.inertiaTimer);
      this.inertiaTimer = -1;
    }
  }

  build() {
    Column({ space: 14 }) {
      Row() {
        Column({ space: 4 }) {
          Text(this.title)
            .fontSize(18)
            .fontWeight(700)
            .fontColor('#F7FAFF');
          Text(this.subtitle)
            .fontSize(12)
            .fontColor('rgba(247, 250, 255, 0.64)');
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1);

        Text('Demo View')
          .fontSize(11)
          .fontColor('#AFC7FF')
          .padding({ left: 10, right: 10, top: 6, bottom: 6 })
          .backgroundColor('rgba(116, 154, 255, 0.16)')
          .borderRadius(14);
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween);

      Stack() {
        ForEach(this.topics, (item: SphereTopic) => {
          Text(item.title)
            .fontSize(item.fontSize + item.alpha * 4)
            .fontWeight(this.currentTopic?.id === item.id ? 700 : 500)
            .fontColor(item.color)
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .backgroundColor(
              this.currentTopic?.id === item.id ? 'rgba(255, 255, 255, 0.18)' : 'rgba(255, 255, 255, 0.06)'
            )
            .borderRadius(18)
            .opacity(0.38 + item.alpha * 0.62)
            .position({ x: item.x, y: item.y })
            .onClick(() => {
              this.currentTopic = item;
            });
        }, (item: SphereTopic) => item.id);
      }
      .height(320)
      .width('100%')
      .onTouch((event?: TouchEvent) => {
        if (!event) {
          return;
        }
        if (event.type === TouchType.Down) {
          this.stopAutoRotate();
        }
        if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
          this.startAutoRotate();
        }
      })
      .gesture(
        PanGesture()
          .onActionStart(() => {
            this.stopInertia();
          })
          .onActionUpdate((event) => {
            this.onPanUpdate(event);
          })
          .onActionEnd(() => {
            this.startInertia();
            this.startAutoRotate();
          })
      );

      if (this.currentTopic) {
        Column({ space: 10 }) {
          Text(this.currentTopic.title)
            .fontSize(22)
            .fontWeight(800)
            .fontColor('#132238');
          Text(this.currentTopic.summary)
            .fontSize(13)
            .fontColor('#607089');
          Text(this.currentTopic.heat + ' · ' + this.currentTopic.momentum)
            .fontSize(12)
            .fontColor('#E06B48');
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(16)
        .backgroundColor('#FFFFFF')
        .borderRadius(22);
      }
    }
    .width('100%')
    .padding(18)
    .backgroundColor('rgba(12, 23, 42, 0.92)')
    .borderRadius(28);
  }
}

这版组件里,我刻意保留了三类对外参数:

  • title
  • subtitle
  • topics

这样外部页面在使用时只需要关心"展示什么",不需要知道词球内部到底怎么转。

而像这些实现细节,则全部收进了组件内部:

  • 球面初始化
  • 双轴旋转
  • 自动旋转的启动与清理
  • 惯性减速
  • 当前词条选中态

这一步做完以后,整个组件就从"效果代码"变成了"业务可复用资产"。

补一个实战细节:词球看起来不居中,通常不是球心错了,而是定位方式错了

这次在 demo 调整里,我还额外处理了一个很典型的问题:球体数学中心是对的,但视觉中心看起来还是偏了。

这个问题一般有两个来源:

  • 球心坐标写死了,比如直接固定 centerX = 156centerY = 152
  • 词条用 .position({ x, y }) 时,传入的是词条中心点,但组件实际按左上角落位

第一种情况会导致词球在不同屏宽下整体偏移;第二种情况会让一圈标签整体"往右下坠",看起来就像球没摆正。

更稳妥的处理方式是:球心跟着容器尺寸动态计算,词条位置再减去自身一半宽高,让视觉中心和数学中心对齐。

ts 复制代码
private syncSphereCenter(width: number, height: number) {
  if (width <= 0 || height <= 0) {
    return;
  }

  const nextCenterX = width / 2;
  const nextCenterY = height / 2;
  const deltaX = nextCenterX - this.centerX;
  const deltaY = nextCenterY - this.centerY;

  if (deltaX === 0 && deltaY === 0) {
    return;
  }

  this.centerX = nextCenterX;
  this.centerY = nextCenterY;
  this.topics.forEach((item) => {
    item.x += deltaX;
    item.y += deltaY;
  });
}

private getTopicPositionX(item: SphereTopic): number {
  return item.x - this.getTopicWidth(item) / 2;
}

private getTopicPositionY(item: SphereTopic): number {
  return item.y - this.getTopicHeight(item) / 2;
}

在视图层里,可以结合 onAreaChange 一起使用:

ts 复制代码
Stack() {
  ForEach(this.topics, (item: SphereTopic) => {
    Text(item.title)
      .position({
        x: this.getTopicPositionX(item),
        y: this.getTopicPositionY(item)
      });
  }, (item: SphereTopic) => item.id);
}
.height(320)
.width('100%')
.onAreaChange((_: Area, value: Area) => {
  this.syncSphereCenter(Number(value.width), Number(value.height));
})

这一层优化看起来不大,但很关键。因为词球这种组件只要中心点稍微发偏,用户第一眼就会觉得"这个球没放正";把容器中心和词条视觉中心同时校准之后,整个组件的稳定感会明显提升。

页面接入、封装收益和后续扩展

页面接入方式非常简单:

ts 复制代码
@Entry
@ComponentV2
struct MainPage {
  @Local topics: SphereTopic[] = mockTopics;

  build() {
    Column() {
      TopicSphereCard({
        title: '今日热点词条',
        subtitle: '拖拽旋转,点击任意词条查看摘要',
        topics: this.topics
      })
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#08111F');
  }
}

页面层现在只承担三件事:

  • 准备数据
  • 传入文案
  • 决定卡片放在哪里

而这恰恰就是组件封装之后最理想的状态。

这次封装带来的直接收益

  1. 同一套旋转逻辑可以复用到热搜词、兴趣标签、品牌词云等多个场景。
  2. 页面样式可以自由换,但词球核心不用反复重写。
  3. 动画、交互和详情联动全部收口,后面接接口或者加埋点都更顺。
  4. 组件边界清晰后,团队协作也会轻松很多,谁负责业务层、谁负责组件层一眼就能分开。

封装这类组件时最容易踩的坑

  • 把业务数据和空间数据拆成两份,导致状态同步很绕
  • 忘记在 aboutToDisappear 清理定时器
  • 用户拖拽时没有暂停自动旋转,导致手感很乱
  • 只做透明度变化,不做字号层次,视觉会显得偏平

我建议继续扩展的方向

  • 支持 @Param onTopicClick,把点击事件抛给外部
  • 支持纯词球模式和"词球 + 详情卡片"双模式
  • 支持自定义主题色,适配深浅两套视觉风格
  • 支持外部数据更新后重新初始化球面
  • 支持自定义半径和高度,适配首页卡片、全屏模块、弹层容器

如果这些都补上,TopicSphereCard 基本就能成为一块长期可复用的内容组件了。

最后回看这次封装,真正重要的不是"做出了一个会转的球",而是把它拆成了清晰的结构:

  • 数据模型负责描述词条
  • 数学逻辑负责维护球面排布和旋转
  • 组件负责承接交互、样式和详情联动
  • 页面只负责组织业务内容

这也是我现在做鸿蒙交互组件时最看重的一点:先把边界切清楚,再去追求效果。

因为只有这样,组件才能真的复用起来,而不是停留在一次性的页面实现里。

相关推荐
源码之家2 小时前
计算机毕业设计:Python基金股票数据分析与可视化平台 Django框架 数据分析 可视化 爬虫 大数据 大模型(建议收藏)✅
爬虫·python·信息可视化·数据分析·django·flask·课程设计
三声三视2 小时前
鸿蒙 ArkTS 数据持久化实战:AppStorage、用户首选项与分布式数据管理
harmonyos
IntMainJhy2 小时前
Flutter flutter_animate 第三方库 动画的鸿蒙化适配与实战指南
nginx·flutter·harmonyos
jiejiejiejie_12 小时前
Flutter 三方库 pull_to_refresh 的鸿蒙化适配指南
flutter·华为·harmonyos
肖有米XTKF864618 小时前
金木新零售模式系统开发介绍平台解析
人工智能·信息可视化·软件工程·团队开发·csdn开发云
数智化精益手记局18 小时前
人员排班管理软件的自动化功能解析:解决传统手工人员进行排班管理耗时长的难题
运维·数据结构·人工智能·信息可视化·自动化·制造·精益工程
摄影图19 小时前
智慧城市数字孪生素材 多元风格适配各类创作需求
信息可视化·aigc·智慧城市·贴图·插画
超级码力66619 小时前
【Latex第三方文档类standalone】standalone类介绍及应用
算法·数学建模·信息可视化
码界筑梦坊20 小时前
94-基于Python的商品物流数据可视化分析系统
开发语言·python·mysql·信息可视化·数据分析·毕业设计·fastapi