鸿蒙 ArkUI 走马灯卡片实战:从官方文档检索到 Swiper 实现

鸿蒙 ArkUI 走马灯卡片实战:从官方文档检索到 Swiper 实现

一、写在前面

这篇文章记录一次鸿蒙 ArkUI 业务 UI 的实现过程:做一个类似"AI 问题推荐"的走马灯卡片。

需求大概是:

text 复制代码
1. 有 10 条推荐问题。
2. 问题分成上下两排展示。
3. 内容循环播放。
4. 整体效果接近走马灯,从右往左移动。
5. 播放速度大约是 6s / 条。
6. 支持手动左右滑动。
7. 用户滑动后暂停 1 秒,再继续自动播放。
8. 点击问题进入 AI 聊天页,并自动发送该问题。
9. 点击"问AI"按钮只进入 AI 聊天页,不携带具体问题。

这类需求看起来只是一个小组件,但实际会涉及:

text 复制代码
ArkUI 布局
Swiper 组件
Marquee 组件适用边界
TouchEvent 触摸事件
@Param / @Event 组件通信
Controller / ViewModel 分层
动画参数 duration / interval / curve
真机调试
手势暂停和恢复逻辑

本文代码均为通用示例代码,不包含公司项目路径、业务常量、接口地址或内部封装。


二、先从官网找资料,而不是直接写代码

做这类组件时,不建议直接凭感觉写 Scroll + setInterval + scrollTo

更稳妥的方式是先去华为开发者官网搜相关组件能力。

可以按下面几个关键词搜索。

1. 搜 "Marquee 跑马灯"

关键词:

text 复制代码
HarmonyOS Marquee 跑马灯
ArkUI Marquee
鸿蒙 Marquee 组件

官方 Marquee 文档说明它是跑马灯组件,用于滚动展示一段单行文本。它适合这种场景:

text 复制代码
一段文字很长
容器宽度不够
文字自己横向滚动

例如:

text 复制代码
这是一个很长很长的公告文本,会自动滚动展示

但是它不适合下面这种复杂场景:

text 复制代码
多个问题气泡
上下两排
每个气泡可以点击
右侧还有固定按钮
每个 item 是一个复杂组件

所以本需求不能直接用 Marquee 解决。


2. 搜 "Swiper 轮播"

关键词:

text 复制代码
HarmonyOS Swiper
ArkUI Swiper
鸿蒙 Swiper duration interval
Swiper curve Linear

Swiper 是轮播容器,适合自动轮播、手动滑动、循环切换等场景。

本需求虽然叫"走马灯",但内容不是单行文本,而是多个自定义气泡组件,所以可以用 Swiper 来实现近似走马灯效果。

需要注意的是:普通 Swiper 写法通常是轮播图效果。

例如:

ts 复制代码
Swiper() {
  ...
}
.interval(3000)
.duration(500)

这种效果是:

text 复制代码
停几秒
快速切下一页
再停几秒

但走马灯更希望是:

text 复制代码
持续、匀速、缓慢地从右往左移动

所以要调整 Swiper 的参数。


3. 搜 "TouchEvent 触摸事件"

关键词:

text 复制代码
HarmonyOS TouchEvent
ArkUI onTouch
TouchType Down Move Up Cancel

手动滑动后暂停 1 秒,不能只靠 onClick

因为用户滑动时会经历:

text 复制代码
手指按下 Down
手指移动 Move
手指抬起 Up
触摸取消 Cancel

所以需要用触摸事件来感知用户开始操作和结束操作。


4. 搜 "@Param @Event 自定义组件"

关键词:

text 复制代码
ArkUI @Param @Event
HarmonyOS ComponentV2 Event
鸿蒙 父子组件通信 @Param @Event

这个需求里,Card 组件不应该直接写业务跳转。更合理的方式是:

text 复制代码
父组件 / Controller:
处理数据、跳转、暂停状态

Card 组件:
接收数据、渲染 UI、抛出点击事件

这就需要用到:

ts 复制代码
@Param
@Event

三、为什么不用 Scroll + Scroller

最开始容易想到:

