HarmonyOS 6.1 实战:进度条的宽度需要根据数值动态计算(.width(this.selBreed.energy + ‘%‘)),如果使用字符串存储则需要额外的解析步骤
鸿蒙操作系统(HarmonyOS)是华为推出的面向全场景的分布式操作系统,其应用开发框架采用ArkUI声明式UI范式,而ArkTS则是在此基础上扩展的TypeScript超集语言。ArkTS在保留TypeScript类型安全特性的同时,针对鸿蒙的声明式UI开发模式做了深度优化,引入了诸如
@Component、@State、@Builder、@Entry等一系列装饰器,使得开发者能够以极其简洁的语法构建出高性能、高可维护性的跨设备应用界面。在鸿蒙生态中,组件化开发思想是核心方法论——每一个UI单元都被封装为独立的、可复用的组件,组件之间通过属性传递和回调函数实现数据流动与交互通信,这种设计模式不仅降低了代码耦合度,还大幅提升了开发效率和可测试性。
鸿蒙的ArkUI框架采用了类似于现代前端框架(如Flutter、SwiftUI)的声明式UI编程范式。在声明式UI中,开发者只需描述界面的当前状态,框架会自动处理状态变化到UI更新的映射关系。这种模式下,@State装饰器标记的变量成为驱动UI更新的核心——当这些变量的值发生变化时,框架会自动重新执行build()方法,生成新的虚拟DOM树,并通过高效的diff算法将差异应用到实际渲染树上。这种机制确保了UI与数据的始终同步,同时也避免了不必要的重绘开销。
在ArkTS中,布局系统提供了一套完整的容器组件和布局方式。Column容器将其子元素按照垂直方向从上到下排列,适用于列表、卡片等纵向排列场景;Row容器则将子元素按照水平方向从左到右排列,适用于工具栏、导航条等横向排列场景;Stack容器允许子元素堆叠在一起,后声明的子元素会覆盖先声明的子元素,适用于角标、浮层等叠加场景。此外,Flex布局提供了更灵活的对齐和分配空间能力,通过justifyContent和alignItems属性可以精确控制子元素在主轴和交叉轴上的位置。
@Builder装饰器是ArkTS中实现UI复用的关键机制。它允许开发者将一段UI描述逻辑封装为可调用的函数,在需要的地方通过this.builderName()的方式引用。与直接在build()中内联UI代码相比,@Builder方法提供了更好的代码组织结构,使得复杂的界面可以被拆分为多个逻辑清晰的构建单元。ForEach则是ArkTS提供的列表渲染指令,它接收一个数据数组和一个渲染函数,为每个数据项生成对应的UI组件,是处理动态列表展示的标准方案。
本文将对一个完整的萌宠社交信息流应用进行逐段代码深度解析,涵盖接口定义、数据建模、辅助函数、主组件架构、弹窗系统、标签页组件等全部技术层面,力求让读者全面理解ArkTS在复杂业务场景下的实践方法。
一、接口定义层:类型安全的数据契约
1.1 PetNews接口——萌闻数据模型
interface PetNews {
id: number;
title: string;
author: string;
topic: string;
views: string;
replies: string;
emoji: string;
badge: string;
time: string;
}

PetNews接口定义了萌宠资讯信息流中每一条新闻的数据结构。在ArkTS中,interface关键字用于声明一个类型契约,它规定了实现该接口的对象必须包含哪些属性以及每个属性的类型。这里的id属性类型为number,用于在列表渲染时作为唯一标识符——ForEach指令可以利用这个标识符进行高效的diff操作,当列表数据变化时只更新发生变化的项,而非重建整个列表。
title和author分别表示新闻标题和发布者名称,它们都是string类型。值得注意的是,views(阅读量)和replies(跟帖数)也被定义为string而非number,这是因为这些数据在界面中以"1102万"、"3.6万"这样的中文计数格式展示,直接使用字符串存储可以避免在渲染时进行数字到中文格式的转换计算。emoji字段存储一个emoji表情字符串,用于在新闻列表中以图标形式快速标识新闻类型——橘猫用🐱、金毛用🐕、仓鼠用🐹等,这种设计让信息流在视觉上更加生动直观。
badge字段表示新闻的标签状态,取值包括"爆"、“热”、“新"或空字符串。在UI渲染时,通过条件判断if (n.badge !== '')来决定是否渲染标签组件,这种空字符串作为"无值"标识的设计模式在ArkTS中非常常见,它避免了使用null或undefined可能带来的类型安全问题。time字段存储新闻的发布时间描述,如"16分钟前”、“2小时前”,采用相对时间格式而非绝对时间戳,这在前端展示层面提供了更好的用户体验。
在ArkTS的类型系统中,接口(interface)不仅用于声明对象的形状,还充当了组件间数据传递的类型约束。当组件的属性被声明为某个接口类型时,编译器会在编译阶段检查传入的数据是否符合接口定义,这种静态类型检查能够有效防止运行时的类型错误,是ArkTS区别于纯JavaScript的重要优势之一。
1.2 PetHot与PetVideo接口——热榜与视频模型
interface PetHot {
rank: number;
tag: string;
reads: string;
state: string;
rise: string;
}
interface PetVideo {
id: number;
title: string;
vlogger: string;
mins: string;
plays: string;
treats: string;
theme: string;
}

PetHot接口定义了萌宠热榜的数据结构。rank字段是热榜排名,类型为number,在UI渲染时被用于显示排名序号,同时也被用于条件渲染——排名前三的热搜使用不同的颜色高亮(第一名金色、第二名橙色、第三名粉色),这种基于排名的视觉差异化设计是信息流应用中常见的用户引导手法。tag字段存储热搜标签文本,如"橘猫店长签字"、“金毛取快递”,这些短小精炼的标签是用户快速浏览热点内容的主要入口。
state字段与PetNews的badge字段类似,表示热搜的状态标签(“爆”、“热”、“新"或空)。rise字段表示热搜的阅读量增长幅度,如"↑ 142万”,使用箭头符号直观地传达了上升趋势。这种将数值变化趋势直接编码为字符串的设计,使得UI层无需额外的格式化逻辑即可直接显示。
PetVideo接口则定义了萌宠视频的数据模型。vlogger字段是视频创作者的名称,这是一个自创词,由"video"和"blogger"组合而成,体现了互联网内容创作领域的术语创新。mins字段存储视频时长,格式为"MM:SS",如"07:22"、“05:41”。plays和treats分别表示播放量和投喂次数——“投喂"是该应用特有的互动机制,用户可以给喜欢的视频"投喂"虚拟食物,这是一种将社交互动游戏化的设计策略。theme字段是视频的主题分类标签,如"纪录”、“跟拍”、“搜查”、"合集"等,用于在视频列表中快速区分内容类型。
1.3 PetTopic与PetPost接口——话题与帖子模型
interface PetTopic {
id: number;
name: string;
joined: string;
posts: string;
mood: string;
color: string;
}
interface PetPost {
id: number;
owner: string;
pet: string;
text: string;
paw: string;
replies: string;
club: string;
}

PetTopic接口定义了萌宠话题的数据模型。name字段存储话题名称,格式为带井号的标签,如"#我家主子是戏精#“、”#萌宠早餐日记#“,这种命名方式借鉴了主流社交媒体平台的话题标签规范。joined和posts分别表示话题的参与人数和累计发帖数,均采用中文计数格式(如"86.4万人”、“12.8万帖”)。
mood字段是一个独特的设计——它存储话题的情感氛围标签,如"搞笑"、“治愈”、“吐槽”、“热闹”、“涨知识”、“纪实”、“开眼”、“公益”。这种情感维度的话题分类方式,使得用户可以根据当前的心情状态选择合适的话题浏览,是内容推荐系统中情感分析的前置简化版本。color字段存储一个十六进制颜色值,如"#F97316"(橙色)、“#38BDF8”(天蓝色),这个颜色在UI中被用于话题名称的文字颜色和相关装饰元素的背景色,使得每个话题在视觉上具有独特的辨识度。
PetPost接口定义了用户帖子的数据结构。owner是帖子作者的用户名,pet字段采用"品种·昵称"的格式(如"橘猫·三斤"、“金毛·馒头”),这种将宠物品种和个性昵称组合的命名方式增加了社交趣味性。text字段是帖子正文内容,paw字段表示"爪印"数——这是该应用的点赞机制,用🐾(爪印)代替传统的👍(点赞),充分体现了萌宠主题的视觉一致性。replies是评论数,club是帖子所属的宠圈分类。
1.4 PetBreed与PetNotice接口——品种档案与通知模型
interface PetBreed {
id: number;
name: string;
origin: string;
energy: number;
price: string;
rating: string;
}
interface PetNotice {
id: number;
kind: string;
title: string;
body: string;
time: string;
fresh: string;
}

