鸿蒙 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)
这里最容易混淆的是 interval 和 duration。
六、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 展示保持了清晰边界。
参考链接
-
HarmonyOS Swiper 组件官方文档
developer.huawei.com/consumer/cn... -
HarmonyOS Marquee 组件官方文档
developer.huawei.com/consumer/cn... -
HarmonyOS 触摸事件官方文档
developer.huawei.com/consumer/cn... -
HarmonyOS 自定义组件官方文档
developer.huawei.com/consumer/cn... -
@Event 装饰器官方说明
developer.huawei.com/consumer/cn... -
官方行业问题:摇一摇弹窗与跑马灯相关示例入口
developer.huawei.com/consumer/cn...