text 复制代码
Scroll 横向滚动
Scroller.scrollTo 控制位置
setInterval 每隔几十毫秒改一次 xOffset

大概像这样:

ts 复制代码
setInterval(() => {
  offset += 1
  scroller.scrollTo({
    xOffset: offset,
    yOffset: 0,
    animation: false
  })
}, 30)

这个方案看起来很直接,但真机上容易遇到几个问题:

text 复制代码
1. 字体抖动
2. 自动滚动和用户手动滑动抢控制权
3. 上下两排同步困难
4. 数据更新后容易出现错位
5. scrollTo 高频调用对性能不友好

Scroll 更适合"用户主动滚动"。

如果我们用程序频繁控制它的位置,再叠加手势、数组变化和动画,很容易变复杂。


四、为什么不用 Marquee

官方 Marquee 是跑马灯组件,但它更适合单行文本。

本需求里的每个问题不是普通字符串,而是一个带背景、圆角、阴影、箭头、点击事件的气泡组件。

例如一个问题气泡是:

text 复制代码
[ 机场怎么去        > ]

它里面有:

text 复制代码
Text
Blank
Text('>')
backgroundColor
borderRadius
shadow
onClick

这已经不是单纯文本滚动了,所以直接用 Marquee 不合适。


五、最终选择:Swiper 近似走马灯

最终选择 Swiper 的原因:

text 复制代码
1. 自带自动播放 autoPlay。
2. 自带循环 loop。
3. 自带手动滑动。
4. 可以设置 duration 控制动画时长。
5. 可以设置 curve(Curve.Linear) 让动画匀速。
6. 不需要自己处理 scrollTo。

但是要把 Swiper 从"普通轮播"调成"近似走马灯",关键参数是:

ts 复制代码
.interval(1)
.duration(6000)
.curve(Curve.Linear)
.loop(true)
.autoPlay(true)

这里最容易混淆的是 intervalduration


六、interval 和 duration 的区别

1. interval

interval 是两次自动播放之间的等待时间。

例如:

ts 复制代码
.interval(3000)

意思是:

text 复制代码
等 3 秒,再自动切换下一项

2. duration

duration 是一次切换动画持续多久。

例如:

ts 复制代码
.duration(6000)

意思是:

text 复制代码
这次从当前 item 移动到下一个 item 的动画,持续 6 秒

3. 走马灯不能这样写

ts 复制代码
.interval(6000)
.duration(6000)

这种写法会变成:

text 复制代码
先等 6 秒
再用 6 秒滚动
再等 6 秒
再滚动

看起来会停顿,不像连续走马灯。

4. 走马灯应该这样写

ts 复制代码
.interval(1)
.duration(6000)
.curve(Curve.Linear)

意思是:

text 复制代码
几乎不等待
每一次切换动画持续 6 秒
动画匀速执行

这样视觉上更接近持续滚动。


七、数据分层设计

不要让 Card 组件自己请求接口,也不要让 Card 组件自己跳页面。

推荐分层:

text 复制代码
Biz
  ↓
获取 10 条推荐问题,可以先 mock

Controller
  ↓
请求数据
格式化数据
拆成上下两排
处理暂停状态
处理点击跳转

ViewModel
  ↓
topQuestionList
bottomQuestionList
isPaused

Card 组件
  ↓
只渲染
只抛事件

八、ViewModel 示例

ts 复制代码
@ObservedV2
export class AiQuestionViewModel {
  @Trace questionList: string[] = []
  @Trace topQuestionList: string[] = []
  @Trace bottomQuestionList: string[] = []
  @Trace loading: boolean = false
  @Trace paused: boolean = false
}

字段说明:

text 复制代码
questionList:
原始 10 条问题。

topQuestionList:
上排问题。

bottomQuestionList:
下排问题。

loading:
加载状态。

paused:
是否暂停自动播放。

九、Biz mock 示例