PetBreed接口定义了宠物品种档案的数据模型。其中energy字段类型为number,取值范围为0到100,表示该品种的活力指数。这是整个应用中少数使用数值类型而非字符串类型的字段之一,原因是活力指数在UI中需要渲染为进度条——进度条的宽度需要根据数值动态计算(.width(this.selBreed.energy + '%')),如果使用字符串存储则需要额外的解析步骤。price字段存储价格信息,可以是数值范围(如"1500-3500")或特殊值"领养"(表示公益领养渠道),在UI渲染时通过三元运算符进行区分显示。
PetNotice接口定义了消息通知的数据结构。kind字段表示通知类型,取值为"互动"、“关注"或"系统”。在UI渲染时,不同类型的通知使用不同颜色的头像背景——"互动"使用金色(#FBBF24)、"关注"使用蓝色(#38BDF8)、"系统"使用绿色(#34D399),这种颜色编码体系帮助用户在不阅读文字内容的情况下快速识别通知类型。fresh字段表示通知是否为未读状态,值为"1"表示未读,空字符串表示已读——这种设计避免了使用布尔类型(在ArkTS中布尔类型在某些序列化场景下可能存在兼容性问题),同时"1"作为字符串可以方便地在模板中进行非空判断。
1.5 其余接口定义
interface PetSub {
id: number;
name: string;
cycle: string;
tag: string;
tip: string;
}
interface PetQuick {
icon: string;
label: string;
color: string;
}
interface PetColumn {
id: number;
name: string;
master: string;
fee: string;
lessons: string;
}
interface PetLive {
id: number;
title: string;
host: string;
watching: string;
scene: string;
air: string;
}
interface PetComment {
id: number;
owner: string;
text: string;
paw: string;
reply: string;
rank: string;
}
interface Vote2 {
id: number;
label: string;
pct: number;
votes: string;
}

PetSub接口定义了订阅频道的数据模型,cycle字段存储更新周期描述(如"每日 8:00 更新"、“每周二更新”),tag字段是一个单字标识符(如"猫"、“汪”、“鸟”),用于在频道列表中作为头像替代文字。PetQuick接口定义了快捷入口按钮的数据,icon存储emoji图标,label存储按钮文字,color存储主题颜色——注意这个接口没有id字段,因为快捷入口是固定数量的,不需要唯一标识。
PetColumn接口定义了付费专栏课程的数据,master字段是主讲人名称,fee存储价格(如"19 宠币"),lessons存储课程讲数(如"12 讲")。PetLive接口定义了直播数据,air字段表示直播状态——“直播中"或"预告”,在UI中通过条件判断决定使用红色还是灰色背景。PetComment接口定义了评论数据,rank字段存储评论的楼层位置(如"1楼"、“3楼”)。
Vote2接口定义了投票选项的数据模型。这个接口名称中的"2"后缀可能表示这是投票功能的第二个版本迭代。pct字段类型为number,表示该选项的得票百分比,这个数值被直接用于进度条宽度渲染(.width(v.pct + '%')),与PetBreed.energy一样是少数使用数值类型的字段。
在ArkTS应用开发中,接口定义是整个架构的基石。良好的接口设计应该遵循"单一职责原则"——每个接口只描述一个业务实体的数据结构,不混合不相关的属性。本项目中的13个接口各自独立地描述了不同业务实体的数据形状,它们之间没有继承关系,保持了扁平化的类型结构,这种设计在中小型应用中是合理的,既保证了类型安全,又避免了过度抽象带来的复杂性。
二、数据常量层:静态数据源的定义
2.1 PET_NEWS与PET_HOT——新闻与热榜数据
const PET_NEWS: PetNews[] = [
{ id: 1, title: '橘猫店长上班打盹被拍,顾客排队等它"签字"', author: '街区猫闻社', topic: '猫圈头条', views: '1102万', replies: '3.6万', emoji: '🐱', badge: '爆', time: '16分钟前' },
{ id: 2, title: '金毛替主人取快递,物业专门给它留了门', author: '邻里汪事记', topic: '汪圈头条', views: '928万', replies: '2.8万', emoji: '🐕', badge: '热', time: '38分钟前' },
// ... 更多数据项
];

PET_NEWS是一个类型为PetNews[]的常量数组,使用const关键字声明,意味着这个引用在初始化后不能被重新赋值。数组中包含10条萌宠新闻数据,每条数据都是一个符合PetNews接口定义的对象字面量。在ArkTS中,当对象字面量被赋值给一个有类型注解的变量时,编译器会自动检查对象属性是否与接口定义匹配——如果缺少必需属性或属性类型不正确,编译阶段就会报错。
这些数据的内容设计值得一提——每条新闻都是一个充满趣味性的萌宠故事,从"橘猫店长签字"到"龟兔赛跑百年重赛",涵盖了猫、狗、仓鼠、鹦鹉、兔子、水獭、布偶猫、刺猬、龟等多种宠物。这种内容设计不仅是为了展示数据,更体现了应用的定位——一个以趣味萌宠内容为核心的社交平台。views字段的值从"1102万"递减到"298万",time字段从"16分钟前"递增到"14小时前",这种有序的数据排列模拟了真实信息流应用中按热度排序的效果。
const PET_HOT: PetHot[] = [
{ rank: 1, tag: '橘猫店长签字', reads: '4213万', state: '爆', rise: '↑ 142万' },
{ rank: 2, tag: '金毛取快递', reads: '3487万', state: '爆', rise: '↑ 117万' },
// ... 更多数据项
];

PET_HOT数组定义了热榜数据,与PET_NEWS数据存在内容上的关联性——热榜第一名的"橘猫店长签字"对应新闻中的第一条"橘猫店长上班打盹被拍",这种数据间的关联性在真实应用中通常通过外键或关联ID来实现,但在这个静态数据版本中,关联关系是通过内容主题的一致性来隐含表达的。rise字段的值从"↑ 142万"递减到"↑ 17万",符合热榜阅读量增长递减的规律。
2.2 PET_VIDEOS与PET_TOPICS——视频与话题数据
const PET_VIDEOS: PetVideo[] = [
{ id: 1, title: '橘猫店长的一天·营业实录', vlogger: '街区猫闻社', mins: '07:22', plays: '812万', treats: '96万', theme: '纪录' },
{ id: 2, title: '金毛取快递全程跟拍', vlogger: '邻里汪事记', mins: '05:41', plays: '684万', treats: '82万', theme: '跟拍' },
// ... 更多数据项
];

PET_VIDEOS数组包含8条视频数据,vlogger字段的值与PET_NEWS中的author字段值保持一致——“街区猫闻社"既是新闻发布者也是视频创作者,这种设计模拟了真实平台中创作者跨内容形式发布的场景。theme字段的取值包括"纪录”、“跟拍”、“搜查”、“合集”、“赛事”、“花絮”、“实测”、"夜拍"共8种类型,每种类型在视频中都有对应的视觉标识。
const PET_TOPICS: PetTopic[] = [
{ id: 1, name: '#我家主子是戏精#', joined: '86.4万人', posts: '12.8万帖', mood: '搞笑', color: '#F97316' },
{ id: 2, name: '#萌宠早餐日记#', joined: '54.2万人', posts: '8.3万帖', mood: '治愈', color: '#38BDF8' },
// ... 更多数据项
];
PET_TOPICS数组的color字段使用了多种十六进制颜色值,包括橙色(#F97316)、天蓝色(#38BDF8)、粉色(#F472B6)、翠绿色(#34D399)、金黄色(#FBBF24)、紫罗兰色(#A78BFA)、青绿色(#2DD4BF)和珊瑚红色(#FB7185)。这些颜色在UI渲染时被用于话题标题的文字颜色和相关装饰元素的背景色,使得每个话题卡片在视觉上具有独特的色彩标识。mood字段的值与话题内容紧密相关——“#我家主子是戏精#“对应"搞笑”、”#萌宠早餐日记#“对应"治愈”、"#拆家现场展览#“对应"吐槽”,这种情感标签帮助用户快速筛选符合当前心情的话题内容。
2.3 其他数据常量
const PET_POSTS: PetPost[] = [
{ id: 1, owner: '鱼干他娘', pet: '橘猫·三斤', text: '今天它把逗猫棒藏进了我鞋里,谁教它的', paw: '6.2万', replies: '1841', club: '晒宠区' },
// ... 更多数据项
];
const PET_BREEDS: PetBreed[] = [
{ id: 1, name: '橘猫', origin: '中华田园', energy: 62, price: '领养', rating: '5.0' },
{ id: 2, name: '金毛寻回犬', origin: '苏格兰', energy: 88, price: '1500-3500', rating: '4.9' },
// ... 更多数据项
];
PET_POSTS数组中的owner字段值与PET_NOTICES和PET_COMMENTS中的owner字段值保持一致,构建了一个贯穿多个数据集的用户身份体系。“鱼干他娘”、“快递站小李”、"囤囤鼠家"等用户名既出现在帖子列表中,也出现在消息通知和评论列表中,这种数据一致性模拟了真实社交平台中用户跨场景活跃的行为模式。
PET_BREEDS数组中的energy字段是整个应用中唯一用于进度条渲染的数值字段。橘猫的活力值为62、金毛为88、布偶猫为45、柯基为90、仓鼠为70、玄凤鹦鹉为76,这些数值大致反映了各品种的真实活跃程度特征。price字段中,橘猫的值为"领养",在UI渲染时通过条件判断显示为"¥ 领养 · 公益渠道",其余品种显示为"¥ "加价格范围。
2.4 PET_NOTICES与PET_COMMENTS——通知与评论数据
const PET_NOTICES: PetNotice[] = [
{ id: 1, kind: '互动', title: '鱼干他娘 回复了你', body: '"它把逗猫棒藏鞋里这招跟馒头学的"', time: '6分钟前', fresh: '1' },
{ id: 2, kind: '关注', title: '啮齿研究所 更新了图鉴', body: '《仓鼠藏粮点全景图 3.0 版》已发布', time: '25分钟前', fresh: '1' },
// ... 更多数据项
];
PET_NOTICES数组中的fresh字段采用了"1"和空字符串的二值设计。前两条通知的fresh值为"1"(未读),后四条为空字符串(已读),这种2:4的未读已读比例在UI中表现为前两条通知使用更亮的背景色(rgba(249,115,22,0.10))和红色未读标记,后四条使用较暗的背景色(rgba(122,98,72,0.08)),这种视觉差异引导用户优先关注未读通知。
const PET_COMMENTS: PetComment[] = [
{ id: 1, owner: '鱼干他娘', text: '店长的"签字"其实是盖爪印,超正式', paw: '2.6万', reply: '486', rank: '1楼' },
// ... 更多数据项
];
PET_COMMENTS数组的rank字段从"1楼"到"20楼",虽然数组只有8条数据,但楼层编号并不连续(1楼、3楼、5楼、8楼、11楼、14楼、17楼、20楼),这种设计模拟了真实评论区中部分楼层被折叠或删除后的显示效果,增加了数据的真实感。
三、辅助函数层:数据访问的封装与抽象
3.1 分片获取函数——列表分列渲染的数据支撑
function getNewsLeft(): PetNews[] {
return PET_NEWS.slice(0, 5);
}
function getNewsRight(): PetNews[] {
return PET_NEWS.slice(5, 10);
}
function getNewsCount(): number {
return PET_NEWS.length;
}
这三个函数构成了新闻数据的访问接口层。getNewsLeft()返回PET_NEWS数组的前5条数据,getNewsRight()返回后5条数据。这种将数据对半分割的设计是为了支持UI中的双列瀑布流布局——在新闻列表区域,左列使用getNewsLeft()的数据渲染,右列使用getNewsRight()的数据渲染,两列独立滚动,形成视觉上的错落感。
getNewsCount()函数返回数组的长度,这个值在UI中被用于显示列表标题中的计数信息,如"萌闻流 (10)"。将数组长度封装为函数而非直接访问PET_NEWS.length,是为了在数据源发生变化时(例如未来从网络接口获取数据),只需要修改函数内部的实现逻辑,而不需要修改所有引用了计数信息的地方——这是一种简单的依赖反转设计。
在ArkTS中,函数声明使用
function关键字,参数和返回值都需要类型注解。这种强类型的函数签名使得编译器能够在调用点检查参数类型和返回值的使用方式,有效防止了类型不匹配导致的运行时错误。本项目中的辅助函数虽然逻辑简单,但每个函数都有明确的类型签名,这是良好工程实践的体现。
3.2 其他分片函数模式
function getHotTop(): PetHot[] {
return PET_HOT.slice(0, 3);
}
function getHotRest(): PetHot[] {
return PET_HOT.slice(3, 10);
}
function getVideoLeft(): PetVideo[] {
return PET_VIDEOS.slice(0, 4);
}
function getVideoRight(): PetVideo[] {
return PET_VIDEOS.slice(4, 8);
}
getHotTop()和getHotRest()函数将热榜数据分为"前三名"和"其余"两部分,这种分片方式与UI中的差异化展示需求对应——前三名热搜使用更大的字号、更亮的颜色和排名图标进行高亮展示,而第四到第十名使用普通的列表样式。getVideoLeft()和getVideoRight()则将视频数据分为左右两列,与新闻数据的分片逻辑一致。
整个应用中遵循着一套统一的分片函数命名规范:getXxxLeft()获取前半部分数据,getXxxRight()获取后半部分数据,getXxxCount()获取数据总数,getXxxTop()获取排名靠前的数据子集,getXxxRest()获取排名靠后的数据子集。这种一致的命名规范使得代码具有高度的可预测性——当看到getBreedLeft()函数名时,开发者可以立即推断出它的功能和返回类型。
3.3 查找函数——按属性检索数据
function findNewsByTitle(title: string): PetNews {
for (let i = 0; i < PET_NEWS.length; i++) {
if (PET_NEWS[i].title === title) {
return PET_NEWS[i];
}
}
return PET_NEWS[0];
}
findNewsByTitle()函数通过title属性在PET_NEWS数组中线性查找匹配的新闻项。这里使用了最基础的for循环进行遍历——在数据量较小(10条)的情况下,线性查找的性能完全足够,没有必要使用更复杂的数据结构如Map或索引。当未找到匹配项时,函数返回数组的第一条数据作为默认值,这种"安全降级"策略确保了即使传入不存在的标题,UI也能正常显示内容而不会出现空指针异常。
function findHotByTag(tag: string): PetHot {
for (let i = 0; i < PET_HOT.length; i++) {
if (PET_HOT[i].tag === tag) {
return PET_HOT[i];
}
}
return PET_HOT[0];
}
function findVideoByTitle(title: string): PetVideo {
for (let i = 0; i < PET_VIDEOS.length; i++) {
if (PET_VIDEOS[i].title === title) {
return PET_VIDEOS[i];
}
}
return PET_VIDEOS[0];
}
这些查找函数遵循完全相同的模式——遍历数组、比较属性值、返回匹配项或默认值。不同函数使用不同的查找属性:findNewsByTitle按标题查找、findHotByTag按标签查找、findVideoByTitle按标题查找、findTopicByName按话题名称查找、findPostByOwner按作者查找、findBreedByName按品种名查找。这些查找函数是连接UI层和数据层的关键桥梁——当用户在新闻列表中点击某条新闻时,UI层会将新闻标题传递给findNewsByTitle()函数,函数返回完整的PetNews对象,然后该对象被赋值给@State变量selNews,驱动弹窗内容的更新。
3.4 全量获取函数
function getCommentList(): PetComment[] {
return PET_COMMENTS;
}
function getVoteList(): Vote2[] {
return PET_VOTE;
}
与分片函数不同,getCommentList()和getVoteList()直接返回完整的数组引用,不做任何分片处理。这是因为评论列表和投票选项在UI中需要一次性全部展示,不需要分列或分页。虽然这两个函数看起来只是对数组引用的直接返回,但将它们封装为函数仍然有其价值——它统一了数据访问的接口风格,使得所有数据获取都通过函数调用完成,未来如果需要在返回数据前进行过滤或排序,只需要修改函数内部逻辑即可。
四、主组件架构:Index入口组件
4.1 组件声明与状态管理
@Entry
@Component
struct Index {
@State tabIdx: number = 0;
@State showNews: boolean = false;
@State showReply: boolean = false;
@State showPost: boolean = false;
@State showTopicCard: boolean = false;
@State showVideo: boolean = false;
@State showBreed: boolean = false;
@State showVote: boolean = false;
@State showAdopt: boolean = false;
@State showNotice: boolean = false;
@State showSub: boolean = false;
@State showColumn: boolean = false;
@State showLive: boolean = false;
@State showVip: boolean = false;
@State showTreat: boolean = false;
@State showShare: boolean = false;
@State showSettings: boolean = false;
@Entry装饰器标记Index为应用的入口组件——每个鸿蒙应用有且只有一个入口组件,它对应着应用的第一个页面。@Component装饰器声明Index为一个自定义组件,使其可以被其他组件引用或在build()方法中渲染。在ArkTS中,struct关键字用于声明组件结构体,它与TypeScript中的struct不同——ArkTS的struct是一种特殊的类,专门用于UI组件声明。
@State装饰器是ArkTS状态管理系统的核心。被@State标记的变量成为"状态变量"——当它们的值发生变化时,ArkUI框架会自动检测到变化,并重新执行build()方法或相关的@Builder方法来更新UI。在这个应用中,tabIdx变量控制当前显示的标签页(0=萌闻、1=视频、2=话题、3=宠圈、4=我的),当用户点击底部导航栏切换标签页时,tabIdx的值改变,触发build()方法重新执行,if-else条件判断链根据新的tabIdx值渲染对应的标签页组件。
16个showXxx布尔变量控制着16个弹窗的显示与隐藏。这种"每个弹窗一个布尔状态"的设计虽然在状态变量数量上看起来较多,但它的优势在于每个弹窗的状态独立可控——可以同时只有一个弹窗显示,也可以通过编程方式同时关闭所有弹窗(将所有变量设为false)。在UI渲染层面,这些布尔变量通过if (this.showNews) { this.newsModal() }的方式条件渲染弹窗内容。
ArkTS的状态管理采用了"单一数据源"原则——组件的所有可变状态都应该通过
@State、@Prop、@Link等装饰器声明,而不应该使用全局变量或外部可变状态。这种设计确保了状态变化可以被框架追踪,从而精确地触发UI更新。在本应用中,Index组件管理了33个状态变量,这在复杂应用中是正常的——随着功能增多,状态数量会相应增长。
4.2 选中项状态变量
@State selNews: PetNews = PET_NEWS[0];
@State selHot: PetHot = PET_HOT[0];
@State selVideo: PetVideo = PET_VIDEOS[0];
@State selTopic: PetTopic = PET_TOPICS[0];
@State selPost: PetPost = PET_POSTS[0];
@State selBreed: PetBreed = PET_BREEDS[0];
@State selNotice: PetNotice = PET_NOTICES[0];
@State selSub: PetSub = PET_SUBS[0];
@State selColumn: PetColumn = PET_COLUMNS[0];
@State selLive: PetLive = PET_LIVES[0];
@State petCoins: number = 1560;
@State treatNum: number = 12;
10个selXxx变量存储当前选中的数据项,它们在初始化时都被赋值为对应数组的第一个元素。这些变量的作用是传递用户选择到弹窗——当用户在列表中点击某个新闻项时,对应的selNews变量被更新为该新闻的完整数据对象,然后showNews被设为true触发弹窗显示,弹窗内的UI通过this.selNews.title、this.selNews.emoji等方式引用选中项的数据来渲染内容。
petCoins变量是应用的虚拟货币系统——用户的宠币余额。这个变量在多个交互场景中被修改:发帖时增加20宠币(this.petCoins = this.petCoins + 20)、投票时增加5宠币、订阅专栏时减少19宠币、开通会员时减少6宠币、投喂时根据食物类型减少2到12宠币。由于petCoins是@State变量,每次修改都会触发UI更新——顶部导航栏中显示的"宠币 1560"会实时反映最新的余额值。
treatNum变量记录用户当天的投喂剩余次数,初始值为12。每次投喂操作都会使其减1(this.treatNum = this.treatNum - 1),这种次数限制机制是游戏化设计中常见的"每日任务"模式,用于控制用户的互动频率并制造稀缺感。
4.3 modalOverlay——通用遮罩构建器
@Builder
modalOverlay(onClose: () => void) {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('rgba(20,10,4,0.68)')
.onClick(() => {
onClose();
})
}
modalOverlay是一个通用的弹窗遮罩层构建器,它接收一个onClose回调函数作为参数。构建器内部声明了一个空的Column容器,设置了全屏宽高和半透明的深色背景(rgba(20,10,4,0.68)),并绑定了点击事件——点击遮罩区域时调用onClose回调关闭弹窗。
@Builder装饰器是ArkTS中实现UI逻辑复用的核心机制。与普通的组件方法不同,@Builder方法中可以包含ArkTS的声明式UI代码(如Column()、Text()等组件声明),这些代码在@Builder方法被调用时会被展开到调用位置。@Builder方法可以接收参数,这使得它能够根据不同的参数生成不同的UI内容——modalOverlay通过onClose参数实现了"同一个遮罩组件,不同的关闭逻辑"的复用。
这个遮罩构建器的背景色使用了rgba格式,其中RGB值为(20,10,4)——这是一种非常深的棕黑色,透明度为0.68。这种颜色选择与应用整体的暖色调暗色主题保持一致,而不是使用常见的纯黑色半透明遮罩,体现了设计上的细节考量。
4.4 build()方法——主界面布局架构
build() {
Column() {
Column() {
Row() {
Column() {
Text('🐾 萌宠圈')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
Text('PET BUZZ · 今日萌指数 98')
.fontSize(9)
.fontColor('#C89A6B')
.letterSpacing(1)
}
.alignItems(HorizontalAlign.Start)
build()方法是每个ArkTS组件的核心——它返回组件的UI结构。Index组件的build()方法最外层是一个Column容器,它将整个界面分为三个垂直排列的区域:顶部导航栏、中间内容滚动区、底部标签栏。
顶部导航栏本身也是一个Column容器,内部包含两个Row。第一个Row是主导航行,包含三个元素:左侧的应用标识区(一个Column包含应用名称"🐾 萌宠圈"和副标题"PET BUZZ · 今日萌指数 98")、中间的搜索框(一个Row包含搜索提示文字"🔍 搜萌闻·话题·品种")、右侧的投喂入口(一个emoji文字"🍖")。
应用标识区使用了alignItems(HorizontalAlign.Start)设置子元素左对齐——HorizontalAlign.Start是ArkTS中对齐枚举之一,表示在水平方向上靠起始边(左侧)对齐。主标题"🐾 萌宠圈"的字号为20、字重为粗体(FontWeight.Bold)、颜色为暖白色(#FFF7E8)。副标题的字号为9——在ArkTS中,fontSize的单位是vp(virtual pixels,虚拟像素),这是一种与设备密度无关的长度单位,9vp在大多数设备上显示为非常小的文字,适用于辅助说明信息。letterSpacing(1)设置了1vp的字符间距,这种微小的间距增加了英文字母的可读性。
Row() {
Text('🔍 搜萌闻·话题·品种')
.fontSize(11)
.fontColor('#B08A5F')
}
.layoutWeight(1)
.height(32)
.borderRadius(16)
.backgroundColor('rgba(249,115,22,0.16)')
.margin({ left: 12, right: 12 })
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showSettings = true;
})
搜索框区域使用了layoutWeight(1)——这是ArkTS中Flex布局的关键属性,它类似于CSS中的flex-grow。在一个Row容器中,如果某个子元素设置了layoutWeight(1),它会占据容器中剩余的所有可用空间。这里搜索框设置了layoutWeight(1),使得它在左侧应用标识和右侧投喂按钮之间自动扩展,填满中间区域。
搜索框的高度固定为32vp,圆角为16vp(正好是高度的一半,形成胶囊形状),背景色为半透明橙色(rgba(249,115,22,0.16))。justifyContent(FlexAlign.Center)使搜索提示文字在水平方向居中显示。点击搜索框会打开设置弹窗(this.showSettings = true),这种将搜索入口与设置页面关联的设计在实际应用中可能是临时方案。
Row() {
Text('今日话题 #我家主子是戏精# 12.8万帖')
.fontSize(10)
.fontColor('#FBBF24')
Row() {
}
.layoutWeight(1)
Text('宠币 ' + this.petCoins)
.fontSize(10)
.fontColor('#F97316')
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 8 })
顶部导航栏的第二个Row是状态信息行,包含三个元素:左侧的话题推广文字(“今日话题 #我家主子是戏精# 12.8万帖”)、中间的弹性间隔(一个设置了layoutWeight(1)的空Row,起到spacer的作用,将左右两侧的内容推向两端)、右侧的宠币余额显示。this.petCoins是一个@State变量,当其值变化时,这个Text组件的内容会自动更新——这就是ArkTS声明式UI的核心机制:UI是状态的函数映射,状态变化自动反映到UI上。
.width('100%')
.linearGradient({
angle: 200,
colors: [['#2A1608', 0.0], ['#3D1F0B', 0.6], ['#F97316', 1.0]]
})
.borderRadius({ bottomLeft: 20, bottomRight: 20 })
顶部导航栏使用了linearGradient线性渐变背景——从角度200度方向开始,颜色从深棕色(#2A1608,位置0.0)过渡到中棕色(#3D1F0B,位置0.6)再到橙色(#F97316,位置1.0)。这种从深到浅的渐变设计营造了一种"从暗色背景中透出温暖光芒"的视觉效果。底部圆角为20vp,使得导航栏底部边缘呈圆弧状,与下方内容区域形成柔和的视觉过渡。
4.5 内容区与标签页切换
Column() {
Scroll() {
Column() {
if (this.tabIdx === 0) {
NewsTab({
onNews: (title: string) => {
this.selNews = findNewsByTitle(title);
this.showNews = true;
},
onQuick: (i: number) => {
if (i === 0) {
this.showTreat = true;
} else if (i === 1) {
this.showBreed = true;
this.selBreed = PET_BREEDS[0];
} else if (i === 2) {
this.showNotice = true;
this.selNotice = PET_NOTICES[0];
}
// ... 更多条件分支
},
onPost: (owner: string) => {
this.selPost = findPostByOwner(owner);
this.showPost = true;
},
onSub: (name: string) => {
this.selSub = findSubByName(name);
this.showSub = true;
}
})
} else if (this.tabIdx === 1) {
VideoTabPet({...})
} else if (this.tabIdx === 2) {
TopicTabPet({...})
} else if (this.tabIdx === 3) {
ClubTab({...})
} else {
MineTabPet({...})
}
}
内容区域的核心逻辑是标签页的条件渲染。if (this.tabIdx === 0)到else的条件链根据tabIdx的值决定渲染哪个标签页组件。这种条件渲染机制是ArkTS处理多标签页的标准方式——当tabIdx变化时,旧标签页组件被销毁,新标签页组件被创建。
每个标签页组件在声明时都接收多个回调函数作为参数。以NewsTab为例,它接收四个回调:onNews(点击新闻项时调用)、onQuick(点击快捷入口时调用)、onPost(点击帖子时调用)、onSub(点击订阅频道时调用)。这些回调函数在Index组件内部定义,通过闭包捕获this引用,从而能够修改Index组件的状态变量。
onNews回调的实现展示了数据流动的完整路径:用户在NewsTab组件中点击某个新闻项 → NewsTab调用this.onNews(n.title) → 回调函数执行this.selNews = findNewsByTitle(title)查找完整新闻数据 → 执行this.showNews = true触发弹窗显示 → newsModal构建器读取this.selNews的数据渲染弹窗内容。这种通过回调函数实现父子组件通信的模式是ArkTS组件化开发的核心范式。
onQuick回调使用if-else链根据快捷入口的索引值决定打开哪个弹窗。索引0打开投喂弹窗、索引1打开品种档案弹窗、索引2打开消息通知弹窗、索引3打开专栏弹窗、索引4打开视频弹窗、索引5打开发帖弹窗、索引6打开领养弹窗、索引7(else分支)默认打开投喂弹窗。这种基于索引的路由逻辑虽然简单,但在快捷入口数量固定且数量较少的场景下是合理的选择。
在ArkTS中,组件间的数据传递遵循"单向数据流"原则——父组件通过属性将数据传递给子组件,子组件通过回调函数将事件通知给父组件。这种模式确保了数据流动的可预测性,使得应用状态的变化路径始终清晰可追踪。本应用中所有标签页组件都严格遵循了这一原则,不直接修改父组件状态,而是通过回调触发父组件的状态更新。
4.6 底部导航栏
Row() {
this.bottomTabItem('📰', '萌闻', 0)
this.bottomTabItem('🎥', '视频', 1)
this.bottomTabItem('💬', '话题', 2)
this.bottomTabItem('🐾', '宠圈', 3)
this.bottomTabItem('👤', '我的', 4)
}
.width('100%')
.height(56)
.backgroundColor('rgba(31,18,10,0.98)')
.borderRadius({ topLeft: 18, topRight: 18 })
底部导航栏是一个Row容器,包含5个bottomTabItem构建器调用。每个导航项由emoji图标和文字标签组成,分别对应5个标签页。导航栏的高度为56vp,背景色为几乎不透明的深棕色(rgba(31,18,10,0.98)),顶部圆角为18vp,与顶部导航栏的底部圆角形成视觉对称。
4.7 bottomTabItem——导航项构建器
@Builder
bottomTabItem(icon: string, label: string, idx: number) {
Column({ space: 3 }) {
Text(icon)
.fontSize(20)
.scale(this.tabIdx === idx ? { x: 1.22, y: 1.22 } : { x: 1, y: 1 })
.animation({ duration: 220, curve: Curve.EaseOut })
Text(label)
.fontSize(10)
.fontColor(this.tabIdx === idx ? '#F97316' : '#7A6248')
.fontWeight(this.tabIdx === idx ? FontWeight.Bold : FontWeight.Normal)
}
.width('20%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.tabIdx = idx;
})
}
bottomTabItem构建器接收三个参数:icon(emoji图标)、label(文字标签)、idx(标签页索引)。它使用Column({ space: 3 })容器将图标和标签垂直排列,间距为3vp。每个导航项的宽度为’20%'——5个导航项各占20%宽度,恰好填满整个导航栏。
这个构建器中最值得关注的是选中状态的视觉反馈设计。当this.tabIdx === idx(当前标签页等于该导航项的索引)时:图标使用scale({ x: 1.22, y: 1.22 })放大1.22倍,标签文字颜色变为橙色(#F97316),字重变为粗体。animation({ duration: 220, curve: Curve.EaseOut })为缩放变化添加了220毫秒的缓出动画——Curve.EaseOut是ArkTS提供的动画曲线之一,它在动画开始时快速变化,结束时缓慢减速,产生自然的过渡效果。
点击导航项时执行this.tabIdx = idx——由于tabIdx是@State变量,这个赋值操作会触发整个build()方法重新执行,旧的标签页组件被销毁,新的标签页组件被创建并渲染。同时,bottomTabItem构建器也会重新执行,根据新的tabIdx值更新选中状态的视觉表现。
五、弹窗系统:模态对话框的设计与实现
5.1 newsModal——萌闻详情弹窗
@Builder
newsModal() {
Column() {
Column({ space: 12 }) {
Row() {
Column({ space: 4 }) {
Text('🐾 萌闻详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
Text('PET BUZZ · 精选资讯')
.fontSize(9)
.fontColor('#C89A6B')
.letterSpacing(1)
}
.alignItems(HorizontalAlign.Start)
Row() {
Text('×')
.fontSize(20)
.fontColor('#7A6248')
}
.width(30)
.height(30)
.borderRadius(15)
.backgroundColor('rgba(249,115,22,0.15)')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showNews = false;
})
newsModal构建器定义了萌闻详情弹窗的UI结构。弹窗的最外层是一个全屏Column,设置了半透明深色背景遮罩(rgba(20,10,4,0.72))。内部是一个宽度为86%的内容卡片,使用justifyContent(FlexAlign.Center)在遮罩层中居中显示。
弹窗的头部区域使用Row容器水平排列标题区和关闭按钮。标题区是一个Column,包含主标题"🐾 萌闻详情"和副标题"PET BUZZ · 精选资讯"。关闭按钮是一个30x30vp的圆形按钮(borderRadius(15)),背景色为半透明橙色,点击时设置this.showNews = false关闭弹窗。
Column({ space: 12 })中的space参数设置了子元素之间的间距为12vp——这是ArkTS中Column和Row容器特有的属性,它为容器内所有子元素之间自动添加指定的间距,无需在每个子元素上单独设置margin。
Row({ space: 10 }) {
Text(this.selNews.emoji)
.fontSize(34)
Column({ space: 4 }) {
Row({ space: 6 }) {
if (this.selNews.badge !== '') {
Text(this.selNews.badge)
.fontSize(9)
.fontColor('#FFF7E8')
.backgroundColor('#F97316')
.borderRadius(4)
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
}
Text(this.selNews.topic)
.fontSize(10)
.fontColor('#FBBF24')
}
Text(this.selNews.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
.lineHeight(20)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
弹窗的内容区域展示了选中新闻的详细信息。左侧是一个34vp大小的emoji图标,右侧是一个Column包含新闻的标签和标题。标签区域使用了条件渲染——if (this.selNews.badge !== '')判断badge是否为空字符串,如果不为空则渲染一个带橙色背景的标签组件。这种条件渲染是ArkTS中处理可选UI元素的常用模式。
lineHeight(20)设置了标题文本的行高为20vp——在ArkTS中,lineHeight属性控制多行文本的行间距,适当的行高可以提高长文本的可读性。标题的字号为14vp,字重为粗体,颜色为暖白色,这些样式参数共同确保了标题在视觉上的突出性。
Row({ space: 8 }) {
Column({ space: 3 }) {
Text(this.selNews.views)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#F97316')
Text('阅读')
.fontSize(9)
.fontColor('#7A6248')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor('rgba(249,115,22,0.10)')
弹窗的统计数据区域使用Row({ space: 8 })水平排列三个统计卡片:阅读量、跟帖数、萌指数。每个卡片都是一个Column,上部显示数值(15vp字号、粗体),下部显示标签文字(9vp字号)。三个卡片使用layoutWeight(1)等分宽度,各自使用不同色调的半透明背景——阅读量用橙色(rgba(249,115,22,0.10))、跟帖数用蓝色(rgba(56,189,248,0.10))、萌指数用绿色(rgba(52,211,153,0.10)),这种颜色编码体系让用户可以通过颜色快速区分不同维度的数据。
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
.onClick(() => {
this.showNews = false;
})
弹窗最外层Column设置了transition(TransitionEffect.OPACITY.animation({ duration: 200 }))——这是ArkTS的过渡动画API。TransitionEffect.OPACITY表示使用透明度变化作为过渡效果,动画时长为200毫秒。当弹窗通过if (this.showNews)条件渲染被添加到UI树时,它会从透明渐变为不透明(淡入效果);当被从UI树中移除时,它从透明渐变为不透明再消失(淡出效果)。这种过渡动画使弹窗的出现和消失更加自然,避免了突兀的显示/隐藏切换。
点击弹窗遮罩区域时设置this.showNews = false关闭弹窗——这是模态弹窗的标准交互模式,允许用户通过点击弹窗外部区域来关闭弹窗。
5.2 replyModal——跟帖评论弹窗
@Builder
replyModal() {
Column() {
Column() {
Row() {
Text('💬 热门跟帖 (' + getCommentCount() + ')')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
Text('收起')
.fontSize(11)
.fontColor('#F97316')
.onClick(() => {
this.showReply = false;
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
replyModal构建器定义了评论列表弹窗。与newsModal不同,这个弹窗从底部滑入(通过justifyContent(FlexAlign.End)将内容推到屏幕底部),模拟了移动端应用中常见的底部弹窗交互模式。弹窗头部使用FlexAlign.SpaceBetween将标题和"收起"按钮分别推到两端——FlexAlign.SpaceBetween是justifyContent的一个枚举值,表示子元素之间均匀分布间距,首尾元素紧贴容器边缘。
Scroll() {
Column({ space: 10 }) {
ForEach(getCommentList(), (c: PetComment) => {
Row({ space: 10 }) {
Column() {
Text(c.owner.substring(0, 1))
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.width(34)
.height(34)
.borderRadius(17)
.backgroundColor('#FBBF24')
.justifyContent(FlexAlign.Center)
评论列表使用了Scroll容器包裹ForEach渲染的评论项。ForEach(getCommentList(), (c: PetComment) => {...})遍历评论数据数组,为每条评论生成一个Row容器。每个评论项的左侧是一个34x34vp的圆形头像——使用c.owner.substring(0, 1)提取用户名的第一个字符作为头像文字,背景色为金色(#FBBF24),这种"首字母头像"设计在用户未上传头像时提供了合理的替代方案。
substring(0, 1)是JavaScript/TypeScript标准字符串方法,在ArkTS中同样可用。它从字符串中提取从索引0开始长度为1的子串——即第一个字符。这种方法生成的是单字符头像,适用于中文用户名(取第一个汉字)和英文用户名(取第一个字母)。
Column({ space: 5 }) {
Row({ space: 6 }) {
Text(c.owner)
.fontSize(12)
.fontColor('#FBBF24')
Text(c.rank)
.fontSize(9)
.fontColor('#7A6248')
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.borderRadius(6)
.backgroundColor('rgba(122,98,72,0.18)')
}
Text(c.text)
.fontSize(12)
.fontColor('#E8D5BC')
.lineHeight(17)
Row({ space: 12 }) {
Text('🐾 ' + c.paw)
.fontSize(10)
.fontColor('#F97316')
Text('↩ ' + c.reply)
.fontSize(10)
.fontColor('#7A6248')
}
}
评论项的右侧是一个Column,包含三行内容:用户名和楼层信息、评论文本、爪印数和回复数。用户名使用金色(#FBBF24),楼层标签使用灰棕色背景的小标签。评论文本使用浅暖色(#E8D5BC),行高为17vp,确保多行评论的可读性。底部的爪印数使用橙色,回复数使用灰棕色,两种互动数据通过颜色区分。
Row({ space: 8 }) {
Row() {
Text('说点萌的…')
.fontSize(11)
.fontColor('#7A6248')
}
.layoutWeight(1)
.height(38)
.borderRadius(19)
.backgroundColor('rgba(122,98,72,0.15)')
.justifyContent(FlexAlign.Center)
Row() {
Text('发送')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.width(72)
.height(38)
.borderRadius(19)
.backgroundColor('#FBBF24')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showReply = false;
})
}
弹窗底部是评论输入区域,包含一个占位文本"说点萌的…"和一个"发送"按钮。输入区域使用layoutWeight(1)占据剩余空间,发送按钮宽度固定为72vp。两者高度均为38vp,圆角为19vp(高度的一半),形成胶囊形状。点击发送按钮关闭弹窗——在真实应用中,这里应该包含发送评论的网络请求逻辑。
5.3 postModal——发帖弹窗
@Builder
postModal() {
Column() {
Column() {
Column() {
Text('✍️')
.fontSize(18)
Text('发帖晒宠')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.width('100%')
.padding({ top: 14, bottom: 14 })
.borderRadius({ topLeft: 22, topRight: 22 })
.backgroundColor('#FBBF24')
.justifyContent(FlexAlign.Center)
postModal构建器定义了发帖弹窗。弹窗顶部是一个金色背景的标题栏,使用justifyContent(FlexAlign.Center)将✍️图标和"发帖晒宠"文字在水平方向居中排列。标题栏的文字颜色为深棕色(#180E06),与金色背景形成高对比度,确保可读性。
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('🐾')
.fontSize(14)
Text('宠物昵称')
.fontSize(11)
.fontColor('#C89A6B')
Text('橘猫·三斤')
.fontSize(11)
.fontColor('#FFF7E8')
}
.width('100%')
.height(44)
.borderRadius(12)
.backgroundColor('rgba(249,115,22,0.10)')
.justifyContent(FlexAlign.Center)
弹窗内容区包含宠物信息行、内容输入区、工具栏、话题标签选择和操作按钮。宠物信息行显示当前选中的宠物(🐾图标 + "宠物昵称"标签 + "橘猫·三斤"实际值),使用半透明橙色背景。内容输入区显示提示文字"今天的主子做了什么萌事?“和字数统计"已输入 128 字 · 建议配上 3 张萌照”。
Row({ space: 10 }) {
Row() {
Text('存草稿')
.fontSize(12)
.fontColor('#C89A6B')
}
.layoutWeight(1)
.height(40)
.borderRadius(20)
.backgroundColor('rgba(122,98,72,0.15)')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showPost = false;
})
Row() {
Text('🐾 发布')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.layoutWeight(1.6)
.height(40)
.borderRadius(20)
.backgroundColor('#F97316')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showPost = false;
this.petCoins = this.petCoins + 20;
})
}
操作按钮区域使用Row({ space: 10 })水平排列"存草稿"和"🐾 发布"两个按钮。两个按钮使用不同的layoutWeight值——"存草稿"为1,"发布"为1.6,使得发布按钮在视觉上更宽更突出。这种通过layoutWeight比例分配宽度的设计是ArkTS Flex布局的常见用法。
点击"发布"按钮时执行两个操作:this.showPost = false关闭弹窗,this.petCoins = this.petCoins + 20增加20宠币作为发帖奖励。由于petCoins是@State变量,这个增量操作会触发顶部导航栏中宠币余额显示的自动更新——用户发布帖子后立即看到宠币增加,这种即时反馈增强了用户的互动激励。
在ArkTS中,
@State变量的修改不仅会触发当前组件的UI更新,还会通过@Prop、@Link等装饰器传递给子组件。本应用中petCoins的修改虽然只发生在Index组件内部,但由于该值在Index的build()方法中被直接引用(顶部导航栏的"宠币 1560"显示),因此修改后顶部导航栏会自动更新显示新的余额值。
5.4 其他弹窗构建器概览
@Builder
topicCardModal() {
// ... 话题详情弹窗,展示话题参与人数、累计发帖、话题动态
}
@Builder
videoModal() {
// ... 视频详情弹窗,展示视频封面、播放信息、投喂/弹幕/分享按钮
}
@Builder
breedModal() {
// ... 品种档案弹窗,展示品种名称、产地、活力指数进度条、参考价格
}
@Builder
voteModal() {
// ... 投票弹窗,展示萌王争霸候选列表及投票进度条
}
@Builder
adoptModal() {
// ... 领养申请弹窗,展示申请表单包含申请人、城市、居住环境、养宠经验
}
应用共包含16个弹窗构建器,每个都遵循相似的结构模式:全屏Column遮罩层 + 居中或底部的内容卡片 + transition过渡动画 + 点击遮罩关闭。不同弹窗的差异主要在内容卡片的布局和数据展示方式上。
breedModal中的活力指数进度条是一个值得关注的UI实现。进度条由两层Row组成:外层Row作为背景轨道(宽度100%、高度8vp、圆角4vp、灰棕色背景),内层Row作为填充条(宽度为this.selBreed.energy + '%'、高度8vp、圆角4vp、橙色背景)。通过将数值(如62)与百分号拼接为字符串"62%"作为宽度值,实现了进度条宽度与数据的动态绑定。
@Builder
treatModal() {
Column() {
Column({ space: 12 }) {
Row() {
Column({ space: 3 }) {
Text('🍖 投喂小站')
.fontSize(15)
.fontWeight(FontWeight.Bold)
Text('今日剩余投喂次数 ' + this.treatNum)
.fontSize(9)
.fontColor('#7C4A12')
}
treatModal是投喂弹窗,它的整体背景色为浅黄色(#FDE68A),与其他弹窗的深色背景(#241408)形成鲜明对比。这种色彩差异设计是因为投喂功能具有"奖励"属性——明亮的黄色背景在视觉上传递了积极、愉悦的情感。弹窗中展示了三种投喂食物:肉干(2币)、小鱼干(5币)、罐头(12币),每种食物点击后都会减少投喂次数和宠币余额。
5.5 voteModal——投票弹窗中的ForEach与条件渲染
@Builder
voteModal() {
Column() {
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('🏆')
.fontSize(20)
Text('本周萌王争霸')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
}
Text('选出你心中的年度萌王 · 已 ' + getVoteCount() + ' 组候选')
.fontSize(10)
.fontColor('#C89A6B')
Column({ space: 10 }) {
ForEach(getVoteList(), (v: Vote2, idx: number) => {
Column({ space: 6 }) {
Row() {
Row({ space: 6 }) {
if (idx === 0) {
Text('👑')
.fontSize(11)
} else {
Text((idx + 1) + '')
.fontSize(10)
.fontColor('#7A6248')
}
Text(v.label)
.fontSize(12)
.fontWeight(idx === 0 ? FontWeight.Bold : FontWeight.Normal)
.fontColor('#FFF7E8')
}
Text(v.pct + '% · ' + v.votes)
.fontSize(10)
.fontColor('#F97316')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
Row() {
}
.width(v.pct + '%')
.height(6)
.borderRadius(3)
.backgroundColor(idx === 0 ? '#FBBF24' : '#F97316')
}
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor('rgba(122,98,72,0.20)')
}
.width('100%')
.padding(10)
.borderRadius(12)
.backgroundColor(idx === 0 ? 'rgba(251,191,36,0.10)' : 'rgba(249,115,22,0.06)')
})
}
voteModal中的ForEach使用了第二个参数idx——这是ForEach的索引参数,表示当前数据项在数组中的位置。通过idx === 0判断是否为第一个(即得票最高的候选),第一个候选使用👑皇冠图标,其余使用数字序号(idx + 1)。这种基于索引的差异化渲染在排行榜类UI中非常常见。
进度条的颜色也基于索引进行条件设置:第一个候选的进度条使用金色(#FBBF24),其余使用橙色(#F97316)。整个候选项卡片的背景色也根据索引不同而变化——第一个使用金色半透明背景(rgba(251,191,36,0.10)),其余使用橙色半透明背景(rgba(249,115,22,0.06))。这种多维度的视觉差异化确保了排名第一的候选在视觉上显著突出。
5.6 noticeModal——消息通知中的动态颜色
Column({ space: 10 }) {
ForEach(PET_NOTICES, (n: PetNotice) => {
Row({ space: 10 }) {
Column() {
Text(n.kind.substring(0, 1))
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor(n.kind === '互动' ? '#FBBF24' : n.kind === '关注' ? '#38BDF8' : '#34D399')
noticeModal中的消息通知项展示了ArkTS中嵌套三元运算符的使用。.backgroundColor(n.kind === '互动' ? '#FBBF24' : n.kind === '关注' ? '#38BDF8' : '#34D399')这行代码根据通知类型动态设置头像背景色:如果类型是"互动"使用金色,否则如果类型是"关注"使用蓝色,否则(即"系统"类型)使用绿色。这种嵌套三元运算符在处理有限分类的颜色映射时非常实用,但如果分类数量增多,建议改用Map或switch语句以提高可读性。
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(n.title)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
if (n.fresh !== '') {
Text(n.fresh)
.fontSize(9)
.fontColor('#180E06')
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.borderRadius(8)
.backgroundColor('#FB7185')
}
}
通知项中未读标记的渲染使用了if (n.fresh !== '')条件判断。当fresh字段不为空字符串时,在通知标题右侧渲染一个红色(#FB7185)的小标签组件,标签内容为n.fresh的值(即"1")。虽然"1"作为标签文字在语义上不够直观,但在真实应用中这个值可以替换为"NEW"或红点图标。整个通知项的背景色也根据fresh字段变化——未读通知使用更亮的橙色半透明背景(rgba(249,115,22,0.10)),已读通知使用较暗的棕色半透明背景(rgba(122,98,72,0.08)),通过背景色差异引导用户关注未读通知。
5.7 treatModal——投喂交互与状态更新
Column({ space: 4 }) {
Text('🥩')
.fontSize(22)
Text('肉干')
.fontSize(10)
.fontColor('#3D1F0B')
Text('2 币')
.fontSize(9)
.fontColor('#7C4A12')
}
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.borderRadius(14)
.backgroundColor('#FBBF24')
.onClick(() => {
this.treatNum = this.treatNum - 1;
this.petCoins = this.petCoins - 2;
})
投喂弹窗中每种食物的点击事件都会同时修改两个@State变量:treatNum减1(减少剩余投喂次数)和petCoins减去对应的价格(肉干2币、小鱼干5币、罐头12币)。这两个状态变量的修改会立即触发UI更新——弹窗中显示的"今日剩余投喂次数"和底部的"宠币余额"会实时反映最新的数值。这种即时的状态反馈是声明式UI的核心优势——开发者只需修改状态变量的值,框架自动处理UI更新的所有细节。
投喂排行区域展示了本周投喂次数最多的三位用户。排行榜的设计采用了"名次+用户名+投喂次数"的三列布局,名次使用橙色粗体数字,用户名使用深棕色,投喂次数使用浅棕色。这种排行榜机制在社交应用中是常见的用户激励手段——通过展示活跃用户来激发其他用户的参与热情。
六、标签页组件:功能模块的独立封装
6.1 NewsTab——萌闻标签页
@Component
struct NewsTab {
onNews: (title: string) => void = (title: string) => {};
onQuick: (i: number) => void = (i: number) => {};
onPost: (owner: string) => void = (owner: string) => {};
onSub: (name: string) => void = (name: string) => {};
NewsTab是萌闻标签页组件,使用@Component装饰器声明(注意没有@Entry,因为它不是入口组件)。组件声明了四个回调函数属性:onNews、onQuick、onPost、onSub。每个属性都被初始化为一个空函数(() => {}),这是一种防御性编程策略——如果父组件没有传入某个回调,调用该回调时不会报错,而是执行空操作。
在ArkTS中,子组件通过组件属性接收父组件传递的数据和回调。当父组件在build()方法中声明NewsTab({ onNews: (title) => {...}, ... })时,这些回调函数被赋值给NewsTab实例的对应属性。子组件内部通过this.onNews(title)调用这些回调,将事件传递给父组件处理。这种"属性向下传递,事件向上回调"的单向数据流模式是ArkTS组件间通信的标准范式。
@Builder
sectionTitle(icon: string, title: string, extra: string) {
Row() {
Row({ space: 6 }) {
Text(icon)
.fontSize(13)
Text(title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
}
Text(extra)
.fontSize(10)
.fontColor('#7A6248')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 16, bottom: 10 })
}
sectionTitle是一个在NewsTab内部定义的@Builder方法,用于渲染区块标题。它接收三个参数:icon(emoji图标)、title(标题文字)、extra(附加信息)。标题使用Row容器水平排列图标和标题文字,附加信息通过FlexAlign.SpaceBetween推到右侧。这个构建器在NewsTab、VideoTabPet、TopicTabPet、ClubTab、MineTabPet五个组件中都有完全相同的定义——虽然代码重复,但在ArkTS中目前没有跨组件的UI Mixin机制,@Builder方法只能在组件内部定义。
build() {
Column() {
Scroll() {
Column() {
Scroll() {
Row({ space: 8 }) {
ForEach(PET_TOPICS, (t: PetTopic) => {
Text(t.name.replace('#', ''))
.fontSize(10)
.fontColor(t.color)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('rgba(249,115,22,0.08)')
.onClick(() => {
this.onNews(PET_NEWS[0].title);
})
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
NewsTab的build()方法最外层是Column容器,内部嵌套一个Scroll容器实现垂直滚动。内容区的第一个元素是一个水平滚动的话题标签栏——使用Scroll容器包裹Row,并通过.scrollable(ScrollDirection.Horizontal)设置为水平滚动方向。.scrollBar(BarState.Off)隐藏了滚动条,使得水平滚动区域在视觉上更加简洁。
ForEach(PET_TOPICS, (t: PetTopic) => {...})遍历话题数据生成标签组件。每个标签使用t.name.replace('#', '')去除话题名称中的井号符号——replace是JavaScript标准字符串方法,在ArkTS中同样可用。标签的文字颜色使用t.color——这个颜色值来自PetTopic接口的color属性,每个话题有自己独特的颜色,使得话题标签栏呈现出彩虹般的色彩效果。
Row({ space: 8 }) {
Text('🔥')
.fontSize(12)
Text('橘猫店长上班打盹被拍,顾客排队等它"签字"')
.fontSize(11)
.fontColor('#FBBF24')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('置顶')
.fontSize(8)
.fontColor('#180E06')
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.borderRadius(4)
.backgroundColor('#FB7185')
}
置顶新闻栏使用Row水平排列火焰图标、新闻标题和"置顶"标签。新闻标题设置了maxLines(1)(最多显示1行)和textOverflow({ overflow: TextOverflow.Ellipsis })(超出部分显示省略号),这两个属性组合实现了单行文本截断效果——TextOverflow.Ellipsis是ArkTS的文本溢出枚举值,表示溢出文本用"…"替代。layoutWeight(1)使标题占据图标和标签之间的所有剩余空间,确保"置顶"标签始终在右侧。
Row() {
ForEach(getQuickLeft(), (q: PetQuick) => {
Column({ space: 5 }) {
Text(q.icon)
.fontSize(20)
Text(q.label)
.fontSize(9)
.fontColor('#E8D5BC')
}
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.borderRadius(14)
.backgroundColor('rgba(249,115,22,0.07)')
.onClick(() => {
this.onQuick(q.icon === '🍖' ? 0 : q.icon === '🏥' ? 1 : q.icon === '📅' ? 2 : 3);
})
})
}
快捷入口区域分为上下两行,每行4个入口。getQuickLeft()返回前4个快捷入口,getQuickRight()返回后4个。每个入口是一个Column容器,上部显示emoji图标(20vp字号),下部显示文字标签(9vp字号)。点击事件中使用了嵌套三元运算符根据emoji图标值映射到不同的索引——🍖映射到0、🏥映射到1、📅映射到2、其余映射到3。这种通过emoji值进行路由的方式虽然在可读性上不如使用枚举或常量,但在快捷入口数量固定且不会变化的场景下是可接受的。
6.2 双列瀑布流布局——新闻列表的分列渲染
this.sectionTitle('📰', '萌闻流 (' + getNewsCount() + ')', '按热度排序')
Row({ space: 10 }) {
Column({ space: 8 }) {
ForEach(getNewsLeft(), (n: PetNews) => {
Column({ space: 6 }) {
Row({ space: 6 }) {
Text(n.emoji)
.fontSize(22)
if (n.badge !== '') {
Text(n.badge)
.fontSize(8)
.fontColor('#FFF7E8')
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.borderRadius(3)
.backgroundColor('#F97316')
}
}
.width('100%')
Text(n.title)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
.lineHeight(16)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(n.views + '阅读')
.fontSize(8)
.fontColor('#7A6248')
Text(n.replies + '帖')
.fontSize(8)
.fontColor('#F97316')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.alignItems(HorizontalAlign.Start)
.padding(10)
.borderRadius(12)
.backgroundColor('rgba(122,98,72,0.10)')
.onClick(() => {
this.onNews(n.title);
})
})
}
.layoutWeight(1)
Column({ space: 8 }) {
ForEach(getNewsRight(), (n: PetNews) => {
// ... 右列新闻项,结构与左列相同,背景色略有差异
})
}
.layoutWeight(1)
}
新闻列表区域使用了双列瀑布流布局——外层Row包含两个Column,每个Column使用layoutWeight(1)等分宽度,间距为10vp。左列使用getNewsLeft()的数据(前5条新闻),右列使用getNewsRight()的数据(后5条新闻)。两列各自独立排列,由于每条新闻的标题长度不同,两列在视觉上形成错落有致的效果。
每个新闻项卡片包含:emoji图标和标签(条件渲染)、新闻标题(最多2行、超出显示省略号)、阅读量和跟帖数。新闻标题设置了maxLines(2)——最多显示2行,配合textOverflow省略号截断,确保卡片高度不会因标题过长而无限增加。左列卡片的背景色为rgba(122,98,72,0.10)(灰棕色半透明),右列卡片的背景色为rgba(249,115,22,0.08)(橙色半透明),这种细微的色彩差异在视觉上区分了左右两列。
点击新闻项时调用this.onNews(n.title)——将新闻标题传递给父组件的回调函数,父组件通过findNewsByTitle(title)查找完整的新闻数据并打开详情弹窗。
ForEach是ArkTS中处理列表渲染的核心指令。它接收三个参数:数据数组、渲染函数和(可选的)键值生成函数。渲染函数为每个数据项生成对应的UI组件。在性能层面,ForEach会利用数据项的唯一标识符(如id字段)进行diff操作——当数组数据变化时,只重新渲染发生变化的数据项对应的UI组件,而非重建整个列表。这种机制确保了列表渲染的性能在数据量增大时仍然保持高效。
6.3 VideoTabPet——视频标签页
@Component
struct VideoTabPet {
onVideo: (title: string) => void = (title: string) => {};
onLive: (title: string) => void = (title: string) => {};
onReply: () => void = () => {};
build() {
Column() {
Scroll() {
Column() {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('▶')
.fontSize(16)
.fontColor('#180E06')
.padding({ left: 9, right: 9, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#FBBF24')
Text('今日萌宠精选 · 橘猫店长的一天')
.fontSize(11)
.fontColor('#FFF7E8')
.layoutWeight(1)
Text('07:22')
.fontSize(10)
.fontColor('#FBBF24')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
}
.width('100%')
.height(140)
.borderRadius(16)
.linearGradient({
angle: 150,
colors: [['#F97316', 0.0], ['#3D1F0B', 0.6], ['#180E06', 1.0]]
})
.justifyContent(FlexAlign.Center)
.margin({ top: 12 })
.onClick(() => {
this.onVideo(PET_VIDEOS[0].title);
})
VideoTabPet组件的build()方法首先展示一个精选视频卡片——高度140vp,使用线性渐变背景(从橙色过渡到深棕色),内部使用FlexAlign.Center将内容在垂直方向居中。卡片内的播放按钮"▶"是一个金色背景的圆形按钮,点击整个卡片调用this.onVideo(PET_VIDEOS[0].title)打开视频详情弹窗。
精选视频卡片下方的"热播跟拍"区域使用了水平滚动的Scroll容器,内部通过ForEach(PET_VIDEOS, ...)遍历所有视频数据生成视频卡片。每个视频卡片固定宽度为120vp,包含主题标签、播放按钮、标题和播放量信息。这种固定宽度的水平滚动列表是视频应用中常见的展示模式——用户可以横向滑动浏览更多视频,每个卡片展示足够的信息供用户决策是否点击观看。
视频库区域使用了与新闻列表类似的双列布局,将视频数据分为左右两列。不同之处在于视频卡片的背景色使用蓝色系半透明(rgba(56,189,248,0.08)),与新闻列表的橙色系形成区分,在视觉上表明这是不同类型的内容。
this.sectionTitle('🔴', '萌宠直播间 (' + getLiveCount() + ')', '治愈循环')
Row({ space: 10 }) {
Column({ space: 8 }) {
ForEach(getLiveLeft(), (l: PetLive) => {
Row({ space: 8 }) {
Column() {
Text('🔴')
.fontSize(12)
}
.width(30)
.height(30)
.borderRadius(15)
.backgroundColor('rgba(251,113,133,0.16)')
直播区域同样使用双列布局,左列展示正在直播的内容(使用红色🔴图标),右列展示预告内容(使用蓝色⏰图标)。每个直播项卡片包含直播标题、主播名和观看人数。正在直播的卡片背景色为粉色系(rgba(251,113,133,0.08)),预告的卡片背景色为蓝色系(rgba(56,189,248,0.08)),通过颜色差异直观地区分直播状态。
6.4 TopicTabPet——话题标签页
@Component
struct TopicTabPet {
onTopic: (name: string) => void = (name: string) => {};
onVote: () => void = () => {};
onHot: (tag: string) => void = (tag: string) => void = (tag: string) => {};
build() {
Column() {
Scroll() {
Column() {
Column({ space: 10 }) {
Row() {
Column({ space: 3 }) {
Text('🏆 本周萌王争霸')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
Text(getVoteCount() + ' 组候选 · 投票进行中')
.fontSize(9)
.fontColor('#7C4A12')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('去投票')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor('#FBBF24')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
TopicTabPet组件的build()方法首先展示一个萌王投票卡片——使用浅黄色背景(#FDE68A),与页面其他区域的深色背景形成鲜明对比。卡片内通过ForEach(getVoteList(), ...)渲染投票候选项的进度条。每个候选项包含名称、百分比和一条彩色进度条。进度条的宽度通过.width(v.pct + '%')动态设置——将数值(如38)与百分号拼接为"38%"作为宽度值。
话题广场区域使用双列布局展示8个话题卡片。每个话题卡片的标题颜色使用话题自身的color属性值——如"#我家主子是戏精#“使用橙色、”#萌宠早餐日记#"使用蓝色,这种"每个话题一个主题色"的设计使得话题列表在视觉上丰富多彩。每个卡片还包含话题的情感氛围标签(mood字段)、参与人数和发帖数。
热榜区域分为"前三名"和"其余"两部分展示。前三名使用getHotTop()获取,每个热搜项包含排名图标(圆形背景,26x26vp)、标签文字、状态标签和增长幅度。排名前三的颜色分别为金色(#FBBF24)、橙色(#F97316)、粉色(#FB7185)。第四到第十名使用getHotRest()获取,采用更简洁的列表样式——排名数字使用灰棕色,无状态标签图标。
6.5 ClubTab——宠圈标签页
@Component
struct ClubTab {
onPost: (owner: string) => void = (owner: string) => {};
onBreed: (name: string) => void = (name: string) => {};
onTopic: (name: string) => void = (name: string) => {};
onLive: (title: string) => void = (title: string) => {};
build() {
Column() {
Scroll() {
Column() {
this.sectionTitle('🏅', '晒宠榜 Top3', '本周爪印最多')
Row({ space: 10 }) {
Column({ space: 6 }) {
Column() {
Text('🥈')
.fontSize(22)
}
.width(52)
.height(52)
.borderRadius(26)
.backgroundColor('rgba(192,132,68,0.22)')
.justifyContent(FlexAlign.Center)
Text(getTopPosts()[1].owner)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#E8D5BC')
Text('🐾 ' + getTopPosts()[1].paw)
.fontSize(9)
.fontColor('#F97316')
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.borderRadius(14)
.backgroundColor('rgba(122,98,72,0.10)')
ClubTab组件首先展示晒宠榜前三名。这是一个颁奖台式的布局——中间(第一名)最大最高(64x64vp头像、26vp字号),两侧(第二、三名)较小(52x52vp头像、22vp字号)。整个Row使用alignItems(VerticalAlign.Bottom)设置子元素底部对齐,模拟了真实颁奖台的视觉效果——第一名的"台子"最高,第二、三名的"台子"较矮。
品种档案区域使用双列布局展示6个品种卡片。每个卡片包含品种名首字母头像、品种名称、产地、活力指数进度条、评分和价格。左列卡片的头像背景色为金色(#FBBF24),右列卡片的头像背景色为蓝色(#38BDF8),活力进度条的颜色也相应使用橙色和蓝色,这种左右两列使用不同主色调的设计在视觉上增加了层次感。
帖子列表区域展示了8条用户帖子,分为左列(4条,金色头像背景)和右列(4条,蓝色头像背景)。每条帖子包含用户首字母头像、用户名、宠物信息标签、帖子正文(最多2行)、爪印数、评论数和所属圈层标签。
this.sectionTitle('🔴', '宠圈直播', '实时连线')
Column({ space: 8 }) {
ForEach(getLiveLeft(), (l: PetLive) => {
Row({ space: 10 }) {
Column() {
Text(l.host.substring(0, 1))
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor('#FB7185')
直播区域展示3场正在进行的直播。每个直播项使用主播名首字母作为头像,背景色为珊瑚红色(#FB7185)。直播状态标签l.air的值为"直播中"或"预告"——通过条件运算符l.air === '直播中' ? '#FB7185' : 'rgba(122,98,72,0.16)'动态设置标签背景色,"直播中"使用红色背景突出显示,"预告"使用灰色背景弱化显示。
6.6 MineTabPet——我的标签页
@Component
struct MineTabPet {
onSub: (name: string) => void = (name: string) => {};
onNotice: (title: string) => void = (title: string) => {};
onVip: () => void = () => {};
onSettings: () => void = () => {};
onShare: () => void = () => {};
onColumn: (name: string) => void = (name: string) => {};
onTreat: () => void = () => {};
build() {
Column() {
Scroll() {
Column() {
Row({ space: 12 }) {
Column() {
Text('🐱')
.fontSize(26)
}
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor('rgba(251,191,36,0.2)')
.justifyContent(FlexAlign.Center)
Column({ space: 5 }) {
Text('鱼干他娘')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
Row({ space: 8 }) {
Text('🥇 晒宠榜 No.1')
.fontSize(8)
.fontColor('#180E06')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor('#FBBF24')
Text('萌值 2860')
.fontSize(8)
.fontColor('#F97316')
}
Text('橘猫三斤的官方铲屎官 · 已发帖 86')
.fontSize(8)
.fontColor('#7A6248')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
Text('👑')
.fontSize(18)
Text('会员')
.fontSize(8)
.fontColor('#FBBF24')
}
.onClick(() => {
this.onVip();
})
}
.width('100%')
.padding(14)
.borderRadius(16)
.linearGradient({
angle: 130,
colors: [['#3D1F0B', 0.0], ['#7C2D12', 1.0]]
})
MineTabPet组件首先展示用户个人资料卡片。卡片使用渐变背景(从深棕色到红棕色),包含用户头像(🐱emoji,56x56vp圆形背景)、用户名、晒宠榜排名标签、萌值、个人简介和会员入口。点击会员入口调用this.onVip()打开会员弹窗。
个人资料卡片下方是四个统计快捷入口:宠币、发帖、获爪、设置。每个入口使用layoutWeight(1)等分宽度,通过不同颜色的半透明背景区分——宠币用金色、发帖用橙色、获爪用蓝色、设置用灰色。点击不同的入口分别调用不同的回调函数打开对应的弹窗或页面。
this.sectionTitle('🔔', '我的消息', getNoticeCount() + ' 条')
Column({ space: 8 }) {
ForEach(PET_NOTICES, (n: PetNotice) => {
Row({ space: 10 }) {
Text(n.kind)
.fontSize(8)
.fontColor('#180E06')
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor(n.kind === '互动' ? '#FBBF24' : n.kind === '关注' ? '#38BDF8' : '#34D399')
Column({ space: 3 }) {
Text(n.title)
.fontSize(11)
.fontColor('#FFF7E8')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(n.time)
.fontSize(8)
.fontColor('#7A6248')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (n.fresh !== '') {
Text('')
.fontSize(8)
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor('#FB7185')
}
}
消息列表区域使用ForEach(PET_NOTICES, ...)遍历通知数据。与noticeModal弹窗中的通知列表不同,这里的通知项采用横向紧凑布局——通知类型标签在左侧,通知标题和时间在右侧。未读通知通过一个8x8vp的红色圆点标识(if (n.fresh !== '')条件渲染),这种小红点设计是移动端应用中标识未读消息的标准UI模式。
七、布局技术与样式系统总结
7.1 layoutWeight的灵活运用
在整个应用的代码中,layoutWeight是最频繁使用的布局属性之一。它的作用类似于CSS中的flex-grow——在Flex容器中,设置了layoutWeight的子元素会根据权重值分配容器的剩余空间。本应用中layoutWeight的使用场景主要包括:
等分宽度场景:双列布局中的左右两列都使用layoutWeight(1),实现50:50的等分效果。这在新闻列表、视频列表、话题列表等多种列表场景中被重复使用。
弹性填充场景:搜索框在导航栏中使用layoutWeight(1)占据标识和按钮之间的所有空间;帖子标题使用layoutWeight(1)占据图标和标签之间的空间。在这些场景中,layoutWeight(1)使得元素自动填充剩余空间,无需计算具体的宽度值。
比例分配场景:发帖弹窗中的"存草稿"按钮使用layoutWeight(1),"发布"按钮使用layoutWeight(1.6),两者宽度比为1:1.6,使得"发布"按钮更宽更突出。这种通过layoutWeight值的比例来控制元素宽度比的设计在操作按钮区域中很常见。
7.2 条件渲染模式
ArkTS支持在build()方法和@Builder方法中使用if-else条件语句来控制UI组件的渲染。本应用中大量使用了条件渲染,主要模式包括:
布尔值条件渲染:if (this.showNews) { this.newsModal() }——根据布尔状态变量决定是否渲染弹窗组件。这是弹窗显示/隐藏的标准实现方式。
非空字符串条件渲染:if (n.badge !== '') { Text(n.badge)... }——根据字符串是否为空决定是否渲染标签组件。这种模式用于处理可选的UI元素,如新闻标签、通知未读标记等。
索引条件渲染:if (idx === 0) { Text('👑')... } else { Text((idx + 1) + '')... }——根据数据项在数组中的索引位置决定渲染内容。这种模式用于排行榜中第一名与其他名次的差异化展示。
7.3 字符串拼接与动态属性
ArkTS支持在UI声明中使用JavaScript模板字符串和字符串拼接运算符来动态设置组件属性。本应用中大量使用了+运算符进行字符串拼接:
Text('宠币 ' + this.petCoins)——将静态字符串与状态变量拼接,实现动态文本显示。当petCoins变化时,文本内容自动更新。
.width(this.selBreed.energy + '%')——将数值与百分号拼接为CSS-like的百分比字符串,作为进度条宽度值。这种将数值动态映射为UI尺寸的方式在进度条、百分比条等组件中很常见。
Text('热门跟帖 (' + getCommentCount() + ')')——将函数返回值嵌入到字符串中,实现动态计数显示。
7.4 渐变背景与视觉设计
本应用的视觉设计采用了暖色调暗色主题,大量使用了linearGradient线性渐变背景:
.linearGradient({
angle: 200,
colors: [['#2A1608', 0.0], ['#3D1F0B', 0.6], ['#F97316', 1.0]]
})
linearGradient方法接收一个对象参数,包含angle(渐变角度)和colors(颜色停止点数组)两个属性。angle以度为单位指定渐变方向——0度表示从下到上,90度表示从左到右,200度则是一个从左下方到右上方的斜向渐变。colors数组中每个元素是一个[颜色值, 位置]的元组,位置值范围从0.0到1.0,表示颜色在渐变路径上的相对位置。
应用中不同区域使用了不同的渐变方案:顶部导航栏使用深棕到橙色的渐变(angle: 200),精选视频卡片使用橙色到深棕色的渐变(angle: 150),直播卡片使用红棕色到深棕色的渐变(angle: 140),会员卡片使用金色到橙色的渐变(angle: 120)。这些渐变方案虽然角度和颜色不同,但都遵循"从暖色到暗色"的整体色调,保持了视觉风格的一致性。
八、综合技术对比表
| 技术特性 | ArkTS实现方式 | 用途说明 | 本应用使用场景 | 优势 | 注意事项 |
|---|---|---|---|---|---|
| @Entry | @Entry struct Index |
标记入口组件 | Index组件 | 确保应用有唯一入口 | 每个应用只能有一个@Entry |
| @Component | @Component struct Xxx |
声明自定义组件 | Index/NewsTab/VideoTabPet等 | 实现组件复用 | 需配合build()方法使用 |
| @State | @State tabIdx: number = 0 |
声明可变状态变量 | tabIdx/showNews/selNews/petCoins等 | 自动触发UI更新 | 仅限组件内部使用 |
| @Builder | @Builder funcName() |
封装可复用UI逻辑 | modalOverlay/bottomTabItem/sectionTitle等 | 代码复用与组织 | 只能在组件struct内部定义 |
| Column | Column() {...} |
垂直布局容器 | 几乎所有布局区域 | 简单直观的纵向排列 | 通过space参数设置子元素间距 |
| Row | Row() {...} |
水平布局容器 | 导航栏/统计卡片/按钮组 | 简单直观的横向排列 | 通过space参数设置子元素间距 |
| Scroll | Scroll() {...} |
可滚动容器 | 内容区/评论列表/话题标签栏 | 支持内容超出屏幕滚动 | 需设置scrollable方向和scrollBar |
| ForEach | ForEach(arr, (item) => {...}) |
列表渲染指令 | 新闻列表/评论列表/话题列表等 | 高效的列表diff更新 | 需提供唯一键值以优化性能 |
| layoutWeight | .layoutWeight(1) |
Flex权重分配 | 双列等分/弹性填充/比例分配 | 自动分配剩余空间 | 值为number类型 |
| linearGradient | .linearGradient({angle, colors}) |
线性渐变背景 | 导航栏/视频卡片/会员卡片等 | 丰富的视觉效果 | 需注意性能开销 |
| justifyContent | .justifyContent(FlexAlign.Center) |
主轴对齐方式 | 几乎所有Row和Column | 控制子元素位置 | 枚举值包括Start/Center/End/SpaceBetween等 |
| alignItems | .alignItems(HorizontalAlign.Start) |
交叉轴对齐方式 | Column内的子元素对齐 | 控制子元素位置 | Row用HorizontalAlign/Column用VerticalAlign |
| fontWeight | .fontWeight(FontWeight.Bold) |
字体粗细 | 标题/强调文字 | 视觉层次区分 | 枚举值包括Normal/Bold等 |
| borderRadius | .borderRadius(12) |
圆角半径 | 卡片/按钮/头像等 | 柔和的视觉风格 | 值为number或Length字符串 |
| backgroundColor | .backgroundColor('#F97316') |
背景颜色 | 所有可见组件 | 视觉区分 | 支持hex/rgba/命名颜色 |
| fontSize | .fontSize(14) |
字体大小 | 所有Text组件 | 文字层次控制 | 单位为vp |
| fontColor | .fontColor('#FFF7E8') |
字体颜色 | 所有Text组件 | 文字可读性 | 支持hex/rgba/命名颜色 |
| padding | .padding({left:16,right:16}) |
内边距 | 几乎所有容器组件 | 控制内容与边界距离 | 支持对象格式或单一数值 |
| margin | .margin({top:16,bottom:10}) |
外边距 | 卡片间距/区块间距 | 控制组件间距离 | 支持对象格式或单一数值 |
| transition | .transition(TransitionEffect.OPACITY...) |
过渡动画 | 所有弹窗组件 | 平滑的显示/隐藏效果 | 需配合条件渲染使用 |
| animation | .animation({duration:220,curve:Curve.EaseOut}) |
属性动画 | 底部导航栏缩放 | 平滑的属性变化 | 需设置duration和curve |
| maxLines | .maxLines(2) |
最大行数 | 新闻标题/帖子正文 | 控制文本显示范围 | 需配合textOverflow使用 |
| textOverflow | .textOverflow({overflow:TextOverflow.Ellipsis}) |
文本溢出处理 | 标题/摘要文本 | 优雅的截断效果 | 需配合maxLines使用 |
| scale | .scale({x:1.22,y:1.22}) |
缩放变换 | 底部导航栏选中项 | 视觉强调效果 | 值为比例因子 |
| letterSpacing | .letterSpacing(1) |
字符间距 | 副标题/英文文本 | 提升可读性 | 值为vp单位 |
| constraintSize | .constraintSize({maxHeight:'78%'}) |
约束尺寸 | 评论弹窗内容区 | 限制最大/最小尺寸 | 防止内容溢出 |
| translate | .translate({y:18}) |
平移变换 | 底部弹窗内容 | 视觉偏移效果 | 支持x/y方向 |
九、总结
本文对一个基于鸿蒙ArkTS语言开发的萌宠社交信息流应用进行了极其详尽的逐段代码解析。该应用的代码量超过4600行,涵盖了从数据接口定义到UI组件实现的完整技术栈,是理解ArkTS声明式UI开发模式的优秀案例。
从架构层面来看,该应用采用了清晰的三层架构:数据层(13个接口定义和13个数据常量数组)、逻辑层(约40个辅助函数包括分片获取和按属性查找)、表现层(1个入口组件、5个标签页组件和16个弹窗构建器)。这种分层设计使得数据、逻辑和UI三者之间保持了低耦合高内聚的关系,每一层都可以独立修改而不影响其他层。数据层通过接口定义确保了类型安全,逻辑层通过函数封装实现了数据访问的统一接口,表现层通过组件化和@Builder方法实现了UI的复用。
从状态管理层面来看,Index组件管理了33个@State状态变量,涵盖了标签页索引、16个弹窗显示状态、10个选中数据项和2个业务数值。这些状态变量的变化驱动了整个应用的UI更新——从标签页切换到弹窗显示/隐藏,从数据选中到内容更新,从虚拟货币增减到余额显示,所有UI变化都通过@State变量的修改自动触发。这种"状态驱动UI"的声明式编程范式是ArkTS的核心特性,它将开发者从命令式的DOM操作中解放出来,使得代码更加简洁和可维护。
从组件通信层面来看,应用采用了严格的"单向数据流"模式——父组件通过组件属性向子组件传递回调函数,子组件通过调用回调函数将用户事件通知给父组件。五个标签页组件(NewsTab、VideoTabPet、TopicTabPet、ClubTab、MineTabPet)各自定义了不同数量的回调属性(NewsTab有4个、MineTabPet有7个),这些回调覆盖了各自标签页内所有需要触发弹窗或导航的用户交互场景。父组件Index在创建子组件时传入这些回调的具体实现,在回调内部修改自身的@State变量来触发弹窗显示。这种通信模式确保了数据流动的可预测性——状态变更只能从父组件发起,子组件不能直接修改父组件的状态。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
interface PetNews {
id: number;
title: string;
author: string;
topic: string;
views: string;
replies: string;
emoji: string;
badge: string;
time: string;
}
interface PetHot {
rank: number;
tag: string;
reads: string;
state: string;
rise: string;
}
interface PetVideo {
id: number;
title: string;
vlogger: string;
mins: string;
plays: string;
treats: string;
theme: string;
}
interface PetTopic {
id: number;
name: string;
joined: string;
posts: string;
mood: string;
color: string;
}
interface PetPost {
id: number;
owner: string;
pet: string;
text: string;
paw: string;
replies: string;
club: string;
}
interface PetBreed {
id: number;
name: string;
origin: string;
energy: number;
price: string;
rating: string;
}
interface PetNotice {
id: number;
kind: string;
title: string;
body: string;
time: string;
fresh: string;
}
interface PetSub {
id: number;
name: string;
cycle: string;
tag: string;
tip: string;
}
interface PetQuick {
icon: string;
label: string;
color: string;
}
interface PetColumn {
id: number;
name: string;
master: string;
fee: string;
lessons: string;
}
interface PetLive {
id: number;
title: string;
host: string;
watching: string;
scene: string;
air: string;
}
interface PetComment {
id: number;
owner: string;
text: string;
paw: string;
reply: string;
rank: string;
}
const PET_NEWS: PetNews[] = [
{ id: 1, title: '橘猫店长上班打盹被拍,顾客排队等它"签字"', author: '街区猫闻社', topic: '猫圈头条', views: '1102万', replies: '3.6万', emoji: '🐱', badge: '爆', time: '16分钟前' },
{ id: 2, title: '金毛替主人取快递,物业专门给它留了门', author: '邻里汪事记', topic: '汪圈头条', views: '928万', replies: '2.8万', emoji: '🐕', badge: '热', time: '38分钟前' },
{ id: 3, title: '仓鼠囤粮地图走红,藏匿点达 27 处', author: '啮齿研究所', topic: '小型圈', views: '664万', replies: '2.1万', emoji: '🐹', badge: '', time: '1小时前' },
{ id: 4, title: '鹦鹉学会叫外卖,连续三周准时喊"到"', author: '羽毛快报', topic: '鸟圈头条', views: '836万', replies: '2.9万', emoji: '🦜', badge: '爆', time: '2小时前' },
{ id: 5, title: '兔子拆家现场被拍,沙发只剩弹簧', author: '兔宅小报', topic: '草食圈', views: '512万', replies: '1.8万', emoji: '🐰', badge: '', time: '3小时前' },
{ id: 6, title: '柯基跑姿新研究:短腿并非劣势', author: '运动汪实验室', topic: '汪圈头条', views: '428万', replies: '1.2万', emoji: '🐶', badge: '新', time: '5小时前' },
{ id: 7, title: '水獭叠石头大赛落幕,冠军塔高 11 层', author: '水畔趣闻', topic: '水族圈', views: '587万', replies: '1.9万', emoji: '🦦', badge: '热', time: '7小时前' },
{ id: 8, title: '布偶猫拍照拒看镜头,摄影师蹲守两小时', author: '猫模经纪部', topic: '猫圈头条', views: '373万', replies: '1.1万', emoji: '🐈', badge: '', time: '9小时前' },
{ id: 9, title: '刺猬过马路,车队礼让三分钟', author: '夜行动物志', topic: '小型圈', views: '341万', replies: '0.9万', emoji: '🦔', badge: '', time: '12小时前' },
{ id: 10, title: '龟兔赛跑百年重赛,乌龟再次夺冠', author: '童话考据组', topic: '草食圈', views: '298万', replies: '0.8万', emoji: '🐢', badge: '', time: '14小时前' }
];
const PET_HOT: PetHot[] = [
{ rank: 1, tag: '橘猫店长签字', reads: '4213万', state: '爆', rise: '↑ 142万' },
{ rank: 2, tag: '金毛取快递', reads: '3487万', state: '爆', rise: '↑ 117万' },
{ rank: 3, tag: '鹦鹉叫外卖', reads: '2866万', state: '热', rise: '↑ 93万' },
{ rank: 4, tag: '仓鼠藏粮图', reads: '2218万', state: '热', rise: '↑ 76万' },
{ rank: 5, tag: '水獭叠石头', reads: '1784万', state: '', rise: '↑ 58万' },
{ rank: 6, tag: '柯基跑姿研究', reads: '1459万', state: '', rise: '↑ 47万' },
{ rank: 7, tag: '布偶猫镜头', reads: '1173万', state: '新', rise: '↑ 36万' },
{ rank: 8, tag: '兔子拆沙发', reads: '952万', state: '', rise: '↑ 29万' },
{ rank: 9, tag: '刺猬过马路', reads: '731万', state: '', rise: '↑ 22万' },
{ rank: 10, tag: '龟兔重赛', reads: '612万', state: '', rise: '↑ 17万' }
];
const PET_VIDEOS: PetVideo[] = [
{ id: 1, title: '橘猫店长的一天·营业实录', vlogger: '街区猫闻社', mins: '07:22', plays: '812万', treats: '96万', theme: '纪录' },
{ id: 2, title: '金毛取快递全程跟拍', vlogger: '邻里汪事记', mins: '05:41', plays: '684万', treats: '82万', theme: '跟拍' },
{ id: 3, title: '仓鼠囤粮大搜查 27 处全公开', vlogger: '啮齿研究所', mins: '11:08', plays: '512万', treats: '67万', theme: '搜查' },
{ id: 4, title: '鹦鹉喊到名场面合集', vlogger: '羽毛快报', mins: '03:57', plays: '459万', treats: '58万', theme: '合集' },
{ id: 5, title: '水獭叠石头决赛回放', vlogger: '水畔趣闻', mins: '09:33', plays: '386万', treats: '49万', theme: '赛事' },
{ id: 6, title: '布偶猫写真幕后花絮', vlogger: '猫模经纪部', mins: '06:15', plays: '297万', treats: '38万', theme: '花絮' },
{ id: 7, title: '柯基跑步机数据实测', vlogger: '运动汪实验室', mins: '08:44', plays: '238万', treats: '31万', theme: '实测' },
{ id: 8, title: '刺猬夜行路线大公开', vlogger: '夜行动物志', mins: '13:26', plays: '184万', treats: '24万', theme: '夜拍' }
];
const PET_TOPICS: PetTopic[] = [
{ id: 1, name: '#我家主子是戏精#', joined: '86.4万人', posts: '12.8万帖', mood: '搞笑', color: '#F97316' },
{ id: 2, name: '#萌宠早餐日记#', joined: '54.2万人', posts: '8.3万帖', mood: '治愈', color: '#38BDF8' },
{ id: 3, name: '#拆家现场展览#', joined: '47.8万人', posts: '7.1万帖', mood: '吐槽', color: '#F472B6' },
{ id: 4, name: '#街头宠物社交#', joined: '39.5万人', posts: '6.4万帖', mood: '热闹', color: '#34D399' },
{ id: 5, name: '#宠物冷知识#', joined: '35.1万人', posts: '5.2万帖', mood: '涨知识', color: '#FBBF24' },
{ id: 6, name: '#动物医院夜班#', joined: '28.7万人', posts: '4.1万帖', mood: '纪实', color: '#A78BFA' },
{ id: 7, name: '#异宠图鉴#', joined: '22.3万人', posts: '3.3万帖', mood: '开眼', color: '#2DD4BF' },
{ id: 8, name: '#领养代替购买#', joined: '61.9万人', posts: '9.7万帖', mood: '公益', color: '#FB7185' }
];
const PET_POSTS: PetPost[] = [
{ id: 1, owner: '鱼干他娘', pet: '橘猫·三斤', text: '今天它把逗猫棒藏进了我鞋里,谁教它的', paw: '6.2万', replies: '1841', club: '晒宠区' },
{ id: 2, owner: '快递站小李', pet: '金毛·馒头', text: '馒头今天取了七个包裹,物业给它发了工牌', paw: '5.4万', replies: '1526', club: '汪圈' },
{ id: 3, owner: '囤囤鼠家', pet: '仓鼠·糯米', text: '按图索骥找出了 27 个藏粮点,糯米全程淡定', paw: '4.8万', replies: '1372', club: '晒宠区' },
{ id: 4, owner: '外卖常客', pet: '鹦鹉·彩彩', text: '彩彩今早在窗口喊"到了",快递员真的来了', paw: '4.1万', replies: '1105', club: '鸟圈' },
{ id: 5, owner: '弹簧沙发主', pet: '兔子·年糕', text: '沙发卒,年糕在废墟上睡得很香', paw: '3.7万', replies: '964', club: '拆家展览' },
{ id: 6, owner: '石头爸爸', pet: '水獭·阿叠', text: '阿叠的 11 层塔终于不会倒了,鼓掌', paw: '3.2万', replies: '833', club: '水族' },
{ id: 7, owner: '蹲拍两小时', pet: '布偶·雪球', text: '雪球终于赏脸看了镜头 0.5 秒', paw: '2.8万', replies: '712', club: '猫圈' },
{ id: 8, owner: '礼让车队一员', pet: '刺猬·板栗', text: '板栗过马路的姿势像一颗滚动的栗子', paw: '2.4万', replies: '648', club: '小型圈' }
];
const PET_BREEDS: PetBreed[] = [
{ id: 1, name: '橘猫', origin: '中华田园', energy: 62, price: '领养', rating: '5.0' },
{ id: 2, name: '金毛寻回犬', origin: '苏格兰', energy: 88, price: '1500-3500', rating: '4.9' },
{ id: 3, name: '布偶猫', origin: '美国', energy: 45, price: '6000-15000', rating: '4.8' },
{ id: 4, name: '柯基', origin: '威尔士', energy: 90, price: '2000-5000', rating: '4.7' },
{ id: 5, name: '仓鼠', origin: '叙利亚', energy: 70, price: '10-50', rating: '4.6' },
{ id: 6, name: '玄凤鹦鹉', origin: '澳洲', energy: 76, price: '100-400', rating: '4.8' }
];
const PET_NOTICES: PetNotice[] = [
{ id: 1, kind: '互动', title: '鱼干他娘 回复了你', body: '"它把逗猫棒藏鞋里这招跟馒头学的"', time: '6分钟前', fresh: '1' },
{ id: 2, kind: '关注', title: '啮齿研究所 更新了图鉴', body: '《仓鼠藏粮点全景图 3.0 版》已发布', time: '25分钟前', fresh: '1' },
{ id: 3, kind: '系统', title: '你的帖子进入热门榜', body: '《弹簧沙发主的第 14 天》获爪 2.4万', time: '2小时前', fresh: '' },
{ id: 4, kind: '互动', title: '快递站小李 赞了你的帖子', body: '"年糕在废墟上睡得很香"', time: '4小时前', fresh: '' },
{ id: 5, kind: '系统', title: '宠粮补给券到账', body: '本月签到满 20 天,获得两张罐头券', time: '昨天', fresh: '' },
{ id: 6, kind: '关注', title: '水畔趣闻 开播了', body: '《水獭睡前叠石头》直播进行中', time: '昨天', fresh: '' }
];
const PET_SUBS: PetSub[] = [
{ id: 1, name: '猫圈头条', cycle: '每日 8:00 更新', tag: '猫', tip: '3篇未读' },
{ id: 2, name: '汪圈头条', cycle: '每日 9:00 更新', tag: '汪', tip: '新' },
{ id: 3, name: '鸟圈头条', cycle: '每日 10:00 更新', tag: '鸟', tip: '' },
{ id: 4, name: '水族圈快报', cycle: '每周二更新', tag: '水', tip: '2篇未读' },
{ id: 5, name: '小型圈周报', cycle: '每周四更新', tag: '小', tip: '' },
{ id: 6, name: '动物医院夜话', cycle: '每周五更新', tag: '医', tip: '直播预告' },
{ id: 7, name: '萌宠美食志', cycle: '每日 12:00 更新', tag: '食', tip: '新' },
{ id: 8, name: '领养直通车', cycle: '实时更新', tag: '领', tip: '' }
];
const PET_QUICKS: PetQuick[] = [
{ icon: '🍖', label: '投喂排行', color: '#F97316' },
{ icon: '🏥', label: '在线问诊', color: '#38BDF8' },
{ icon: '📅', label: '疫苗提醒', color: '#34D399' },
{ icon: '🛁', label: '洗护预约', color: '#A78BFA' },
{ icon: '🎥', label: '萌宠视频', color: '#F472B6' },
{ icon: '✍️', label: '发帖晒宠', color: '#FBBF24' },
{ icon: '🏠', label: '领养大厅', color: '#FB7185' },
{ icon: '🎫', label: '宠粮券', color: '#2DD4BF' }
];
const PET_COLUMNS: PetColumn[] = [
{ id: 1, name: '新手养猫十二课', master: '街区猫闻社', fee: '19 宠币', lessons: '12 讲' },
{ id: 2, name: '汪星人行为学', master: '邻里汪事记', fee: '36 宠币', lessons: '28 讲' },
{ id: 3, name: '仓鼠饲养全图鉴', master: '啮齿研究所', fee: '15 宠币', lessons: '16 讲' },
{ id: 4, name: '鹦鹉语训营', master: '羽毛快报', fee: '26 宠币', lessons: '20 讲' },
{ id: 5, name: '异宠入门手册', master: '夜行动物志', fee: '22 宠币', lessons: '14 讲' }
];
const PET_LIVES: PetLive[] = [
{ id: 1, title: '水獭睡前叠石头', host: '水畔趣闻', watching: '31.2万', scene: '治愈', air: '直播中' },
{ id: 2, title: '橘猫店长营业中', host: '街区猫闻社', watching: '24.6万', scene: '营业', air: '直播中' },
{ id: 3, title: '动物医院夜班实录', host: '动物医院夜话', watching: '11.4万', scene: '纪实', air: '直播中' },
{ id: 4, title: '金毛接送快递途中', host: '邻里汪事记', watching: '8.8万', scene: '跟拍', air: '预告' },
{ id: 5, title: '仓鼠囤粮夜巡', host: '啮齿研究所', watching: '6.2万', scene: '夜巡', air: '预告' },
{ id: 6, title: '鹦鹉晨间练声', host: '羽毛快报', watching: '4.9万', scene: '练声', air: '预告' }
];
const PET_COMMENTS: PetComment[] = [
{ id: 1, owner: '鱼干他娘', text: '店长的"签字"其实是盖爪印,超正式', paw: '2.6万', reply: '486', rank: '1楼' },
{ id: 2, owner: '快递站小李', text: '馒头今天多取了一单,物业准备给它涨"工资"', paw: '2.2万', reply: '391', rank: '3楼' },
{ id: 3, owner: '囤囤鼠家', text: '糯米看到地图发布后连夜转移了粮仓', paw: '1.9万', reply: '342', rank: '5楼' },
{ id: 4, owner: '外卖常客', text: '彩彩现在会喊"到了"和"辛苦了",学坏了', paw: '1.6万', reply: '287', rank: '8楼' },
{ id: 5, owner: '弹簧沙发主', text: '新沙发已下单,卖家问我要不要加固版', paw: '1.3万', reply: '231', rank: '11楼' },
{ id: 6, owner: '石头爸爸', text: '阿叠的塔塌了三次,第四次终于稳了', paw: '1.1万', reply: '196', rank: '14楼' },
{ id: 7, owner: '蹲拍两小时', text: '雪球那 0.5 秒我已经做成壁纸了', paw: '0.9万', reply: '173', rank: '17楼' },
{ id: 8, owner: '礼让车队一员', text: '板栗到家了,一路小跑没停过', paw: '0.7万', reply: '142', rank: '20楼' }
];
interface Vote2 {
id: number;
label: string;
pct: number;
votes: string;
}
const PET_VOTE: Vote2[] = [
{ id: 1, label: '橘猫店长', pct: 38, votes: '9.6万' },
{ id: 2, label: '金毛馒头', pct: 35, votes: '8.8万' },
{ id: 3, label: '水獭阿叠', pct: 16, votes: '4.1万' },
{ id: 4, label: '鹦鹉彩彩', pct: 11, votes: '2.8万' }
];
function getNewsLeft(): PetNews[] {
return PET_NEWS.slice(0, 5);
}
function getNewsRight(): PetNews[] {
return PET_NEWS.slice(5, 10);
}
function getNewsCount(): number {
return PET_NEWS.length;
}
function getHotTop(): PetHot[] {
return PET_HOT.slice(0, 3);
}
function getHotRest(): PetHot[] {
return PET_HOT.slice(3, 10);
}
function getHotCount(): number {
return PET_HOT.length;
}
function getVideoLeft(): PetVideo[] {
return PET_VIDEOS.slice(0, 4);
}
function getVideoRight(): PetVideo[] {
return PET_VIDEOS.slice(4, 8);
}
function getVideoCount(): number {
return PET_VIDEOS.length;
}
function getTopicLeft(): PetTopic[] {
return PET_TOPICS.slice(0, 4);
}
function getTopicRight(): PetTopic[] {
return PET_TOPICS.slice(4, 8);
}
function getTopicCount(): number {
return PET_TOPICS.length;
}
function getPostLeft(): PetPost[] {
return PET_POSTS.slice(0, 4);
}
function getPostRight(): PetPost[] {
return PET_POSTS.slice(4, 8);
}
function getPostCount(): number {
return PET_POSTS.length;
}
function getTopPosts(): PetPost[] {
return PET_POSTS.slice(0, 3);
}
function getBreedLeft(): PetBreed[] {
return PET_BREEDS.slice(0, 3);
}
function getBreedRight(): PetBreed[] {
return PET_BREEDS.slice(3, 6);
}
function getBreedCount(): number {
return PET_BREEDS.length;
}
function getNoticeCount(): number {
return PET_NOTICES.length;
}
function getSubLeft(): PetSub[] {
return PET_SUBS.slice(0, 4);
}
function getSubRight(): PetSub[] {
return PET_SUBS.slice(4, 8);
}
function getSubCount(): number {
return PET_SUBS.length;
}
function getQuickLeft(): PetQuick[] {
return PET_QUICKS.slice(0, 4);
}
function getQuickRight(): PetQuick[] {
return PET_QUICKS.slice(4, 8);
}
function getColumnLeft(): PetColumn[] {
return PET_COLUMNS.slice(0, 3);
}
function getColumnRight(): PetColumn[] {
return PET_COLUMNS.slice(3, 5);
}
function getColumnCount(): number {
return PET_COLUMNS.length;
}
function getLiveLeft(): PetLive[] {
return PET_LIVES.slice(0, 3);
}
function getLiveRight(): PetLive[] {
return PET_LIVES.slice(3, 6);
}
function getLiveCount(): number {
return PET_LIVES.length;
}
function getCommentList(): PetComment[] {
return PET_COMMENTS;
}
function getCommentCount(): number {
return PET_COMMENTS.length;
}
function getVoteList(): Vote2[] {
return PET_VOTE;
}
function getVoteCount(): number {
return PET_VOTE.length;
}
function findNewsByTitle(title: string): PetNews {
for (let i = 0; i < PET_NEWS.length; i++) {
if (PET_NEWS[i].title === title) {
return PET_NEWS[i];
}
}
return PET_NEWS[0];
}
function findHotByTag(tag: string): PetHot {
for (let i = 0; i < PET_HOT.length; i++) {
if (PET_HOT[i].tag === tag) {
return PET_HOT[i];
}
}
return PET_HOT[0];
}
function findVideoByTitle(title: string): PetVideo {
for (let i = 0; i < PET_VIDEOS.length; i++) {
if (PET_VIDEOS[i].title === title) {
return PET_VIDEOS[i];
}
}
return PET_VIDEOS[0];
}
function findTopicByName(name: string): PetTopic {
for (let i = 0; i < PET_TOPICS.length; i++) {
if (PET_TOPICS[i].name === name) {
return PET_TOPICS[i];
}
}
return PET_TOPICS[0];
}
function findPostByOwner(owner: string): PetPost {
for (let i = 0; i < PET_POSTS.length; i++) {
if (PET_POSTS[i].owner === owner) {
return PET_POSTS[i];
}
}
return PET_POSTS[0];
}
function findBreedByName(name: string): PetBreed {
for (let i = 0; i < PET_BREEDS.length; i++) {
if (PET_BREEDS[i].name === name) {
return PET_BREEDS[i];
}
}
return PET_BREEDS[0];
}
function findSubByName(name: string): PetSub {
for (let i = 0; i < PET_SUBS.length; i++) {
if (PET_SUBS[i].name === name) {
return PET_SUBS[i];
}
}
return PET_SUBS[0];
}
function findColumnByName(name: string): PetColumn {
for (let i = 0; i < PET_COLUMNS.length; i++) {
if (PET_COLUMNS[i].name === name) {
return PET_COLUMNS[i];
}
}
return PET_COLUMNS[0];
}
function findLiveByTitle(title: string): PetLive {
for (let i = 0; i < PET_LIVES.length; i++) {
if (PET_LIVES[i].title === title) {
return PET_LIVES[i];
}
}
return PET_LIVES[0];
}
function findNoticeByTitle(title: string): PetNotice {
for (let i = 0; i < PET_NOTICES.length; i++) {
if (PET_NOTICES[i].title === title) {
return PET_NOTICES[i];
}
}
return PET_NOTICES[0];
}
@Entry
@Component
struct Index {
@State tabIdx: number = 0;
@State showNews: boolean = false;
@State showReply: boolean = false;
@State showPost: boolean = false;
@State showTopicCard: boolean = false;
@State showVideo: boolean = false;
@State showBreed: boolean = false;
@State showVote: boolean = false;
@State showAdopt: boolean = false;
@State showNotice: boolean = false;
@State showSub: boolean = false;
@State showColumn: boolean = false;
@State showLive: boolean = false;
@State showVip: boolean = false;
@State showTreat: boolean = false;
@State showShare: boolean = false;
@State showSettings: boolean = false;
@State selNews: PetNews = PET_NEWS[0];
@State selHot: PetHot = PET_HOT[0];
@State selVideo: PetVideo = PET_VIDEOS[0];
@State selTopic: PetTopic = PET_TOPICS[0];
@State selPost: PetPost = PET_POSTS[0];
@State selBreed: PetBreed = PET_BREEDS[0];
@State selNotice: PetNotice = PET_NOTICES[0];
@State selSub: PetSub = PET_SUBS[0];
@State selColumn: PetColumn = PET_COLUMNS[0];
@State selLive: PetLive = PET_LIVES[0];
@State petCoins: number = 1560;
@State treatNum: number = 12;
@Builder
modalOverlay(onClose: () => void) {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('rgba(20,10,4,0.68)')
.onClick(() => {
onClose();
})
}
build() {
Column() {
Column() {
Row() {
Column() {
Text('🐾 萌宠圈')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
Text('PET BUZZ · 今日萌指数 98')
.fontSize(9)
.fontColor('#C89A6B')
.letterSpacing(1)
}
.alignItems(HorizontalAlign.Start)
Row() {
Text('🔍 搜萌闻·话题·品种')
.fontSize(11)
.fontColor('#B08A5F')
}
.layoutWeight(1)
.height(32)
.borderRadius(16)
.backgroundColor('rgba(249,115,22,0.16)')
.margin({ left: 12, right: 12 })
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showSettings = true;
})
Text('🍖')
.fontSize(20)
.onClick(() => {
this.showTreat = true;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
Row() {
Text('今日话题 #我家主子是戏精# 12.8万帖')
.fontSize(10)
.fontColor('#FBBF24')
Row() {
}
.layoutWeight(1)
Text('宠币 ' + this.petCoins)
.fontSize(10)
.fontColor('#F97316')
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 8 })
}
.width('100%')
.linearGradient({
angle: 200,
colors: [['#2A1608', 0.0], ['#3D1F0B', 0.6], ['#F97316', 1.0]]
})
.borderRadius({ bottomLeft: 20, bottomRight: 20 })
Column() {
Scroll() {
Column() {
if (this.tabIdx === 0) {
NewsTab({
onNews: (title: string) => {
this.selNews = findNewsByTitle(title);
this.showNews = true;
},
onQuick: (i: number) => {
if (i === 0) {
this.showTreat = true;
} else if (i === 1) {
this.showBreed = true;
this.selBreed = PET_BREEDS[0];
} else if (i === 2) {
this.showNotice = true;
this.selNotice = PET_NOTICES[0];
} else if (i === 3) {
this.showColumn = true;
this.selColumn = PET_COLUMNS[0];
} else if (i === 4) {
this.selVideo = PET_VIDEOS[0];
this.showVideo = true;
} else if (i === 5) {
this.showPost = true;
} else if (i === 6) {
this.showAdopt = true;
} else {
this.showTreat = true;
}
},
onPost: (owner: string) => {
this.selPost = findPostByOwner(owner);
this.showPost = true;
},
onSub: (name: string) => {
this.selSub = findSubByName(name);
this.showSub = true;
}
})
} else if (this.tabIdx === 1) {
VideoTabPet({
onVideo: (title: string) => {
this.selVideo = findVideoByTitle(title);
this.showVideo = true;
},
onLive: (title: string) => {
this.selLive = findLiveByTitle(title);
this.showLive = true;
},
onReply: () => {
this.showReply = true;
}
})
} else if (this.tabIdx === 2) {
TopicTabPet({
onTopic: (name: string) => {
this.selTopic = findTopicByName(name);
this.showTopicCard = true;
},
onVote: () => {
this.showVote = true;
},
onHot: (tag: string) => {
this.selHot = findHotByTag(tag);
this.showNews = true;
this.selNews = PET_NEWS[0];
}
})
} else if (this.tabIdx === 3) {
ClubTab({
onPost: (owner: string) => {
this.selPost = findPostByOwner(owner);
this.showPost = true;
},
onBreed: (name: string) => {
this.selBreed = findBreedByName(name);
this.showBreed = true;
},
onTopic: (name: string) => {
this.selTopic = findTopicByName(name);
this.showTopicCard = true;
},
onLive: (title: string) => {
this.selLive = findLiveByTitle(title);
this.showLive = true;
}
})
} else {
MineTabPet({
onSub: (name: string) => {
this.selSub = findSubByName(name);
this.showSub = true;
},
onNotice: (title: string) => {
this.selNotice = findNoticeByTitle(title);
this.showNotice = true;
},
onVip: () => {
this.showVip = true;
},
onSettings: () => {
this.showSettings = true;
},
onShare: () => {
this.showShare = true;
},
onColumn: (name: string) => {
this.selColumn = findColumnByName(name);
this.showColumn = true;
},
onTreat: () => {
this.showTreat = true;
}
})
}
}
.width('100%')
.padding({ bottom: 76 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
}
.layoutWeight(1)
Row() {
this.bottomTabItem('📰', '萌闻', 0)
this.bottomTabItem('🎥', '视频', 1)
this.bottomTabItem('💬', '话题', 2)
this.bottomTabItem('🐾', '宠圈', 3)
this.bottomTabItem('👤', '我的', 4)
}
.width('100%')
.height(56)
.backgroundColor('rgba(31,18,10,0.98)')
.borderRadius({ topLeft: 18, topRight: 18 })
}
.width('100%')
.height('100%')
.backgroundColor('#180E06')
}
@Builder
bottomTabItem(icon: string, label: string, idx: number) {
Column({ space: 3 }) {
Text(icon)
.fontSize(20)
.scale(this.tabIdx === idx ? { x: 1.22, y: 1.22 } : { x: 1, y: 1 })
.animation({ duration: 220, curve: Curve.EaseOut })
Text(label)
.fontSize(10)
.fontColor(this.tabIdx === idx ? '#F97316' : '#7A6248')
.fontWeight(this.tabIdx === idx ? FontWeight.Bold : FontWeight.Normal)
}
.width('20%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.tabIdx = idx;
})
}
@Builder
newsModal() {
Column() {
Column({ space: 12 }) {
Row() {
Column({ space: 4 }) {
Text('🐾 萌闻详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
Text('PET BUZZ · 精选资讯')
.fontSize(9)
.fontColor('#C89A6B')
.letterSpacing(1)
}
.alignItems(HorizontalAlign.Start)
Row() {
Text('×')
.fontSize(20)
.fontColor('#7A6248')
}
.width(30)
.height(30)
.borderRadius(15)
.backgroundColor('rgba(249,115,22,0.15)')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showNews = false;
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row({ space: 10 }) {
Text(this.selNews.emoji)
.fontSize(34)
Column({ space: 4 }) {
Row({ space: 6 }) {
if (this.selNews.badge !== '') {
Text(this.selNews.badge)
.fontSize(9)
.fontColor('#FFF7E8')
.backgroundColor('#F97316')
.borderRadius(4)
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
}
Text(this.selNews.topic)
.fontSize(10)
.fontColor('#FBBF24')
}
Text(this.selNews.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
.lineHeight(20)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
.padding(12)
.borderRadius(14)
.backgroundColor('rgba(249,115,22,0.08)')
Row() {
Text('✍️ ' + this.selNews.author)
.fontSize(11)
.fontColor('#C89A6B')
Text(this.selNews.time)
.fontSize(10)
.fontColor('#7A6248')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row({ space: 8 }) {
Column({ space: 3 }) {
Text(this.selNews.views)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#F97316')
Text('阅读')
.fontSize(9)
.fontColor('#7A6248')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor('rgba(249,115,22,0.10)')
Column({ space: 3 }) {
Text(this.selNews.replies)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#38BDF8')
Text('跟帖')
.fontSize(9)
.fontColor('#7A6248')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor('rgba(56,189,248,0.10)')
Column({ space: 3 }) {
Text('98')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#34D399')
Text('萌指数')
.fontSize(9)
.fontColor('#7A6248')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor('rgba(52,211,153,0.10)')
}
.width('100%')
Row({ space: 8 }) {
Text('🔥 相关热搜')
.fontSize(10)
.fontColor('#FBBF24')
Text('#' + this.selHot.tag + '# ' + this.selHot.reads)
.fontSize(10)
.fontColor('#FFF7E8')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(10)
.backgroundColor('rgba(251,191,36,0.12)')
}
.width('100%')
Row({ space: 10 }) {
Row({ space: 5 }) {
Text('🐾')
.fontSize(13)
Text('爪印 6.2万')
.fontSize(11)
.fontColor('#FFF7E8')
}
.layoutWeight(1)
.height(40)
.borderRadius(20)
.backgroundColor('#F97316')
.justifyContent(FlexAlign.Center)
Row({ space: 5 }) {
Text('💬')
.fontSize(13)
Text('跟帖')
.fontSize(11)
.fontColor('#FBBF24')
}
.layoutWeight(1)
.height(40)
.borderRadius(20)
.backgroundColor('rgba(251,191,36,0.14)')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showNews = false;
this.showReply = true;
})
}
.width('100%')
}
.width('86%')
.padding(16)
.borderRadius(20)
.backgroundColor('#241408')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(20,10,4,0.72)')
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
.onClick(() => {
this.showNews = false;
})
}
@Builder
replyModal() {
Column() {
Column() {
Row() {
Text('💬 热门跟帖 (' + getCommentCount() + ')')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
Text('收起')
.fontSize(11)
.fontColor('#F97316')
.onClick(() => {
this.showReply = false;
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Scroll() {
Column({ space: 10 }) {
ForEach(getCommentList(), (c: PetComment) => {
Row({ space: 10 }) {
Column() {
Text(c.owner.substring(0, 1))
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.width(34)
.height(34)
.borderRadius(17)
.backgroundColor('#FBBF24')
.justifyContent(FlexAlign.Center)
Column({ space: 5 }) {
Row({ space: 6 }) {
Text(c.owner)
.fontSize(12)
.fontColor('#FBBF24')
Text(c.rank)
.fontSize(9)
.fontColor('#7A6248')
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.borderRadius(6)
.backgroundColor('rgba(122,98,72,0.18)')
}
Text(c.text)
.fontSize(12)
.fontColor('#E8D5BC')
.lineHeight(17)
Row({ space: 12 }) {
Text('🐾 ' + c.paw)
.fontSize(10)
.fontColor('#F97316')
Text('↩ ' + c.reply)
.fontSize(10)
.fontColor('#7A6248')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
.padding({ top: 8, bottom: 8 })
.borderRadius(12)
.backgroundColor('rgba(249,115,22,0.06)')
})
}
.width('100%')
}
.layoutWeight(1)
.scrollBar(BarState.Off)
Row({ space: 8 }) {
Row() {
Text('说点萌的…')
.fontSize(11)
.fontColor('#7A6248')
}
.layoutWeight(1)
.height(38)
.borderRadius(19)
.backgroundColor('rgba(122,98,72,0.15)')
.justifyContent(FlexAlign.Center)
Row() {
Text('发送')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.width(72)
.height(38)
.borderRadius(19)
.backgroundColor('#FBBF24')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showReply = false;
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.constraintSize({ maxHeight: '78%' })
.padding(16)
.borderRadius({ topLeft: 22, topRight: 22 })
.backgroundColor('#241408')
.translate({ y: 18 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.End)
.backgroundColor('rgba(20,10,4,0.72)')
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
.onClick(() => {
this.showReply = false;
})
}
@Builder
postModal() {
Column() {
Column() {
Column() {
Text('✍️')
.fontSize(18)
Text('发帖晒宠')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.width('100%')
.padding({ top: 14, bottom: 14 })
.borderRadius({ topLeft: 22, topRight: 22 })
.backgroundColor('#FBBF24')
.justifyContent(FlexAlign.Center)
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('🐾')
.fontSize(14)
Text('宠物昵称')
.fontSize(11)
.fontColor('#C89A6B')
Text('橘猫·三斤')
.fontSize(11)
.fontColor('#FFF7E8')
}
.width('100%')
.height(44)
.borderRadius(12)
.backgroundColor('rgba(249,115,22,0.10)')
.justifyContent(FlexAlign.Center)
Column({ space: 6 }) {
Text('今天的主子做了什么萌事?')
.fontSize(11)
.fontColor('#7A6248')
Text('已输入 128 字 · 建议配上 3 张萌照')
.fontSize(9)
.fontColor('#F97316')
}
.width('100%')
.height(88)
.borderRadius(12)
.backgroundColor('rgba(122,98,72,0.12)')
.justifyContent(FlexAlign.Center)
Row({ space: 8 }) {
Text('📷')
.fontSize(16)
Text('🎥')
.fontSize(16)
Text('🏷️')
.fontSize(16)
Text('😀')
.fontSize(16)
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
Row({ space: 8 }) {
Text('#晒宠区#')
.fontSize(10)
.fontColor('#FBBF24')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(10)
.backgroundColor('rgba(251,191,36,0.15)')
Text('#我家主子是戏精#')
.fontSize(10)
.fontColor('#38BDF8')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(10)
.backgroundColor('rgba(56,189,248,0.12)')
Text('#萌宠早餐日记#')
.fontSize(10)
.fontColor('#34D399')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(10)
.backgroundColor('rgba(52,211,153,0.12)')
}
.width('100%')
Row({ space: 10 }) {
Row() {
Text('存草稿')
.fontSize(12)
.fontColor('#C89A6B')
}
.layoutWeight(1)
.height(40)
.borderRadius(20)
.backgroundColor('rgba(122,98,72,0.15)')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showPost = false;
})
Row() {
Text('🐾 发布')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.layoutWeight(1.6)
.height(40)
.borderRadius(20)
.backgroundColor('#F97316')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showPost = false;
this.petCoins = this.petCoins + 20;
})
}
.width('100%')
}
.padding(16)
}
.width('100%')
.borderRadius({ topLeft: 22, topRight: 22 })
.backgroundColor('#241408')
.translate({ y: 18 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.End)
.backgroundColor('rgba(20,10,4,0.72)')
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
.onClick(() => {
this.showPost = false;
})
}
@Builder
topicCardModal() {
Column() {
Column({ space: 14 }) {
Column({ space: 6 }) {
Text(this.selTopic.mood)
.fontSize(10)
.fontColor('#180E06')
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.borderRadius(10)
.backgroundColor('#FBBF24')
Text(this.selTopic.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
}
.width('100%')
.padding(18)
.borderRadius(16)
.linearGradient({
angle: 135,
colors: [[this.selTopic.color, 0.0], ['#3D1F0B', 1.0]]
})
Row({ space: 8 }) {
Column({ space: 3 }) {
Text(this.selTopic.joined)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#F97316')
Text('参与人数')
.fontSize(9)
.fontColor('#7A6248')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor('rgba(249,115,22,0.10)')
Column({ space: 3 }) {
Text(this.selTopic.posts)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#38BDF8')
Text('累计发帖')
.fontSize(9)
.fontColor('#7A6248')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor('rgba(56,189,248,0.10)')
Column({ space: 3 }) {
Text('火热')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FB7185')
Text('话题状态')
.fontSize(9)
.fontColor('#7A6248')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor('rgba(251,113,133,0.10)')
}
.width('100%')
Column({ space: 8 }) {
Row() {
Text('🔥 话题动态')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#FBBF24')
}
.width('100%')
Row({ space: 8 }) {
Text('鱼干他娘')
.fontSize(11)
.fontColor('#FBBF24')
Text('刚发布了新晒宠帖')
.fontSize(11)
.fontColor('#E8D5BC')
}
.width('100%')
Row({ space: 8 }) {
Text('囤囤鼠家')
.fontSize(11)
.fontColor('#38BDF8')
Text('晒出了仓鼠藏粮全景图')
.fontSize(11)
.fontColor('#E8D5BC')
}
.width('100%')
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor('rgba(122,98,72,0.10)')
Row({ space: 10 }) {
Row() {
Text('× 关闭')
.fontSize(12)
.fontColor('#C89A6B')
}
.layoutWeight(1)
.height(40)
.borderRadius(20)
.backgroundColor('rgba(122,98,72,0.15)')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showTopicCard = false;
})
Row() {
Text('✍️ 参与话题')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.layoutWeight(1.6)
.height(40)
.borderRadius(20)
.backgroundColor('#F97316')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showTopicCard = false;
this.showPost = true;
})
}
.width('100%')
}
.width('88%')
.padding(16)
.borderRadius(20)
.backgroundColor('#241408')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(20,10,4,0.72)')
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
.onClick(() => {
this.showTopicCard = false;
})
}
@Builder
videoModal() {
Column() {
Column({ space: 12 }) {
Column() {
Text(this.selVideo.theme)
.fontSize(11)
.fontColor('#180E06')
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.borderRadius(10)
.backgroundColor('#FBBF24')
Text('▶')
.fontSize(30)
.fontColor('#FFF7E8')
.margin({ top: 6 })
}
.width('100%')
.height(150)
.borderRadius(14)
.linearGradient({
angle: 160,
colors: [[this.selVideo.theme === '纪录' ? '#7C2D12' : '#431407', 0.0], ['#180E06', 1.0]]
})
.justifyContent(FlexAlign.Center)
Row() {
Column({ space: 4 }) {
Text(this.selVideo.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
.lineHeight(19)
Row({ space: 10 }) {
Text('@' + this.selVideo.vlogger)
.fontSize(10)
.fontColor('#FBBF24')
Text('⏱ ' + this.selVideo.mins)
.fontSize(10)
.fontColor('#7A6248')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row() {
Text('▶ ' + this.selVideo.plays)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#180E06')
}
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.borderRadius(16)
.backgroundColor('#F97316')
.onClick(() => {
this.showVideo = false;
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
Column({ space: 6 }) {
Row() {
Text('播放进度')
.fontSize(9)
.fontColor('#7A6248')
Text(this.selVideo.mins + ' / 已投喂 ' + this.selVideo.treats)
.fontSize(9)
.fontColor('#F97316')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
}
.width('68%')
.height(4)
.borderRadius(2)
.backgroundColor('#F97316')
}
.width('100%')
.padding(10)
.borderRadius(12)
.backgroundColor('rgba(249,115,22,0.08)')
Row({ space: 10 }) {
Row({ space: 5 }) {
Text('🍖')
.fontSize(13)
Text('投喂')
.fontSize(11)
.fontColor('#FFF7E8')
}
.layoutWeight(1)
.height(38)
.borderRadius(19)
.backgroundColor('rgba(249,115,22,0.16)')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showVideo = false;
this.showTreat = true;
})
Row({ space: 5 }) {
Text('💬')
.fontSize(13)
Text('弹幕')
.fontSize(11)
.fontColor('#FBBF24')
}
.layoutWeight(1)
.height(38)
.borderRadius(19)
.backgroundColor('rgba(251,191,36,0.14)')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showVideo = false;
this.showReply = true;
})
Row({ space: 5 }) {
Text('↗')
.fontSize(13)
Text('分享')
.fontSize(11)
.fontColor('#38BDF8')
}
.layoutWeight(1)
.height(38)
.borderRadius(19)
.backgroundColor('rgba(56,189,248,0.14)')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.showVideo = false;
this.showShare = true;
})
}
.width('100%')
}
.width('100%')
.padding(16)
.borderRadius({ topLeft: 22, topRight: 22 })
.backgroundColor('#241408')
.translate({ y: 18 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.End)
.backgroundColor('rgba(20,10,4,0.72)')
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
.onClick(() => {
this.showVideo = false;
})
}
@Builder
breedModal() {
Column() {
Column({ space: 12 }) {
Row() {
Column({ space: 3 }) {
Text('📋 品种档案')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FFF7E8')
Text('BREED FILE · 共 ' + getBreedCount() + ' 个品种')
.fontSize(9)
.fontColor('#C89A6B')
.letterSpacing(1)
Text('👑 萌宠会员')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
}

从布局技术层面来看,应用大量使用了Column和Row容器进行线性布局,通过layoutWeight实现弹性空间分配,通过space参数控制子元素间距。双列等分布局(左右各layoutWeight(1))在新闻列表、视频列表、话题列表、品种列表、帖子列表、专栏列表、订阅列表等7个区域被重复使用,构成了应用的核心布局模式。水平滚动列表(通过Scroll+scrollable(ScrollDirection.Horizontal)实现)在话题标签栏、视频跟拍栏、热帖速览栏、话题讨论栏等4个区域使用。条件渲染(if语句)在标签页切换、弹窗显示/隐藏、可选UI元素展示(如新闻标签、未读标记、排名图标)等场景中被广泛使用。
从视觉设计层面来看,应用采用了暖色调暗色主题,主色调包括深棕色背景(#180E06、#241408)、暖白色文字(#FFF7E8)、橙色强调色(#F97316)、金色高亮色(#FBBF24)等多种色彩。不同功能区域通过背景色的色调差异进行视觉区分——投喂弹窗使用明亮的黄色背景(#FDE68A)传递积极情感,投票卡片使用浅黄色背景(#FDE68A)吸引用户注意,其他弹窗使用深棕色背景(#241408)保持与整体主题的一致性。线性渐变背景在导航栏、精选视频卡片、直播卡片、会员卡片等关键视觉区域使用,通过不同角度和颜色停止点的配置创造出丰富的视觉效果。
从交互设计层面来看,应用实现了多种交互反馈机制:底部导航栏的选中项通过缩放动画(1.22倍放大)和颜色变化提供视觉反馈;弹窗通过透明度过渡动画(200毫秒淡入淡出)实现平滑的显示/隐藏效果;投喂操作通过同时修改投喂次数和宠币余额两个状态变量提供即时的数值反馈;发帖操作通过增加20宠币提供正向激励反馈。这些交互反馈机制共同构成了应用的用户体验基础。
从代码质量层面来看,应用遵循了ArkTS的编码规范——所有变量和函数都有明确的类型注解,接口定义完整且职责单一,辅助函数命名遵循统一的规范(getXxxLeft/getXxxRight/getXxxCount/findXxxByYyy),组件属性初始化使用空函数作为默认值确保了防御性编程。虽然存在代码重复(如sectionTitle构建器在5个组件中重复定义、双列列表布局模式在7个区域重复使用),但这是ArkTS当前版本限制下的合理折衷——随着ArkTS语言的演进,未来可能通过UI Mixin或高阶组件等机制解决这些重复问题。
更多推荐




所有评论(0)