ts 复制代码
export class AiQuestionBiz {
  static async getQuestionList(): Promise<string[]> {
    return new Promise<string[]>((resolve) => {
      setTimeout(() => {
        resolve([
          '机场怎么去',
          '附近吃什么',
          '亲子去哪玩',
          '一日游推荐',
          '商场怎么走',
          '哪里能寄存',
          '有母婴室吗',
          '怎么去高铁站',
          '附近有地铁吗',
          '推荐夜景路线'
        ])
      }, 300)
    })
  }
}

这里是 mock 数据。真实项目里可以替换为接口请求。


十、Controller 示例

ts 复制代码
export class AiQuestionController {
  private resumeTimerId: number = -1

  constructor(private viewModel: AiQuestionViewModel) {}

  public loadQuestionList(): void {
    this.viewModel.loading = true

    AiQuestionBiz.getQuestionList()
      .then((list: string[]) => {
        const formatList: string[] = this.formatQuestionList(list)

        this.viewModel.questionList = formatList
        this.initQuestionRows(formatList)
        this.viewModel.loading = false
      })
      .catch((err: Error) => {
        this.viewModel.questionList = []
        this.viewModel.topQuestionList = []
        this.viewModel.bottomQuestionList = []
        this.viewModel.loading = false
      })
  }

  private formatQuestionList(list: string[]): string[] {
    if (!Array.isArray(list)) {
      return []
    }

    const result: string[] = []

    list.forEach((item: string) => {
      const text: string = item.trim()

      if (text.length === 0 || text.length > 18) {
        return
      }

      if (result.length < 10) {
        result.push(text)
      }
    })

    return result
  }

  private initQuestionRows(list: string[]): void {
    const middleIndex: number = Math.ceil(list.length / 2)

    this.viewModel.topQuestionList = list.slice(0, middleIndex)
    this.viewModel.bottomQuestionList = list.slice(middleIndex)
  }

  public pauseMarqueeStart(): void {
    this.viewModel.paused = true
    this.clearResumeTimer()
  }

  public pauseMarqueeEnd(): void {
    this.clearResumeTimer()

    this.resumeTimerId = setTimeout(() => {
      this.viewModel.paused = false
    }, 1000)
  }

  private clearResumeTimer(): void {
    if (this.resumeTimerId !== -1) {
      clearTimeout(this.resumeTimerId)
      this.resumeTimerId = -1
    }
  }

  public openAiChat(question?: string): void {
    if (question && question.length > 0) {
      // 示例:这里替换为项目自己的路由封装
      console.info(`open ai chat with question: ${question}`)
      return
    }

    console.info('open ai chat')
  }
}

这里需要注意:暂停分为两个阶段。

text 复制代码
pauseMarqueeStart:
手指按下,立即暂停,不开始 1 秒倒计时。

pauseMarqueeEnd:
手指抬起,暂停 1 秒后恢复。

不要在 Move 阶段反复设置定时器,否则暂停时间会被一直刷新。


十一、Card 组件完整示例

ts 复制代码
import { Callback } from '@kit.BasicServicesKit'

@ComponentV2
export struct AiQuestionMarqueeCard {
  @Param topQuestionList: string[] = []
  @Param bottomQuestionList: string[] = []
  @Param isPaused: boolean = false

  @Event onQuestionClick?: Callback<string>
  @Event onAskClick?: Callback<void>
  @Event onTouchStart?: Callback<void>
  @Event onTouchEnd?: Callback<void>

  private readonly ITEM_SPACE: number = 8
  private readonly CARD_HEIGHT: number = 96
  private readonly ASK_BUTTON_WIDTH: number = 88
  private readonly PLAY_DURATION: number = 6000
  private readonly PLAY_INTERVAL: number = 1

  build() {
    Stack() {
      Column({ space: 8 }) {
        this.TopSwiperBuilder()

        Row({ space: 0 }) {
          this.BottomSwiperBuilder()
          this.AskButtonBuilder()
        }
        .width('100%')
        .height(40)
        .alignItems(VerticalAlign.Center)
      }
      .width('100%')
      .height(this.CARD_HEIGHT)
      .padding({
        left: 12,
        right: 12,
        top: 10,
        bottom: 10
      })
      .borderRadius(20)
      .clip(true)
      .linearGradient({
        angle: 90,
        colors: [
          ['#EAF8FF', 0.0],
          ['#F4FCFF', 0.55],
          ['#DFF4FF', 1.0]
        ]
      })
    }
    .width('100%')
    .height(this.CARD_HEIGHT)
    .clip(true)
  }

  @Builder
  TopSwiperBuilder() {
    Swiper() {
      ForEach(this.topQuestionList, (question: string, index: number) => {
        this.QuestionItemBuilder(question)
      }, (question: string, index: number) => `top_${index}_${question}`)
    }
    .width('100%')
    .height(30)
    .indicator(false)
    .loop(true)
    .autoPlay(!this.isPaused)
    .interval(this.PLAY_INTERVAL)
    .duration(this.isPaused ? 300 : this.PLAY_DURATION)
    .curve(Curve.Linear)
    .itemSpace(this.ITEM_SPACE)
    .displayCount(2)
    .disableSwipe(false)
    .onTouch((event: TouchEvent) => {
      this.handleTouchEvent(event)
    })
  }

  @Builder
  BottomSwiperBuilder() {
    Swiper() {
      ForEach(this.bottomQuestionList, (question: string, index: number) => {
        this.QuestionItemBuilder(question)
      }, (question: string, index: number) => `bottom_${index}_${question}`)
    }
    .layoutWeight(1)
    .height(30)
    .indicator(false)
    .loop(true)
    .autoPlay(!this.isPaused)
    .interval(this.PLAY_INTERVAL)
    .duration(this.isPaused ? 300 : this.PLAY_DURATION)
    .curve(Curve.Linear)
    .itemSpace(this.ITEM_SPACE)
    .displayCount(1)
    .disableSwipe(false)
    .onTouch((event: TouchEvent) => {
      this.handleTouchEvent(event)
    })
  }

  @Builder
  QuestionItemBuilder(question: string) {
    Row() {
      Text(question)
        .fontSize(14)
        .fontColor('#99000000')
        .fontWeight(FontWeight.Medium)
        .maxLines(1)

      Blank()
        .layoutWeight(1)

      Text('>')
        .fontSize(16)
        .fontColor('#99000000')
        .fontWeight(FontWeight.Bold)
    }
    .height(30)
    .constraintSize({
      minWidth: 110,
      maxWidth: 220
    })
    .padding({
      left: 12,
      right: 10
    })
    .borderRadius(15)
    .backgroundColor('#FFFFFF')
    .shadow({
      radius: 6,
      color: '#14000000',
      offsetX: 0,
      offsetY: 2
    })
    .onClick(() => {
      this.onQuestionClick?.(question)
    })
  }

  @Builder
  AskButtonBuilder() {
    Row() {
      Text('问AI')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
    }
    .width(this.ASK_BUTTON_WIDTH)
    .height(40)
    .justifyContent(FlexAlign.Center)
    .alignItems(VerticalAlign.Center)
    .borderRadius({
      topLeft: 0,
      topRight: 12,
      bottomLeft: 12,
      bottomRight: 12
    })
    .linearGradient({
      angle: 90,
      colors: [
        ['#70C7EA', 0.0],
        ['#4A86E8', 1.0]
      ]
    })
    .onClick(() => {
      this.onAskClick?.()
    })
  }

  private handleTouchEvent(event: TouchEvent): void {
    if (event.type === TouchType.Down) {
      this.onTouchStart?.()
      return
    }

    if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
      this.onTouchEnd?.()
    }
  }
}

十二、父组件调用示例

ts 复制代码
AiQuestionMarqueeCard({
  topQuestionList: this.viewModel.topQuestionList,
  bottomQuestionList: this.viewModel.bottomQuestionList,
  isPaused: this.viewModel.paused,

  onQuestionClick: (question: string) => {
    this.controller.openAiChat(question)
  },

  onAskClick: () => {
    this.controller.openAiChat()
  },

  onTouchStart: () => {
    this.controller.pauseMarqueeStart()
  },

  onTouchEnd: () => {
    this.controller.pauseMarqueeEnd()
  }
})

十三、关键问题复盘

1. 为什么不用一个数组动态切分?

如果使用一个数组:

text 复制代码
[A, B, C, D, E, F, G, H, I, J]

每 6 秒把第一个移到最后:

text 复制代码
[B, C, D, E, F, G, H, I, J, A]

再按前 5 个和后 5 个切分,上下排会变成:

text 复制代码
上排:B C D E F
下排:G H I J A

这会导致原本下排的内容跑到上排,视觉上会跳动。

所以应该在 Controller 中拆成两个数组:

text 复制代码
topQuestionList
bottomQuestionList

上下两排各自播放,互不影响。


2. 为什么气泡不能完全不设宽度?

完全不设宽度时,箭头会紧贴文字:

text 复制代码
[机场怎么去>]

更好的做法是:

ts 复制代码
.constraintSize({
  minWidth: 110,
  maxWidth: 220
})

再用:

ts 复制代码
Blank().layoutWeight(1)

把文字和箭头撑开:

text 复制代码
[机场怎么去          >]

3. 为什么 Move 阶段不要反复暂停?

如果在 TouchType.Move 里反复执行:

ts 复制代码
setTimeout(() => {
  paused = false
}, 1000)

用户滑动过程中会不停刷新定时器,导致实际暂停时间远超过 1 秒。

更好的方式是:

text 复制代码
Down:
立即暂停

Up / Cancel:
开始 1 秒恢复倒计时

十四、总结

这个走马灯卡片看起来只是一个小 UI,但里面涉及很多鸿蒙 ArkUI 开发中常见的问题:

text 复制代码
如何从官网查组件能力
如何区分 Marquee、Swiper、Scroll 的适用场景
如何让 Swiper 接近走马灯效果
如何处理手势暂停
如何做父子组件通信
如何拆分 Controller / ViewModel / Component
如何根据真机效果调动画参数

最终方案是:

text 复制代码
Marquee:
适合单行文本,不适合复杂气泡组件。

Scroll + Scroller:
能做,但自动控制和手动滑动冲突较多。

Swiper:
适合本需求,配合 interval(1)、duration(6000)、curve(Curve.Linear),可以实现接近连续走马灯的效果。

业务开发时,不要只追求"能动",还要考虑:

text 复制代码
是否稳定
是否能手动滑动
是否容易维护
是否符合项目分层
是否方便后续替换真实接口

本次实现最终采用 Swiper + Controller 拆分数据 + @Param/@Event 通信,既保留了组件稳定性,也让业务逻辑和 UI 展示保持了清晰边界。


参考链接

  1. HarmonyOS Swiper 组件官方文档
    developer.huawei.com/consumer/cn...

  2. HarmonyOS Marquee 组件官方文档
    developer.huawei.com/consumer/cn...

  3. HarmonyOS 触摸事件官方文档
    developer.huawei.com/consumer/cn...

  4. HarmonyOS 自定义组件官方文档
    developer.huawei.com/consumer/cn...

  5. @Event 装饰器官方说明
    developer.huawei.com/consumer/cn...

  6. 官方行业问题:摇一摇弹窗与跑马灯相关示例入口
    developer.huawei.com/consumer/cn...

相关推荐
喵个咪1 小时前
吃透后台权限系统:从架构设计到 Vue3/React 双框架完整落地
前端·vue.js·react.js
一起逃去看海吧1 小时前
对接LangSmith
java·前端·数据库
wyhwust1 小时前
web应用技术-第一次课后作业
java·前端·数据库
问心无愧05131 小时前
ctf show web入门257
android·前端·笔记
学且思1 小时前
Vue3 Patch 算法深度解析:从原理到源码实现
前端·vue.js
streaker3031 小时前
从复制 Token 到复用登录态:site-fetchkit 的抽离过程
前端·浏览器·ai编程
dsyyyyy11011 小时前
CSS继承性
前端·css·tensorflow
wordbaby1 小时前
React Native 压缩上传全链路方案:从架构设计到生产实践
前端·react native
Rain5091 小时前
05. mini-cc 工具系统:让 AI 拥有动手能力
linux·前端·人工智能·ubuntu·typescript·ai编程