HarmonyOS ArkTS API 24观鸟与猛禽救助平台状态标签的颜色(通过statusColor函数)、进度条的颜色和进度值(通过statusProgress函数)
开篇:声明式UI开发范式与HarmonyOS生态技术全景

在鸿蒙生态持续演进的今天,声明式UI开发范式已经成为构建跨端应用的主流选择。HarmonyOS ArkUI框架以其简洁、高效、可组合的特性,为开发者提供了一套完整的声明式UI解决方案。与传统的命令式UI开发不同,声明式UI强调"描述UI应该是什么样子"而非"一步步告诉UI如何变化",这极大地提升了开发效率和代码可维护性。
HarmonyOS 6.1.1及ArkTS API 24带来了更加完善的状态管理机制、更丰富的布局容器、更强大的动画能力以及更精细化的渲染控制。在API 24中,@Observed装饰器与@ObjectLink的配合使用使得嵌套对象的细粒度监听成为可能,@Builder装饰器则允许开发者将UI片段抽象为可复用的构建函数,从而实现UI逻辑的模块化与组合化。
在状态管理层面,HarmonyOS ArkUI提供了多层次的状态管理方案。从最基础的@State到组件间共享的@Provide/@Consume,再到跨页面全局的AppStorage/LocalStorage,开发者可以根据数据的作用域和复杂度选择合适的状态管理方式。而@Observed装饰器则专门用于标记需要被观察的类,使得该类的实例在被@ObjectLink引用时,其属性变化能够触发UI的精准刷新,避免了不必要的整体重绘。
@Builder装饰器是ArkUI声明式开发中的一把利器。它允许开发者将重复的UI结构抽取为独立的构建函数,函数内部可以使用完整的ArkUI声明式语法,包括条件渲染、循环渲染、属性设置等。@Builder既可以是组件内的成员方法,也可以是全局的自定义构建函数,配合@Styles和@Extend装饰器,可以实现从样式到结构的全方位复用。
在布局系统方面,ArkUI提供了Stack、Column、Row三大基础布局容器,它们构成了复杂界面的基石。Stack采用堆叠布局,子组件按照声明顺序层层叠加,适合构建具有层级关系的界面;Column采用垂直方向的线性布局,子组件从上到下依次排列;Row采用水平方向的线性布局,子组件从左到右依次排列。通过这三种基础容器的灵活组合与嵌套,几乎可以构建出任何复杂的界面结构。
ArkUI的渲染管线经历了从组件树构建、布局计算、绘制指令生成到GPU渲染的完整流程。在声明式范式下,框架会自动追踪状态依赖,当状态发生变化时,仅重新构建受影响的组件节点,从而实现高效的局部刷新。这种精细化的差异更新机制,是ArkUI能够在复杂界面下保持流畅体验的核心保障。
湖水蓝色彩设计系统在观鸟类应用中具有独特的语义价值。湖水蓝(#4A7C8C)作为主色调,既呼应了湿地、湖泊等观鸟栖息地的自然环境,又传递出宁静、专注的观察氛围。橙色(#E8945A)作为强调色,象征着朝阳、活力与热情,与湖水蓝形成冷暖对比,为界面注入生动的视觉节奏。羽色(#7FB3C4)作为特殊点缀色,恰似羽毛在阳光下的光泽,强化了"羽语"这一品牌意象。
观鸟与猛禽救助社区的交互模式具有鲜明的垂直领域特征。它融合了图鉴浏览、路线预约、观察记录、社区分享、装备推荐、在线课堂等多种功能形态,用户既可以是观察者、记录者,也可以是救助志愿者、社区贡献者。这种多角色、多场景的交互模式要求应用架构具有良好的可扩展性,不同功能模块之间既要保持独立,又要在数据层面实现统一管理。
接下来,本文将从颜色系统、数据模型、静态数据、导航配置、工具函数、组件架构、特效实现等多个维度,对"羽语·城市观鸟与猛禽救助预约平台"进行深度的技术剖析,力求展现HarmonyOS ArkTS声明式开发的完整实践图景。
一、ColorPalette接口设计:湖水蓝+橙色主题的十六色系统化构建

1.1 接口定义的设计考量
在任何一款设计精良的应用中,色彩系统都是视觉语言的基石。ColorPalette接口通过TypeScript的interface语法定义了一套完整的色彩规范,将应用中所有使用到的颜色抽象为具名的语义字段,从而实现了"一处定义、全局引用、主题可换"的架构目标。
接口定义采用了语义化命名而非物理命名的策略。这意味着每个颜色字段都描述了它的用途(如primary、accent、success)而非具体的颜色值(如blue、orange、green)。这种设计的优势在于,当需要切换主题时,只需替换COLORS常量对象的具体色值即可,所有引用了颜色字段的UI代码无需任何修改。
1.2 十六个颜色字段逐字段深度解析
primary(主色 #4A7C8C):湖水蓝,是整个色彩系统的核心锚点。它来源于湿地湖泊水面在晨光中的真实色调,既具有蓝色系的宁静感,又带有绿色系的自然气息。主色广泛应用于品牌标识、重要按钮、选中状态、进度条填充等关键视觉元素,是用户识别品牌的第一色彩印象。
primaryLight(主色亮调 #D6E6EA):湖水蓝的浅色版本,饱和度降低、明度提升,呈现出一种清透的淡青色。主要用于选中态的背景填充、次级强调区域、分割线与边框的柔和过渡等场景。它与primary的搭配形成了同色系的深浅层次,使得界面在统一色调中仍具有丰富的明暗对比。
primaryDark(主色暗调 #2E5561):湖水蓝的深色版本,明度降低、饱和度微调,呈现深邃的湖底青色调。主要用于文字在浅色背景上的高对比度显示、深色按钮的文字颜色、以及需要与primary形成深浅对照的视觉元素。primaryDark确保了在湖水蓝主题下文字的可读性和信息的层次感。
accent(强调色 #E8945A):橙色,是整个色彩系统的视觉亮点。它温暖、活泼、充满能量,与湖水蓝形成鲜明的冷暖对比。强调色主要用于行动号召按钮(CTA)、签到、报名等重要操作入口、标签高亮、以及需要吸引用户注意力的关键信息点。橙色的运用打破了湖水蓝的沉静,为界面注入了动态的活力。
accentLight(强调色亮调 #FAE6D6):橙色的浅色版本,呈现柔和的蜜桃色调。主要用于状态标签的背景填充、橙色元素的柔和衬托、以及需要弱化强调但仍保持暖色调统一的区域。它与accent的搭配同样遵循同色系深浅原则,在保持视觉一致性的同时提供了更多的色彩层次选择。
bg(背景色 #F2F6F5):页面整体背景色,是一种略带青绿倾向的浅灰白色。它不是纯粹的白色,而是融入了湖水蓝的极浅色调,使得整个界面在白色基调中微微透出湖水的清凉感。背景色的选择体现了设计的精致度——它几乎难以被察觉,但却在潜意识层面强化了观鸟主题的自然氛围。
cardBg(卡片背景 #FFFFFF):纯白色,用于各类卡片、弹框、输入框等容器组件的背景。纯白的卡片背景与略带色调的页面背景形成微妙的层次区分,既保证了卡片内容的可读性,又通过背景色的差异自然地划分了信息区块。
textPrimary(主要文字 #22383F):主要文字颜色,是一种深青灰色,而非纯黑。它的选择同样遵循了湖水蓝主题的整体调性——在保证足够对比度的前提下,融入了湖水蓝的色相倾向,使得文字与整体界面更加和谐统一。主要文字用于标题、正文等核心信息的展示。
textSecondary(次要文字 #5D7681):次要文字颜色,是中等明度的青灰色。用于辅助说明、副标题、描述性文字等信息层级稍低的内容。它与textPrimary形成了清晰的文字层级,帮助用户快速识别信息的重要程度。
textHint(提示文字 #A4BBC3):提示文字颜色,是浅青灰色。用于占位符、时间戳、辅助标签等信息层级最低的内容。它的明度较高、对比度较低,确保了在不干扰主要信息阅读的前提下,提供必要的补充说明。
border(边框色 #E1ECEA):边框与分割线颜色,是极浅的青白色。用于卡片边框、分割线、输入框边框等场景。边框色的选择同样融入了湖水蓝的色调倾向,使得界面中的每一条分割线都与整体主题保持一致。
success(成功色 #6FA37D):成功状态色,是柔和的苔藓绿色。绿色象征着生长、健康与完成,在观鸟应用中代表着观察记录已完成、鸟种已确认、任务已达成等积极状态。它的饱和度适中,既不会过于刺眼,又能清晰地传达成功的语义。
warning(警告色 #D9A05B):警告状态色,是温暖的琥珀金色。用于需要引起注意但并非紧急的状态,如待确认、进行中等。金色调与橙色强调色系出同门,在视觉上具有关联性,同时保持了足够的区分度。
danger(危险色 #C76B5A):危险/错误状态色,是柔和的赤陶红色。用于删除、取消、退课等不可逆或具有破坏性的操作,以及错误提示、稀有警告等场景。它的红色并不刺眼,而是带有橙棕的暖调,与整体的自然主题相协调。
white(纯白 #FFFFFF):标准纯白,用于需要绝对白色的场景,如深色背景上的文字、图标等。与cardBg的区别在于语义层面——white强调颜色本身,cardBg强调用途。
feather(羽色 #7FB3C4):羽毛色,是整个色彩系统中最具特色的特殊色。它是一种明亮的浅灰蓝色,恰似水鸟羽毛在阳光下泛出的光泽。羽色主要用于羽毛飘落特效、标签的高亮背景、以及品牌相关的装饰元素。它的存在使得"羽语"这一品牌名称不再是抽象的概念,而是有了具体的视觉载体。
1.3 色彩系统的架构价值

ColorPalette接口与COLORS常量的组合,构成了应用的设计令牌(Design Tokens)层。这一层级的抽象具有多重价值:首先,它确保了全局色彩的一致性,避免了在代码各处散落硬编码色值导致的视觉混乱;其次,它使得主题切换成为可能,只需定义多套ColorPalette实现即可快速更换应用的整体视觉风格;第三,它建立了设计与开发之间的沟通桥梁,设计师在设计稿中定义的色彩规范可以直接映射为代码中的具名字段,减少了沟通成本和还原误差。
二、@Observed数据模型:四类核心业务实体的字段级解析

在HarmonyOS ArkUI的状态管理体系中,@Observed装饰器扮演着至关重要的角色。它用于装饰类,使得该类的实例属性能够被ArkUI的状态管理机制精准追踪。当被@Observed装饰的类的实例通过@ObjectLink或@State引用时,其内部属性的变化会触发对应UI节点的精准刷新,而不是整个组件的重建。
2.1 PostItem:鸟友动态数据模型
PostItem模型代表了鸟友社区中的一条动态消息,是社区互动模块的核心数据结构。
id(number):动态的唯一标识符,类型为数字。在ForEach循环渲染中,id作为键值(key)使用,确保列表的高效diff和正确的节点复用。每条动态都有独一无二的id,新增动态时(如用户发布"羽语")会分配一个新的id值。
nick(string):发布者的昵称,类型为字符串。昵称是社区身份的基本标识,在动态卡片中以加粗的主要文字样式展示,位于卡片的左上方。从静态数据中可以看到,昵称充满了观鸟社区的特色,如"芦苇荡蹲守人"、“望远镜不离手”、"湿地清晨党"等,生动地勾勒出鸟友群体的画像。
avatar(string):发布者的头像,使用emoji字符表示。这种设计选择既简洁又富有表现力,避免了图片资源加载的性能开销,同时emoji的多样性足以表达不同的个性特征。头像的字体大小设置为22-26像素,在视觉上形成清晰的身份标识。
text(string):动态的正文内容,类型为字符串。这是动态信息的核心载体,记录了鸟友分享的观察经历、发现心得、活动信息等。正文内容通常为一到两句话,简洁生动,充满现场感,如"震旦鸦雀终于蹲到了,芦苇里蹦了半小时"、"红隼悬停捕食,正好停在视野正中央"等。
time(string):动态的发布时间,类型为字符串,采用相对时间的表述方式,如"6分钟前"、“19分钟前”、“1小时前”、"昨天"等。相对时间的呈现方式比绝对时间更具人情味,也更符合社交应用的阅读习惯。时间信息以提示文字颜色展示,位于昵称的右侧,形成时间维度的信息补充。
likes(number):点赞数(羽语数),类型为数字。代表这条动态获得的社区认可程度。点赞数与羽毛emoji组合展示(如"🪶 132"),形成"羽语"品牌特有的互动符号,替代了传统社交应用中的"点赞"概念,强化了观鸟主题的品牌识别。
PostItem的构造函数接收全部六个参数,按照id、nick、avatar、text、time、likes的顺序初始化实例属性。这种标准的构造函数模式确保了数据对象创建的一致性和类型安全。
2.2 BirdItem:鸟种图鉴数据模型

BirdItem模型代表了鸟种图鉴中的一种鸟类,是图鉴浏览和鸟种识别模块的基础数据结构。
id(number):鸟种的唯一标识符,类型为数字。同样用于列表渲染的键值,确保鸟种列表的高效更新。
name(string):鸟种的中文名,类型为字符串。这是鸟种信息中最重要的字段,以加粗的主要文字样式展示,是用户识别和记忆鸟种的第一标识。从数据中可以看到,收录的鸟种涵盖了雀形目、隼形目、雁形目等多个类群,包括震旦鸦雀、普通翠鸟、红隼、黑枕黄鹂、鸳鸯、游隼、白腰雨燕、寿带鸟等。
habitat(string):栖息地信息,类型为字符串。描述该鸟种主要的生活环境,如"芦苇湿地"、“溪流荷塘”、“城市上空”、“阔叶林带”、"城市公园湖"等。栖息地信息对于观鸟者来说具有重要的实用价值——知道在哪里能找到什么鸟,是观鸟活动的核心知识之一。
rarity(string):稀有度等级,类型为字符串。取值包括"常见"、“季节”、“少见”、"稀有"四个等级。稀有度信息以标签形式展示在鸟种卡片上,标签的背景色通过rarityColor函数根据稀有度动态计算,稀有度越高,标签颜色越趋近于警示红色,直观地传达了鸟种的珍贵程度。
seen(number):目击次数/人气值,类型为数字。表示该鸟种被观察记录到的次数,数值越高表示越常见或越受关注。目击次数同时作为进度条的百分比值使用,以可视化的方式展示鸟种的"人气值"。进度条的颜色通过birdTagColor函数根据鸟名关键词动态确定,不同类群的鸟种对应不同的主题色。
2.3 WatchItem:观察记录数据模型

WatchItem模型代表了用户的一条观察记录,是个人观察档案和观鸟路线模块的核心数据结构。
id(number):观察记录的唯一标识符,类型为数字。
name(string):观察记录的名称,类型为字符串。通常包含地点和观察点信息,如"北湿地·3号观鸟屋"、“东滩·芦苇栈道”、"西山·猛禽监测点"等。名称是观察记录的主要标识,以加粗标题形式展示。
item(string):观察内容/目标,类型为字符串。描述本次观察的具体对象,如"雁鸭类·12种"、“震旦鸦雀蹲守”、“过境猛禽计数”、"猫头鹰声音调查"等。观察内容以辅助文字形式展示,提供了观察活动的具体信息。
status(string):观察状态,类型为字符串。取值包括"已记录"、“观察中”、“待确认”、"鉴定中"等多个状态。状态字段是观察记录的核心属性之一,它决定了状态标签的颜色(通过statusColor函数)、进度条的颜色和进度值(通过statusProgress函数)。不同的状态代表了观察活动所处的不同阶段,从待确认到鉴定中,再到观察中,最后到已记录,形成了一条完整的观察生命周期。
progress(number):观察进度,类型为数字。表示观察活动的完成百分比,取值范围为0到100。进度值以进度条的形式可视化展示,让用户一目了然地了解观察活动的完成情况。进度字段与status字段配合使用,statusProgress函数可以根据状态返回默认进度值,而progress字段则存储具体的个性化进度数据。
2.4 GearItem:装备数据模型
GearItem模型代表了观鸟装备库中的一件装备,是装备推荐和电商模块的基础数据结构。
id(number):装备的唯一标识符,类型为数字。
name(string):装备名称,类型为字符串。如"双筒望远镜 8×42"、“单筒观鸟镜 25-70×”、“观鸟三脚架”、"迷彩伪装网"等。装备名称清晰地描述了产品的品类和关键参数。
desc(string):装备描述,类型为字符串。用简短的一句话概括装备的特点和用途,如"入门观鸟标配"、“远距离水鸟”、“云台缓动款”、"蹲守必备"等。描述信息帮助用户快速了解装备的定位和适用场景。
icon(string):装备图标,使用emoji字符表示。与PostItem的avatar字段类似,使用emoji作为图标既简洁又富有表现力。不同的装备对应不同的emoji,望远镜对应🔭、水鸟对应🦆、三脚架对应🎯、伪装网对应🕸️、图鉴对应📖、记录本对应📒、相机对应📷、驱蚊喷雾对应🦟等。
2.5 辅助接口定义

除了四个主要的@Observed类模型外,代码中还定义了四个辅助接口,用于支撑图表和课程等功能模块的数据结构。
WeekChartItem:周观察图表数据项接口,包含label(标签,如"周一")和value(数值)两个字段。用于构建一周七天的观鸟打卡次数柱状图。
BirdChartItem:鸟种构成图表数据项接口,在WeekChartItem的基础上增加了color(颜色)字段。用于构建鸟种类群构成的横向条形图,每个类群对应不同的颜色。
CourseItem:课程数据项接口,包含title(课程标题)、week(周次)、progress(学习进度)、color(进度条颜色)四个字段。用于鸟类课堂模块的课程列表展示。
WatchTypeItem:观鸟类型数据项接口,包含name(名称)、desc(描述)、icon(图标)、hot(是否热门)四个字段。用于首页的"玩法四格"功能入口展示。
NavItem:导航项接口,包含icon(图标)和label(标签)两个字段。用于底部导航栏的配置。
这些辅助接口虽然没有使用@Observed装饰器,但它们同样构成了应用数据模型的重要组成部分。它们主要用于静态展示数据的结构化描述,其数据在运行时不会频繁变化,因此不需要细粒度的状态观察能力。
三、静态数据数组:八大数据集的业务全景呈现

静态数据数组是应用内容的基石,它们以硬编码的方式提供了各类业务模块的初始数据。虽然在生产环境中这些数据通常来自后端API,但在原型开发和UI演示阶段,静态数据数组具有不可替代的价值——它们使得UI开发可以独立于后端进行,同时保证了演示效果的稳定性和可重现性。
3.1 POST_LIST:鸟友动态数据集
POST_LIST数组包含8条鸟友动态数据,涵盖了不同类型的观鸟内容:珍稀鸟种发现(震旦鸦雀)、猛禽捕食观察(红隼悬停)、群鸟栖息景观(白鹭起飞)、救助活动招募(猛禽救助开放日)、新手入门记录(人生第一只翠鸟)、摄影创作分享(游隼俯冲连拍)、救助案例记录(撞玻璃丘鹬康复)、城市观鸟数据(21楼阳台过境猛禽)。
这8条动态数据精心设计了内容的多样性,从新手到老鸟、从观察到救助、从记录到创作,全方位地展现了观鸟社区的丰富生态。点赞数从132到58递减,模拟了动态列表按热度或时间排序的真实效果。
3.2 BIRD_LIST:鸟种图鉴数据集
BIRD_LIST数组包含8种鸟种数据,涵盖了不同类群和稀有度等级的鸟类。震旦鸦雀(稀有)、普通翠鸟(常见)、红隼(常见)、黑枕黄鹂(少见)、鸳鸯(季节)、游隼(少见)、白腰雨燕(季节)、寿带鸟(稀有)。这8种鸟的选择既考虑了知名度,又兼顾了生态多样性。
栖息地信息涵盖了芦苇湿地、溪流荷塘、城市上空、阔叶林带、城市公园湖、高楼崖壁、古建屋檐、丘陵灌丛等多种生境类型,展示了观鸟活动场景的丰富性。目击次数从98到64不等,形成了自然的人气梯度。
3.3 WATCH_LIST:观察记录数据集
WATCH_LIST数组包含8条观察记录,模拟了用户多样化的观鸟活动。从北湿地的雁鸭类观察到西山的猛禽监测,从古建群的雨燕巢位登记到城南林带的猫头鹰夜观,记录类型丰富多样。状态分布包括:2条"已记录"、2条"观察中"、3条"待确认"、1条"鉴定中",呈现出真实的观察活动分布。
进度值从100%到12%不等,与状态字段相互呼应——"已记录"的进度为100%或90%,"观察中"的进度在46%-72%之间,"待确认"的进度在12%-58%之间,"鉴定中"的进度为26%。这种数据设计使得进度条与状态标签在视觉上形成了有意义的关联。
3.4 GEAR_LIST:装备库数据集
GEAR_LIST数组包含8件观鸟装备,覆盖了从入门到专业的完整装备链条。光学类包括双筒望远镜和单筒观鸟镜,支撑类包括三脚架,伪装类包括迷彩伪装网,参考类包括鸟类图鉴和防水记录本,影像类包括长焦相机,防护类包括驱蚊喷雾。
装备的选择体现了对观鸟活动的深入理解——不仅有核心器材,还有记录本、驱蚊喷雾等容易被忽视但实际非常重要的周边装备。这种全面性增强了应用的专业感和实用价值。
3.5 WEEK_CHART:周观察图表数据
WEEK_CHART数组包含7天的观鸟打卡数据,从周一到周日的数值分别为3、4、6、5、9、16、14。数据呈现出明显的周末效应——周六和周日的打卡次数显著高于工作日,周六达到峰值16次,周日为14次,工作日则在3-9次之间波动。
这种数据分布非常贴近真实的观鸟行为模式——工作日人们通常只有清晨或傍晚的碎片时间可以观鸟,而周末则有更充裕的时间进行完整的观鸟活动。"周末出勤占比68%"的统计数据正是基于这组数据计算得出的。
3.6 BIRD_CHART:鸟种构成图表数据
BIRD_CHART数组包含5个鸟类类群的构成比例数据:雁鸭类28%、鸻鹬类24%、鸣禽雀形22%、猛禽隼形16%、鹭鹳鹮类10%。这五个类群覆盖了湿地观鸟中最常见的主要鸟类类群,比例分配也基本符合湿地生态系统的鸟类群落结构特征。
每个类群都配有对应的主题色:雁鸭类用湖水蓝(#4A7C8C)、鸻鹬类用橙色(#E8945A)、鸣禽雀形用成功绿(#6FA37D)、猛禽隼形用警告金(#D9A05B)、鹭鹳鹮类用危险红(#C76B5A)。颜色的选择既保持了与整体色彩系统的一致性,又为每个类群赋予了独特的视觉标识。
3.7 TYPE_LIST:观鸟类型数据集
TYPE_LIST数组包含4种观鸟玩法类型:观鸟导赏、鸟种图鉴、猛禽救助、照片鉴定。每种类型都有对应的图标、描述和热门标签。其中"观鸟导赏"和"鸟种图鉴"标记为热门,"猛禽救助"和"照片鉴定"为普通。
这四种玩法构成了应用的核心功能矩阵,从信息查询(图鉴)到活动参与(导赏),从公益服务(救助)到技术支持(鉴定),覆盖了观鸟爱好者的多样化需求。
3.8 COURSE_LIST:课程列表数据集
COURSE_LIST数组包含5门鸟类学习课程,按照学习进度依次排列:观鸟入门与器材选择(100%)、常见水鸟快速识别(84%)、雀形目鸣禽辨声(52%)、猛禽过境监测方法(30%)、鸟类调查数据上报(8%)。
课程的设计遵循了由浅入深的学习路径——从入门器材到水鸟识别,再到鸣禽辨声、猛禽监测,最后到数据上报,形成了一条完整的学习进阶路线。学习进度依次递减,模拟了用户逐步学习的真实状态。每门课程配有不同的主题色,与鸟种构成图表的配色体系相呼应。
四、导航配置:双层Tab架构与居中圆点指示式导航设计
4.1 底部主导航:NAV_LIST四Tab架构
NAV_LIST数组定义了应用的底部主导航栏,包含4个主Tab:首页、图鉴、课堂、我的。每个导航项由icon(emoji图标)和label(文字标签)两部分组成。
首页(🪶):应用的核心入口,聚合了精选内容、鸟种橱窗、我的观察、观鸟路线、鸟类课堂、鸟友圈、装备库等7个子模块的内容入口。首页是用户打开应用后看到的第一个页面,承担着信息聚合和功能分发的重要职责。
图鉴(📖):鸟种图鉴模块的独立入口。点击后直接进入鸟种橱窗页面,用户可以浏览完整的鸟种列表,查看每种鸟的详细信息。
课堂(📚):鸟类课堂模块的独立入口。点击后直接进入课程列表页面,用户可以查看和学习各类观鸟课程。
我的(👤):个人中心入口,展示用户信息、观察档案、羽语动态等个人数据。
四Tab的底部导航是移动应用中非常经典的信息架构模式,它将应用的核心功能清晰地分为四大板块,用户可以通过一次点击在各板块间快速切换。在视觉设计上,选中态的图标不透明(opacity为1)、文字加粗并使用主色调,未选中态的图标半透明(opacity为0.55)、文字使用提示色,形成了清晰的选中/未选中视觉对比。
4.2 首页子导航:SUB_NAV_LIST七内容Tab
SUB_NAV_LIST数组定义了首页的子导航栏,包含7个内容Tab:精选、鸟种橱窗、我的观察、观鸟路线、鸟类课堂、鸟友圈、装备库。
这7个子Tab构成了首页内容的完整分类体系。每个Tab对应一个独立的页面Builder(pageFeatured、pageBirds、pageWatches、pageRoute、pageCourse、pageCircle、pageGear),用户通过水平滚动的子导航栏可以在7个内容模块间自由切换。
4.3 居中圆点指示式导航的设计实现
子导航的一个显著设计特色是采用了"居中圆点指示"的选中标识方式,而非传统的下划线指示。这种设计更加轻盈、精致,与湖水蓝自然主题的视觉调性高度契合。
其实现原理如下:每个导航项是一个Column容器,内部包含Text(文字)和一个6×6像素的圆点Column。当该Tab被选中时,圆点使用橙色(COLORS.accent)填充,完全可见;当未选中时,圆点保持透明(opacity为0),不可见。
选中态的文字字号更大(13px vs 11px)、字重更粗(Bold vs Normal)、颜色更深(primaryDark vs textSecondary),同时背景使用主色亮调(primaryLight)填充。未选中态的文字更小、更细、颜色更浅,背景与页面背景一致。
这种多维度的选中态反馈(字号、字重、文字颜色、背景色、圆点指示器)确保了选中状态的清晰可辨,同时整体视觉效果柔和统一,没有传统下划线指示带来的割裂感。
导航栏使用Scroll组件包裹,设置为水平滚动方向,隐藏滚动条。当7个Tab的总宽度超出屏幕时,用户可以左右滑动查看更多选项,保证了在不同尺寸设备上的可用性。
五、纯函数工具集:特效、颜色映射与数据转换的函数式编程实践
纯函数是函数式编程的核心概念,它具有相同输入永远产生相同输出、不产生任何副作用的特性。在ArkUI开发中,纯函数被广泛用于计算UI展示所需的各种派生数据——颜色、尺寸、位置、透明度等。它们将复杂的计算逻辑从UI声明中抽离出来,使得UI代码更加简洁易读,同时计算逻辑本身也更容易测试和复用。
5.1 barH:柱状图高度计算函数
barH函数接收两个参数:当前值v和最大值max,返回柱状图的高度(像素值)。计算公式为Math.round(118 * v / max),即柱状图的最大高度为118像素,当前值按比例缩放后取整。
这个函数被用于周观察柱状图的渲染,将抽象的打卡次数转换为可视化的柱形高度。函数的实现简洁高效,Math.round确保了高度值为整数像素,避免了亚像素渲染导致的模糊问题。
5.2 birdColor:鸟种颜色映射函数
birdColor函数接收一个数值参数v,根据数值大小返回不同的颜色值:大于9返回湖水蓝(#4A7C8C),大于6返回橙色(#E8945A),其他情况返回警告金(#D9A05B)。
这个函数被用于周观察柱状图中每个柱形的填充颜色,数值越高颜色越偏向湖水蓝(主色调),数值越低颜色越偏向警告金。这种颜色映射方式为枯燥的数字增加了直观的视觉含义——高数值的柱子以主色调突出显示,低数值的柱子以暖色调弱化显示。
5.3 featherA/X/Y/S:羽毛飘落特效函数族
羽毛飘落特效由四个纯函数驱动,每个函数控制羽毛的一个视觉属性,它们都接收tick(时间帧计数器)和i(羽毛索引)两个参数:
featherA:计算羽毛的透明度。公式为0.1 + ((tick + i) % 5) * 0.05,透明度在0.1到0.3之间周期性变化。透明度的变化模拟了羽毛飘落过程中因角度和光线变化产生的明暗闪烁效果。
featherX:计算羽毛的水平位置。公式为30 + ((tick * 3 + i * 173) % 600),羽毛在水平方向上以600像素为周期循环移动。tick乘以3表示水平方向的移动速度,i乘以173是一个随机偏移量,确保每根羽毛的运动轨迹不同。
featherY:计算羽毛的垂直位置。公式为40 + ((tick * 9 + i * 211) % 640),羽毛在垂直方向上以640像素为周期向下飘落。tick乘以9表示垂直方向的移动速度(比水平方向快,符合重力下落的直觉),i乘以211是另一组随机偏移。
featherS:计算羽毛的大小。公式为4 + ((tick + i * 2) % 3) * 2,羽毛尺寸在4、6、8像素之间循环变化。大小的变化模拟了羽毛远近不同的空间层次感。
这四个函数协同工作,通过数学公式的精确计算,在每一个tick时刻为每一根羽毛计算出独特的位置、大小和透明度,从而形成了自然流畅的羽毛飘落动画。这种基于数学公式的程序化动画比关键帧动画更加灵活,且性能开销极低。
5.4 windA/X:风线漂移特效函数族
风线漂移特效由两个纯函数驱动,同样接收tick和i两个参数:
windA:计算风线的透明度。公式为0.05 + ((tick + i) % 4) * 0.04,透明度在0.05到0.17之间变化。风线的透明度整体低于羽毛,因为风线是更加微妙的背景装饰元素。
windX:计算风线的水平位置。公式为20 + ((tick * 11 + i * 233) % 580),风线在水平方向上以580像素为周期漂移。tick乘以11表示风线的移动速度较快,模拟了风吹过的流动感。
风线特效的Y轴位置不是通过函数计算的,而是直接在组件中通过130 + i * 150的公式均匀分布在页面上,形成了四条水平等距分布的风线。这种水平分布与水平漂移的组合,模拟了微风拂过湖面时水面上形成的一道道涟漪。
5.5 statusColor:状态颜色映射函数
statusColor函数接收状态字符串s,根据不同的状态返回对应的颜色值:"已记录"返回成功绿(#6FA37D)、"观察中"返回湖水蓝(#4A7C8C)、"待确认"返回橙色(#E8945A)、"鉴定中"返回警告金(#D9A05B)、其他情况返回提示灰(#A4BBC3)。
这个函数建立了观察状态与颜色语义之间的映射关系:已记录(完成态)用绿色表示成功和完成,观察中(进行态)用蓝色表示稳定和持续,待确认(待定态)用橙色表示关注和行动,鉴定中(处理态)用金色表示进展和等待。这种色彩语义的建立使得用户仅凭颜色就能快速理解状态的含义。
5.6 statusProgress:状态进度映射函数
statusProgress函数接收状态字符串s,根据不同的状态返回默认的进度百分比:"已记录"返回100、"观察中"返回68、"待确认"返回44、"鉴定中"返回28、其他情况返回10。
这个函数为每种状态定义了一个具有代表性的进度值,使得进度条能够直观地反映观察活动所处的阶段。从鉴定中的28%到待确认的44%,再到观察中的68%,最后到已记录的100%,进度值与状态的语义高度吻合。
5.7 birdTagColor:鸟种标签颜色函数
birdTagColor函数接收鸟名或类群字符串g,通过关键词匹配返回不同的颜色。如果名称中包含"鸦雀"或"寿带",返回湖水蓝(#4A7C8C);如果包含"翠鸟"或"雨燕",返回橙色(#E8945A);如果包含"隼",返回警告金(#D9A05B);其他情况返回成功绿(#6FA37D)。
这个函数基于鸟种名称的关键词进行分类着色,使得不同类群的鸟种在视觉上具有可区分的颜色标识。这种基于规则的颜色分配方式虽然简单,但在鸟种数量有限的情况下非常有效,且无需额外的数据字段支持。
5.8 rarityColor:稀有度颜色函数
rarityColor函数接收稀有度字符串s,根据不同等级返回对应的颜色:"常见"返回成功绿(#6FA37D)、"季节"返回橙色(#E8945A)、"少见"返回警告金(#D9A05B)、其他情况(稀有)返回危险红(#C76B5A)。
稀有度的颜色映射遵循了从常见到稀有的视觉警示梯度:常见(绿色,安全、普遍)→ 季节(橙色,注意、限时)→ 少见(金色,珍贵、难得)→ 稀有(红色,珍稀、罕见)。这种颜色编码方式使得用户在浏览鸟种列表时能够快速识别出哪些鸟种更加珍贵。
六、组件状态管理与生命周期:@State驱动的响应式UI引擎
6.1 组件级状态变量全解析
PageBirdWatching组件作为应用的主入口组件,承载了全局的UI状态管理。通过@State装饰器定义的状态变量构成了组件的"状态中枢",每一个状态变量的变化都会触发相关UI节点的自动刷新。
mainTab(number,初始值0):底部主导航的当前选中索引,取值范围0-3。0对应首页,1对应图鉴,2对应课堂,3对应我的。这个状态变量决定了mainContent中显示哪个主页面,以及bottomBar中哪个导航项高亮。
subTab(number,初始值0):首页子导航的当前选中索引,取值范围0-6。0对应精选,1对应鸟种橱窗,2对应我的观察,3对应观鸟路线,4对应鸟类课堂,5对应鸟友圈,6对应装备库。这个状态变量决定了首页Scroll区域内显示哪个子页面,以及subNav中哪个Tab被选中。
tick(number,初始值0):特效动画的时间帧计数器,由定时器每90毫秒递增1。tick是羽毛飘落和风线漂移特效的核心驱动变量,它的持续递增使得特效函数族能够计算出不断变化的位置、大小和透明度,从而产生动画效果。
glow(number,初始值0):发光效果的状态变量,由定时器每90毫秒更新一次,取值在0和1之间循环((this.glow + 1) % 2)。glow变量设计用于实现呼吸灯或脉冲发光效果,为界面增添微妙的动态细节。
addOpen(boolean,初始值false):"发布羽语"弹框的开关状态。当值为true时,发布弹框显示在界面上;为false时隐藏。弹框的显示与隐藏通过build方法中的条件渲染(if (this.addOpen))控制。
editOpen(boolean,初始值false):"编辑观察记录"弹框的开关状态。控制编辑弹框的显示与隐藏,逻辑与addOpen类似。
delOpen(boolean,初始值false):"删除/取消"确认弹框的开关状态。控制删除确认弹框的显示与隐藏。
editIdx(number,初始值0):当前正在编辑的观察记录在watchList数组中的索引位置。当用户点击某条观察记录的"编辑记录"按钮时,openEdit方法会将该记录的索引存入editIdx,供后续的doEdit方法使用。
delTarget(string,初始值’watch’):删除操作的目标类型,取值为’watch’或’course’。这个变量决定了删除确认弹框中显示的文字内容(“取消观察记录"还是"退出鸟类课堂”),以及doDel方法实际执行的删除操作(删除watchList的首项还是postList的首项)。
editName / editStyle / editNote(string):编辑弹框中的三个输入字段值,分别对应观察名称、状态和观察内容。这些变量与弹框中的TextInput和TextArea组件实现双向绑定,用户输入的内容会实时更新到状态变量中。
addTitle / addContent(string):发布羽语弹框中的两个输入字段值,分别对应标题和内容。与编辑弹框的输入字段类似,实现与TextInput和TextArea的双向绑定。
postList(PostItem[]):鸟友动态列表数据,初始值为POST_LIST静态数据。这是一个数组类型的状态变量,当用户发布新羽语或删除动态时,数组内容会发生变化,从而触发列表UI的自动更新。
watchList(WatchItem[]):观察记录列表数据,初始值为WATCH_LIST静态数据。同样是数组类型的状态变量,支持编辑和删除操作。
fxTimer(number,初始值-1):特效定时器的ID,用于存储setInterval返回的定时器句柄。在aboutToDisappear生命周期回调中需要使用这个ID来清除定时器,防止内存泄漏。初始值设为-1是一个哨兵值,表示定时器尚未启动。
6.2 生命周期方法详解
aboutToAppear:组件即将出现时的回调函数。在这个方法中,通过setInterval启动了一个每90毫秒触发一次的定时器。定时器的回调函数执行两个操作:将tick加1(驱动特效动画),以及更新glow值((this.glow + 1) % 2,实现二态循环)。
90毫秒的间隔时间约等于每秒11帧,这个帧率对于装饰性特效来说已经足够流畅,同时不会消耗过多的CPU资源。特效定时器的句柄被保存在fxTimer变量中,便于后续清除。
aboutToDisappear:组件即将消失时的回调函数。在这个方法中,检查fxTimer是否大于0(即定时器是否正在运行),如果是,则调用clearInterval清除定时器,并将fxTimer重置为-1。这个清理操作至关重要——如果组件销毁后定时器仍然运行,会导致内存泄漏和不必要的性能消耗。
6.3 业务操作方法解析
openAdd:打开发布羽语弹框的方法。方法内部首先将addTitle和addContent重置为空字符串(确保每次打开弹框都是空白的新状态),然后将addOpen设为true以显示弹框。
openEdit:打开编辑观察记录弹框的方法,接收一个索引参数index。方法内部首先进行边界检查(确保索引在watchList数组的有效范围内),然后将当前选中记录的name、status、item三个字段的值分别赋给editName、editStyle、editNote三个编辑状态变量,最后将editOpen设为true显示弹框。
边界检查是良好的防御性编程实践,它确保了即使传入无效索引,代码也不会抛出异常,而是优雅地处理异常情况。
openDelA / openDelB:打开删除确认弹框的两个方法。openDelA设置delTarget为’watch’(取消观察),openDelB设置delTarget为’course’(退课),然后都将delOpen设为true。将删除操作拆分为两个方法使得调用方代码更加清晰——调用者无需了解delTarget的具体取值,只需调用语义明确的方法即可。
doAdd:执行发布羽语操作的方法。方法内部首先检查addTitle的长度是否大于0(标题不能为空),如果有效,则使用unshift方法将一条新的PostItem插入到postList数组的最前面(即列表顶部)。新动态的id设为999,昵称设为"我的羽语",头像设为羽毛emoji,时间设为"刚刚",点赞数初始化为0。最后将addOpen设为false关闭弹框。
unshift方法将新元素添加到数组开头,符合社交应用"最新动态在最前面"的展示习惯。
doEdit:执行编辑观察记录操作的方法。方法内部检查editIdx的有效性,如果有效,则使用splice方法用新的WatchItem替换数组中对应位置的元素。新记录保留原始id,使用编辑后的名称、内容和状态,进度初始化为40。最后关闭编辑弹框。
splice方法的第二个参数为1(删除1个元素),第三个参数为新元素(插入新元素),从而实现了"替换"操作。
doDel:执行删除操作的方法。方法内部根据delTarget的值执行不同的删除逻辑:如果目标是’watch’且watchList不为空,则删除watchList的第一个元素;如果目标是’course’且postList不为空,则删除postList的第一个元素。最后关闭删除弹框。
七、fxLayer特效层:羽毛飘落与风线漂移的纯声明式实现
7.1 特效层的架构设计
fxLayer(特效层)是一个@Builder装饰的构建函数,它负责渲染页面中的所有装饰性动画特效——飘落的羽毛和漂移的风线。特效层使用Stack布局,所有特效元素以堆叠方式排列在容器中。
特效层的一个关键设计是使用了.hitTestBehavior(HitTestMode.None)属性。这个属性设置使得特效层不响应任何触摸事件,所有的点击、滑动等交互都会穿透特效层传递到下方的内容层。这是一种非常优雅的架构设计——特效层纯粹作为视觉装饰存在,完全不干扰用户与应用内容的交互。
7.2 羽毛飘落特效的实现
羽毛飘落特效通过ForEach循环渲染5根羽毛。每根羽毛都是一个Column组件(用作矩形元素),通过以下属性的组合形成羽毛的视觉效果:
- 尺寸:宽度为featherS函数的返回值,高度为宽度的2倍,形成细长的羽毛形状
- 圆角:borderRadius设为宽度的一半,使矩形两端变成半圆形,更接近羽毛的轮廓
- 颜色:使用COLORS.feather羽色填充
- 透明度:由featherA函数动态计算,产生明暗闪烁效果
- 旋转:固定旋转24度角,模拟羽毛飘落时的倾斜姿态
- 位移:由featherX和featherY函数动态计算,产生飘落运动效果
5根羽毛共享同一个tick计数器,但由于每根羽毛的索引i不同,它们的运动轨迹、大小和透明度变化都是错位的,形成了自然错落的飘落效果。
7.3 风线漂移特效的实现
风线漂移特效通过ForEach循环渲染4条风线。每条风线是一个Row组件(用作细长条),属性设置如下:
- 尺寸:宽度46像素,高度3像素,形成细长的线条
- 圆角:borderRadius为2像素,使线条两端圆润
- 颜色:使用COLORS.primaryLight主色亮调填充,与湖水蓝主题呼应
- 透明度:由windA函数动态计算,风线若隐若现
- 旋转:固定旋转8度角,模拟风线的倾斜方向
- 位移:水平位置由windX函数动态计算,垂直位置均匀分布(130 + i * 150)
4条风线在垂直方向上等距分布,各自以不同的节奏在水平方向上漂移,形成了微风拂过的流动感。风线的整体透明度低于羽毛,作为更加底层的背景装饰存在。
7.4 特效性能考量
特效的实现采用了纯函数驱动的声明式模式,具有以下性能优势:
- 计算高效:所有特效参数的计算都是简单的算术运算(加减乘除和取模),CPU开销极低。
- 局部刷新:由于tick是@State变量,且仅被fxLayer中的特效元素引用,因此tick变化时只会触发特效层的重新渲染,不会影响其他UI组件。
- 帧率可控:通过调整定时器的间隔时间(90毫秒),可以灵活控制特效的流畅度与性能消耗之间的平衡。
- 无DOM操作:完全通过状态驱动UI更新,避免了直接操作DOM节点带来的性能问题。
八、header头部组件:信息聚合与品牌传达的视觉枢纽
header头部组件是首页顶部的信息聚合区域,它由品牌行、候鸟迁徙卡、周末导赏横幅和数据四格四个部分组成,垂直排列在Column容器中。
8.1 品牌行:定位与签到
品牌行是头部的第一行内容,使用Row布局水平排列三个元素:品牌Logo(羽毛emoji)、品牌名称与定位信息、签到按钮。
品牌Logo使用🪶羽毛emoji,字体大小24像素,是品牌识别的核心符号。品牌名称"羽语观鸟"使用16号加粗字体,配合"北湖湿地公园·今晨已记录22种"的定位信息,清晰地传达了应用名称、当前位置和实时数据三个关键信息。
签到按钮使用橙色(accent色)背景和白色文字,圆角胶囊形状,是头部最醒目的行动号召(CTA)元素。点击签到按钮会打开发布羽语弹框(调用openAdd方法),这种设计将"签到"与"分享观察"有机结合——签到即发布一条观察动态。
8.2 候鸟迁徙卡:核心信息的视觉焦点
候鸟迁徙卡是头部区域最大、最醒目的卡片,使用湖水蓝(primary色)作为背景色,白色作为文字颜色,形成强烈的视觉对比。卡片内容包括:
- 主标题:“白鹭先头部队已抵达北湖”,使用15号加粗白色文字,是卡片的核心信息
- 副标题:“今晨6:20·3号观鸟屋·34只落芦苇荡”,使用浅青色辅助文字,提供具体的时间、地点和数量信息
- 标签行:包含"迁徙高峰"标签(羽色背景+深色文字)和"今日观测点7个"辅助文字
- 行动按钮:"查看路线"按钮(羽色背景+深色文字),点击后打开编辑观察记录弹框
这张卡片是头部信息密度最高、视觉权重最大的区域,承担着传达重要观鸟资讯的功能。湖水蓝的背景色与候鸟迁徙的主题高度契合——蓝色既代表天空也代表水域,是候鸟迁徙路线上的两种核心环境。
8.3 周末导赏横幅:活动招募入口
周末导赏横幅是一个白色背景的卡片,内容包括望远镜emoji图标、活动标题与描述、报名按钮。活动标题"周末猛禽救助开放日·招志愿者"使用13号加粗文字,副标题"参与放飞一只康复红隼·记4学时"使用11号辅助文字。
报名按钮与签到按钮采用相同的视觉样式(橙色背景+白色文字+圆角胶囊),保持了CTA元素的视觉一致性。点击报名按钮同样打开发布羽语弹框,简化了报名流程。
8.4 数据四格:核心指标的概览展示
数据四格是四个等宽的小卡片,水平排列在一行中,每个卡片展示一项核心指标:
- 观察天数:126天,主色文字
- 记录鸟种:189种,强调色文字
- 热门鸟点:北湖3号,成功色文字
- 羽币:¥86,警告色文字
每个卡片内部都是垂直排列的标签和数值,标签使用9号提示文字,数值使用13号加粗文字,且每个数值使用不同的主题色(分别对应primary、accent、success、warning)。这种多色数据展示方式既美观又实用,用户可以快速识别不同类型的数据指标。
四格布局使用layoutWeight(1)实现等分排列,确保在不同屏幕宽度下都能保持均匀分布。
九、subNav子导航:居中圆点指示的Tab切换交互
subNav子导航组件是首页的二级导航系统,采用水平滚动的Tab栏形式。它的设计亮点在于"居中圆点指示"的选中标识方式,以及多维度的选中态视觉反馈。
9.1 滚动容器与布局结构
子导航的最外层是一个Scroll组件,设置为水平滚动方向(ScrollDirection.Horizontal),隐藏滚动条(BarState.Off)。这使得当7个Tab的总宽度超过屏幕宽度时,用户可以左右滑动查看全部选项。
Scroll内部是一个Row容器,通过ForEach循环渲染SUB_NAV_LIST中的每一个导航项。导航项之间间距为10像素,整体Row居中对齐(VerticalAlign.Center)。
9.2 导航项的视觉设计
每个导航项是一个Column容器,包含文字标签和圆点指示器两个子元素。导航项的视觉状态由subTab状态变量与当前索引idx的比较结果决定:
选中态(subTab === idx):
- 文字字号:13像素
- 文字字重:Bold(加粗)
- 文字颜色:primaryDark(深湖蓝色)
- 圆点:橙色填充,完全可见
- 背景:primaryLight(浅湖蓝色)
- 圆角:12像素
未选中态(subTab !== idx):
- 文字字号:11像素
- 文字字重:Normal(常规)
- 文字颜色:textSecondary(次要文字色)
- 圆点:透明(opacity: 0),不可见
- 背景:bg(页面背景色,与背景融为一体)
- 圆角:12像素
选中态的文字更大、更粗、更深,背景有浅色填充,下方有橙色圆点指示——从字号、字重、颜色、背景到指示器,共五个维度的视觉差异确保了选中状态的清晰可辨。
9.3 交互逻辑
每个导航项通过onClick事件绑定点击处理函数,点击时将subTab状态变量更新为当前索引idx。由于subTab是@State变量,它的变化会触发所有导航项的重新渲染——被点击的项变为选中态,之前选中的项变为未选中态,整个切换过程平滑自然。
点击事件的响应范围是整个导航项区域(包括文字和圆点周围的padding),确保了足够的点击热区,提升了移动端的触控体验。
十、页面Builder详解:七大子页面的结构与实现
10.1 pageFeatured:精选首页
精选首页是子导航的第一个Tab,也是内容最丰富的页面。它包含五个主要模块,垂直排列在Column容器中:
玩法四格:在羽语能玩什么——四个等宽的功能入口卡片,水平排列展示观鸟导赏、鸟种图鉴、猛禽救助、照片鉴定四种玩法。每个卡片包含图标、名称、描述和热门标签,使用layoutWeight(1)实现等分布局。
周观察柱状图:本周观鸟打卡次数——使用纯CSS(ArkUI属性)绘制的柱状图,展示周一到周日的打卡数据。柱状图的行容器设置了alignItems(VerticalAlign.Bottom),使得所有柱形从底部对齐,形成标准的柱状图视觉。柱形高度由barH函数计算,颜色由birdColor函数决定。图表底部还有"周末出勤占比68%"的统计信息。
鸟种构成:本月目击鸟种构成——横向条形图,展示五大鸟类类群的构成比例。每个类群一行,包含类群名称、进度条(Stack堆叠实现的双层Row)和百分比数值。进度条的实现方式是经典的Stack布局技巧:底层是灰色背景条(100%宽度),上层是彩色进度条(按百分比宽度),两层左对齐堆叠。
人气鸟种图鉴:双列网格布局的鸟种卡片列表。通过ForEach循环和idx % 2 === 0的条件判断实现双列布局——每次循环处理一对卡片(第idx个和第idx+1个),放置在同一个Row中。每张卡片包含鸟种图标、稀有度标签、鸟名、栖息地和目击次数信息。
鸟友动态:社区动态列表,展示最新的3条鸟友动态(使用postList.slice(0, 3)截取前3条)。每条动态是一个横向布局的卡片,左侧是头像emoji,右侧是昵称、时间、正文和点赞数。右上角有"发羽语"入口,点击打开发布弹框。
10.2 pageBirds:鸟种橱窗
鸟种橱窗页面是一个垂直列表,展示所有鸟种的详细卡片。每张鸟种卡片包含以下元素:
- 左侧色条:4×38像素的彩色竖条,颜色由birdTagColor函数根据鸟名动态确定,为每种鸟赋予独特的颜色标识
- 标题行:鸟名(加粗主文字)和稀有度标签(圆角胶囊形)
- 信息行:栖息地和目击次数两个信息项,使用emoji前缀增强视觉识别
- 人气进度条:Stack堆叠实现的进度条,背景为灰色边框色,前景为鸟种主题色,进度值为目击次数(百分比形式)
- 底部操作行:人气值文字和"加入图鉴夹"按钮
这种卡片设计信息层次清晰,视觉元素丰富,颜色编码系统使得用户可以快速区分不同类群的鸟种。
10.3 pageWatches:我的观察
我的观察页面展示用户的所有观察记录列表。每条记录卡片包含:
- 头部行:望远镜图标、观察名称(加粗)、状态标签(颜色由statusColor函数确定)
- 观察内容:鸟种类别和数量信息
- 进度条:观察进度可视化,颜色和进度值分别由statusColor和statusProgress函数根据状态确定
- 底部操作行:进度百分比文字和"编辑记录"按钮
观察记录的状态标签、进度条颜色、进度值三者都与status字段关联,形成了统一的状态视觉语言。点击"编辑记录"按钮会调用openEdit方法,打开编辑弹框。
10.4 pageRoute:观鸟路线
观鸟路线页面包含三个主要部分:
路线信息四格:四个等宽的小卡片展示今日出发时间(6:20)、路线全长(4.6km)、观测点数量(7个)、预计鸟种数(35+)。每个数值使用不同的主题色,与header中的数据四格风格一致。
鸟种排行列表:展示路线上排名前5的鸟种(使用BIRD_LIST.slice(0, 5))。每条记录包含排名序号、鸟名、栖息地与稀有度、目击次数。前三名的序号使用主色突出显示,第四名以后使用提示色。
装备速购区:双列网格布局的装备卡片列表,与精选页的人气鸟种双列布局类似。每件装备包含图标、名称、描述和"加购"按钮。
10.5 pageCourse:鸟类课堂
鸟类课堂页面展示课程学习列表,每门课程包含:
- 标题行:课程标题(加粗)和周次(提示文字)
- 进度条:Stack堆叠实现的进度条,每门课程使用不同的主题色
- 底部行:"已学"标签和进度百分比数值
课程列表的下方有一个"退课"按钮,使用危险红色背景,点击后打开删除确认弹框(调用openDelB方法)。退课按钮的实现比较特别——使用Button组件作为背景,然后用Text组件通过负margin定位到按钮上方显示文字。这种实现方式虽然能够达到视觉效果,但更标准的做法是使用Button组件的子组件形式或设置buttonStyle。
10.6 pageCircle:鸟友圈
鸟友圈页面是完整的社区动态列表,展示所有8条鸟友动态(而非精选页的3条)。每条动态的布局与精选页类似,但内容展示更加完整:
- 头像更大(26px vs 22px)
- 正文字号更大(13px vs 12px),且不限制行数
- 底部有"🪶 点赞数 · 💬 回复"的互动信息
右上角的"发羽语"入口与精选页一致,点击打开发布弹框。鸟友圈是社区互动的核心页面,完整展示了社区的动态信息流。
10.7 pageGear:装备库
装备库页面展示观鸟装备推荐,包含三个主要部分:
装备分类三格:三个等宽的分类入口卡片——光学区(双筒·单筒)、影像区(长焦·记录)、蹲守区(伪装·帐篷)。每个分类卡片包含大图标(28px)、分类名称(加粗)和分类描述。
装备列表:垂直排列的装备卡片列表,每件装备包含图标、名称、描述和"加购"按钮。与路线页的双列装备网格不同,这里采用单列列表形式,信息展示更加充分。
取消观察按钮:页面底部有一个危险红色的"取消观察"按钮,点击后打开删除确认弹框(调用openDelA方法)。其实现方式与退课按钮相同,使用Button+负margin Text的组合。
10.8 pageMine:我的(个人中心)
个人中心页面展示用户的个人信息和核心数据,包含三个卡片模块:
个人信息卡:展示用户头像(羽毛emoji,40px大号)、昵称"羽语观鸟人"(18号加粗文字)、会员等级与数据统计(白鹭会员·观察126天·鸟种189·救助6次)。这是个人中心的头部区域,信息密度最高。
我的观察档案卡:展示观察记录的数量和前3条记录的简要信息(名称和状态)。记录数量以主色显示在右侧,每条记录以行内形式展示名称和状态标签。
我的羽语卡:展示发布的羽语动态数量和前3条的简要信息(昵称和时间)。布局风格与观察档案卡一致,保持了卡片模块的视觉统一性。
十一、弹框组件:新增/编辑/删除三类模态交互
11.1 弹框架构设计
应用的弹框系统采用了Stack堆叠+条件渲染的经典实现模式。每个弹框由两部分组成:半透明遮罩层(modalOverlay)和弹框内容体(addModalBody / editModalBody / delModalBody)。
遮罩层(modalOverlay):一个全屏的Column组件,背景为黑色(#000000),透明度为0.6。遮罩层的作用是弱化弹框下方的内容,将用户的注意力聚焦到弹框上。同时,遮罩层绑定了onClick事件,点击遮罩可以关闭弹框(将三个弹框状态都设为false),这是一种符合用户直觉的交互模式。
弹框内容体:具体的弹框内容,宽度为屏幕的88%,最大高度为屏幕的80%,圆角16像素,zIndex设为999确保弹框在最上层。内容体使用白色(cardBg)背景,内部是具体的表单或提示内容。
三个弹框(新增、编辑、删除)分别由三个布尔状态变量(addOpen、editOpen、delOpen)独立控制显示与隐藏,它们共享同一个遮罩层构建函数,但各自有不同的内容体。
11.2 addModalBody:发布羽语弹框
发布羽语弹框是一个表单弹框,包含以下元素:
- 标题:“发布羽语”,17号加粗主文字
- 标题输入框:TextInput组件,占位符为"一句话观鸟日常…",高度40像素,背景为bg色,圆角8像素。输入值与addTitle状态变量双向绑定。
- 内容输入框:TextArea组件,占位符为"补充细节:地点、鸟种、观察心得…",高度90像素,支持多行输入。输入值与addContent状态变量双向绑定。
- 按钮行:水平排列的两个按钮——"取消"按钮(浅色背景+次要文字)和"发布"按钮(主色背景+白色加粗文字)。
按钮的实现方式同样使用了Button+负margin Text的组合。两个按钮通过layoutWeight(1)实现等分宽度。
11.3 editModalBody:编辑观察记录弹框
编辑观察记录弹框与发布弹框结构类似,但包含三个输入字段:
- 标题:“编辑观察记录”
- 观察名称输入框:TextInput,与editName双向绑定
- 状态输入框:TextInput,占位符为"状态(如 观察中)",与editStyle双向绑定
- 观察内容输入框:TextArea,高度70像素,与editNote双向绑定
- 按钮行:"取消"和"保存"两个按钮
编辑弹框与发布弹框在布局风格上保持一致,体现了组件化设计的统一性思想。
11.4 delModalBody:删除确认弹框
删除确认弹框是一个简单的确认对话框,不包含输入字段,仅展示提示信息和操作按钮:
- 标题:根据delTarget的值动态显示"取消观察记录"或"退出鸟类课堂"
- 提示文字:根据delTarget的值动态显示对应的确认提示文案
- 按钮行:"取消"按钮(浅色背景)和"确认"按钮(危险红色背景+白色加粗文字)
删除操作使用危险红色作为确认按钮的颜色,符合UI设计的通用规范——破坏性操作使用红色警示,防止用户误操作。弹框的文案设计也很重要——明确告知用户操作的后果(“将取消首条观察记录并归档数据”),让用户在确认前清楚了解自己在做什么。
十二、bottomBar底部导航与mainContent主内容区
12.1 bottomBar:底部导航栏
底部导航栏是一个高度为56像素的Row容器,背景为白色(cardBg),顶部左右圆角为16像素。圆角顶部的设计使得底部导航栏看起来像一个悬浮的托盘,与内容区域形成柔和的视觉过渡。
导航栏通过ForEach循环渲染NAV_LIST中的4个导航项。每个导航项是一个Column容器,包含图标和文字标签,使用layoutWeight(1)实现等分排列。
选中态的视觉反馈包括:图标完全不透明(opacity: 1)、文字加粗、文字颜色为主色;未选中态则图标半透明(opacity: 0.55)、文字常规字重、文字颜色为提示色。这种简洁的选中态反馈在保持视觉清晰的同时,避免了过度设计。
点击导航项会更新mainTab状态变量,触发主内容区的页面切换和导航栏的选中态更新。
12.2 mainContent:主内容区
mainContent是应用的主体内容区域,它是一个Column容器,垂直排列header头部和内容区域。内容区域根据mainTab的值显示不同的页面:
mainTab === 0(首页):
- 显示subNav子导航栏
- 显示可垂直滚动的内容区域
- 内容区域根据subTab的值显示7个子页面中的一个(pageFeatured / pageBirds / pageWatches / pageRoute / pageCourse / pageCircle / pageGear)
- 内容区域左右padding为14像素,底部padding为20像素
mainTab === 1(图鉴):
- 直接显示pageBirds鸟种橱窗页面(无subNav)
- 垂直滚动,左右padding 14像素
mainTab === 2(课堂):
- 直接显示pageCourse鸟类课堂页面
- 垂直滚动,左右padding 14像素
mainTab === 3(我的):
- 直接显示pageMine个人中心页面
- 垂直滚动,左右padding 14像素
主内容区的布局采用了Column + Scroll的经典结构——header固定在顶部,内容区域可滚动。Scroll组件设置为垂直滚动方向,隐藏滚动条,使用layoutWeight(1)占据剩余的所有空间。
值得注意的是,首页的子页面与主Tab对应的页面存在复用关系——如pageBirds同时用于首页的"鸟种橱窗"子Tab和底部导航的"图鉴"Tab,pageCourse同时用于首页的"鸟类课堂"子Tab和底部导航的"课堂"Tab。这种页面Builder的复用减少了代码冗余,提高了开发效率。
十三、build()方法:Stack三层架构与弹框的条件渲染
build()方法是组件的入口方法,它定义了组件的整体UI结构。整个页面采用Stack堆叠布局,从下到上分为三层结构:
第一层(最底层):背景层
一个全屏的Column组件,背景色为COLORS.bg。这一层是整个页面的底色,为所有上层内容提供视觉基底。
第二层:特效层
调用this.fxLayer()渲染羽毛飘落和风线漂移特效。特效层位于背景层之上、内容层之下,既不会被背景遮挡,也不会遮挡内容。由于特效层设置了hitTestBehavior(HitTestMode.None),它完全不影响用户的触摸交互。
第三层(最上层):内容与导航层
一个全屏的Column组件,垂直排列mainContent主内容区和bottomBar底部导航栏。mainContent占据上方的主要空间,bottomBar固定在底部。这一层是用户交互的主要区域。
13.1 弹框的条件渲染
在三层主体结构之上,还有三个条件渲染的弹框层,分别对应新增、编辑和删除三种弹框。每个弹框都是一个独立的Stack层:
if (this.addOpen) {
Stack() {
this.modalOverlay()
Column() { this.addModalBody() }
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
}
每个弹框层内部使用Stack布局,底部是半透明遮罩(modalOverlay),顶部是弹框内容体。弹框内容体的宽度为屏幕宽度的88%,最大高度为屏幕高度的80%,zIndex设为999确保弹框在所有内容的最上层。
当addOpen/editOpen/delOpen为true时,对应的弹框层会被渲染到界面上;为false时则完全不渲染。这种条件渲染的方式相比"渲染但隐藏"的方式更加高效——不需要显示的弹框根本不存在于组件树中,减少了渲染开销和内存占用。
13.2 整体架构的层次总结
从下到上,应用的视觉层次依次为:
- 背景色(bg浅灰白)
- 羽毛飘落特效(5根羽毛)
- 风线漂移特效(4条风线)
- 头部区域(品牌+卡片+数据)
- 子导航栏(首页时显示)
- 主内容区(各页面内容)
- 底部导航栏
- 弹框遮罩层(弹框显示时出现)
- 弹框内容体(弹框显示时出现)
这种清晰的层次结构确保了界面的视觉秩序——背景在最下,装饰特效在内容之下,交互内容在中间,导航在底部,弹框在最上。每一层的职责明确,互不干扰。
十四、应用架构流程图
点击/滑动/输入] --> B{状态变更
@State 变量 -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'
十五、数据模型对比表
| 模型名称 | 装饰器 | 核心标识 | 关键字段数 | 主要用途 | 状态可变 | 所在模块 | 典型场景 |
|---|---|---|---|---|---|---|---|
| PostItem | @Observed | id (number) | 6 | 鸟友社区动态 | 是 | 鸟友圈/精选页 | 发布羽语、浏览动态、点赞互动 |
| BirdItem | @Observed | id (number) | 5 | 鸟种图鉴信息 | 否 | 图鉴/精选/路线 | 浏览鸟种、查看栖息地、稀有度识别 |
| WatchItem | @Observed | id (number) | 5 | 观察记录档案 | 是 | 我的观察/个人中心 | 创建记录、编辑状态、跟踪进度 |
| GearItem | @Observed | id (number) | 4 | 装备库商品 | 否 | 装备库/路线页 | 浏览装备、加购推荐、分类查找 |
| WeekChartItem | interface | label (string) | 2 | 周打卡柱状图数据 | 否 | 精选页图表 | 展示一周打卡趋势 |
| BirdChartItem | interface | label (string) | 3 | 鸟种构成条形图数据 | 否 | 精选页图表 | 展示鸟类类群比例 |
| CourseItem | interface | title (string) | 4 | 课程学习进度 | 否 | 课堂页 | 展示课程列表与学习进度 |
| WatchTypeItem | interface | name (string) | 4 | 玩法类型入口 | 否 | 精选页四格 | 功能入口导航 |
| NavItem | interface | label (string) | 2 | 底部导航配置 | 否 | bottomBar | 主导航切换 |
表格说明
-
装饰器差异:四个核心业务模型(PostItem、BirdItem、WatchItem、GearItem)使用了@Observed装饰器,意味着它们的实例属性变化可以被ArkUI状态管理精准追踪。辅助接口(WeekChartItem、BirdChartItem等)则是普通的TypeScript interface,仅用于类型约束和数据结构描述,不具备响应式能力。
-
状态可变性:PostItem和WatchItem是用户产生的数据,支持新增、编辑、删除等操作,因此是状态可变的。BirdItem和GearItem是系统提供的基础数据,用户只能浏览和收藏,数据本身不发生变化。
-
字段数量与复杂度:PostItem字段最多(6个),因为社区动态需要承载最丰富的信息(发布者、内容、时间、互动等)。GearItem字段最少(4个),因为装备信息相对简单。
-
复用程度:BirdItem和CourseItem的复用程度最高,分别在多个页面中出现(鸟种橱窗/精选/路线,鸟类课堂/首页子Tab/底部Tab)。@Builder的页面复用机制使得这些数据模型可以在不同上下文中被灵活使用。
十六、总结:声明式UI开发的设计智慧与工程价值
经过对"羽语·城市观鸟与猛禽救助预约平台"完整代码的深度剖析,我们可以清晰地看到HarmonyOS ArkTS声明式UI开发范式在实际项目中的应用图景。这不仅仅是一套UI框架的使用,更是一种全新的开发思维方式的体现。
16.1 声明式开发的核心价值
声明式UI开发的最大价值在于它将开发者从繁琐的命令式DOM操作中解放出来,让开发者可以专注于"描述UI应该是什么样子"而非"一步步告诉UI如何变化"。在本项目中,所有的UI变化——Tab切换、弹框显示、列表更新、特效动画——都是通过状态变量的变化自动驱动的。开发者只需关心状态的语义含义,而无需关心UI更新的具体过程。
这种开发模式带来了几个显著的好处:代码更加简洁易读,因为UI结构与状态的映射关系一目了然;bug更加容易定位,因为任何UI异常都可以追溯到对应的状态变量;代码更加容易维护,因为新增功能通常只需要新增状态变量和对应的UI描述,而不需要修改已有的状态更新逻辑。
16.2 @Observed与精细化状态管理
@Observed装饰器的引入使得状态管理的粒度更加精细。对于复杂的业务对象(如PostItem、WatchItem),传统的@State只能追踪整个对象的替换,而无法追踪对象内部属性的变化。@Observed则使得对象的每个属性都能被独立观察,当某个属性变化时,只有引用了该属性的UI节点会被刷新,大大提高了渲染效率。
在本项目中,四个核心业务模型都使用了@Observed装饰器,这为后续的功能扩展(如编辑鸟种信息、更新装备描述等)打下了良好的基础。即使当前版本中这些模型的某些属性并未在运行时动态修改,但提前使用@Observed装饰器是一种具有前瞻性的架构决策。
16.3 @Builder与UI组件化
@Builder装饰器是UI复用的利器。在本项目中,几乎所有的UI模块都被抽取为独立的@Builder方法——header、subNav、7个页面Builder、3个弹框Builder、fxLayer、bottomBar、mainContent等。这种抽取使得build()方法非常简洁,只需按照层次结构调用各个Builder即可。
@Builder的优势在于它既保持了声明式UI的开发体验(内部可以使用完整的ArkUI语法),又实现了UI逻辑的模块化和复用。相比于封装为独立组件,@Builder更加轻量,不需要额外的文件和类定义,适合用于组件内部的UI片段复用。
16.4 色彩系统与设计令牌
ColorPalette接口与COLORS常量构成了一套完整的设计令牌(Design Tokens)系统。16个精心定义的颜色字段覆盖了主色、辅助色、背景色、文字色、边框色、功能色等所有色彩场景,且每个颜色都有明确的语义含义。
设计令牌的价值不仅仅在于统一视觉风格,更在于它建立了设计与开发之间的标准接口。设计师在设计稿中定义的色彩规范可以直接映射为代码中的具名字段,开发者不需要反复确认"这个蓝色用的是哪一个",只需引用对应的语义字段即可。这种标准化极大地提高了设计还原的准确度和开发效率。
16.5 纯函数与函数式思维
特效、颜色映射、数据转换等逻辑全部通过纯函数实现,这是函数式编程思维在UI开发中的具体体现。纯函数的好处在于:可预测性(相同输入永远产生相同输出)、可测试性(无需依赖外部状态即可单独测试)、可组合性(多个纯函数可以组合成更复杂的逻辑)。
羽毛飘落和风线漂移特效的实现尤其值得关注——通过几个简单的数学函数,就实现了流畅自然的动画效果,既不需要复杂的动画库,也不需要大量的关键帧配置。这种基于数学公式的程序化动画思路,在很多场景下都比传统的关键帧动画更加高效和灵活。
16.6 双层导航架构的信息组织
应用采用了"底部4主Tab + 首页7子Tab"的双层导航架构,这种架构在信息组织上具有明显的优势:底部导航将应用分为四大板块,形成了清晰的一级信息架构;首页的子导航则将精选内容进一步细分为七个类别,为用户提供了更加精细的内容入口。
居中圆点指示的选中标识方式是UI设计的一个亮点,它比传统的下划线指示更加轻盈精致,与湖水蓝自然主题的调性更加契合。多维度的选中态反馈(字号、字重、颜色、背景、圆点)确保了选中状态的清晰可辨。
16.7 特效层与交互穿透
特效层的hitTestBehavior(HitTestMode.None)设置是一个非常重要的架构细节。它使得装饰性特效可以自由地叠加在界面上,而完全不影响用户的触摸交互。这种"视觉装饰层"与"交互内容层"的分离设计,既保证了视觉效果的丰富性,又保证了交互体验的流畅性。
在很多应用中,装饰性元素常常会无意中遮挡交互元素,导致用户点击失效。而通过专门的特效层和穿透设置,这个问题得到了优雅的解决。
16.8 从原型到生产的演进路径
当前版本的代码使用静态数据数组作为数据源,这非常适合原型开发和UI演示。在向生产环境演进时,可以按照以下路径逐步升级:
- 数据层抽象:将静态数据替换为网络请求或本地数据库查询,引入Repository模式统一管理数据访问。
- 状态管理升级:对于更复杂的跨组件状态共享,可以引入@Provide/@Consume或AppStorage/LocalStorage等更高级的状态管理机制。
- 组件拆分:将较大的页面Builder进一步拆分为独立的@Component组件,提高代码的可维护性和复用性。
- 性能优化:对于长列表,可以使用LazyForEach替代ForEach实现懒加载,进一步提升滚动性能。
- 国际化:将硬编码的中文字符串抽取为资源文件,支持多语言切换。
- 主题系统:在ColorPalette的基础上扩展深色模式、多主题切换等能力。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// 场景:观鸟路线预约 / 鸟种图鉴 / 猛禽救助志愿者 / 鸟友社区
// 底部4主tab(首页/图鉴/课堂/我的)+ 首页7内容tab(居中圆点指示式)
// 特效:羽毛飘落 + 风线漂移(特效层位于底层,不遮挡点击)
// 弹框:新增(发羽语) / 编辑(编辑观察记录) / 删除(取消观察·退课)
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
feather: string;
}
const COLORS: ColorPalette = {
primary: '#4A7C8C',
primaryLight: '#D6E6EA',
primaryDark: '#2E5561',
accent: '#E8945A',
accentLight: '#FAE6D6',
bg: '#F2F6F5',
cardBg: '#FFFFFF',
textPrimary: '#22383F',
textSecondary: '#5D7681',
textHint: '#A4BBC3',
border: '#E1ECEA',
success: '#6FA37D',
warning: '#D9A05B',
danger: '#C76B5A',
white: '#FFFFFF',
feather: '#7FB3C4'
};
// ============ 鸟友动态数据模型 ============
@Observed
export class PostItem {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
time: string = ''
likes: number = 0
constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
this.time = time; this.likes = likes
}
}
// ============ 鸟种图鉴数据模型 ============
@Observed
export class BirdItem {
id: number = 0
name: string = ''
habitat: string = ''
rarity: string = ''
seen: number = 0
constructor(id: number, name: string, habitat: string, rarity: string, seen: number) {
this.id = id; this.name = name; this.habitat = habitat; this.rarity = rarity
this.seen = seen
}
}
// ============ 观察记录数据模型 ============
@Observed
export class WatchItem {
id: number = 0
name: string = ''
item: string = ''
status: string = ''
progress: number = 0
constructor(id: number, name: string, item: string, status: string, progress: number) {
this.id = id; this.name = name; this.item = item; this.status = status
this.progress = progress
}
}
// ============ 装备数据模型 ============
@Observed
export class GearItem {
id: number = 0
name: string = ''
desc: string = ''
icon: string = ''
constructor(id: number, name: string, desc: string, icon: string) {
this.id = id; this.name = name; this.desc = desc; this.icon = icon
}
}
// ============ 图表与课程接口 ============
interface WeekChartItem {
label: string;
value: number;
}
interface BirdChartItem {
label: string;
value: number;
color: string;
}
interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
interface WatchTypeItem {
name: string;
desc: string;
icon: string;
hot: boolean;
}
// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
new PostItem(1, '芦苇荡蹲守人', '🦆', '震旦鸦雀终于蹲到了,芦苇里蹦了半小时。', '6分钟前', 132),
new PostItem(2, '望远镜不离手', '🔭', '红隼悬停捕食,正好停在视野正中央。', '19分钟前', 118),
new PostItem(3, '湿地清晨党', '🌅', '五点半到北岸,白鹭群起飞像下雪。', '38分钟前', 96),
new PostItem(4, '羽语主理人', '🪶', '周末猛禽救助开放日招募 8 名志愿者。', '1小时前', 88),
new PostItem(5, '新手观鸟人', '📒', '人生第一只翠鸟,蓝光一闪尖叫出声。', '2小时前', 81),
new PostItem(6, '长焦扛炮手', '📷', '游隼俯冲连拍 23 张,出片率意外地高。', '3小时前', 72),
new PostItem(7, '猛禽救助站义工', '🦅', '接了一只撞玻璃的丘鹬,恢复良好。', '5小时前', 66),
new PostItem(8, '城市阳台观鸟', '🏢', '21 楼阳台记录到过境猛禽 14 只。', '昨天', 58)
];
const BIRD_LIST: BirdItem[] = [
new BirdItem(1, '震旦鸦雀', '芦苇湿地', '稀有', 98),
new BirdItem(2, '普通翠鸟', '溪流荷塘', '常见', 95),
new BirdItem(3, '红隼', '城市上空', '常见', 90),
new BirdItem(4, '黑枕黄鹂', '阔叶林带', '少见', 84),
new BirdItem(5, '鸳鸯', '城市公园湖', '季节', 80),
new BirdItem(6, '游隼', '高楼崖壁', '少见', 76),
new BirdItem(7, '白腰雨燕', '古建屋檐', '季节', 71),
new BirdItem(8, '寿带鸟', '丘陵灌丛', '稀有', 64)
];
const WATCH_LIST: WatchItem[] = [
new WatchItem(1, '北湿地 · 3 号观鸟屋', '雁鸭类 · 12 种', '已记录', 100),
new WatchItem(2, '东滩 · 芦苇栈道', '震旦鸦雀蹲守', '观察中', 72),
new WatchItem(3, '城市公园 · 湖心岛', '鸳鸯繁殖监测', '待确认', 58),
new WatchItem(4, '西山 · 猛禽监测点', '过境猛禽计数', '观察中', 46),
new WatchItem(5, '河口 · 潮间带', '鸻鹬类大潮日', '待确认', 34),
new WatchItem(6, '猛禽救助站 · 值班', '康复训练陪护', '鉴定中', 26),
new WatchItem(7, '古建群 · 屋檐', '雨燕巢位登记', '已记录', 90),
new WatchItem(8, '城南林带 · 夜观', '猫头鹰声音调查', '待确认', 12)
];
const GEAR_LIST: GearItem[] = [
new GearItem(1, '双筒望远镜 8×42', '入门观鸟标配', '🔭'),
new GearItem(2, '单筒观鸟镜 25-70×', '远距离水鸟', '🦆'),
new GearItem(3, '观鸟三脚架', '云台缓动款', '🎯'),
new GearItem(4, '迷彩伪装网', '蹲守必备', '🕸️'),
new GearItem(5, '区域鸟类图鉴', '手绘图版', '📖'),
new GearItem(6, '防水记录本', '速记鸟种', '📒'),
new GearItem(7, '长焦相机 600mm', '打鸟利器', '📷'),
new GearItem(8, '驱蚊喷雾', '夏季湿地用', '🦟')
];
const WEEK_CHART: WeekChartItem[] = [
{ label: '周一', value: 3 },
{ label: '周二', value: 4 },
{ label: '周三', value: 6 },
{ label: '周四', value: 5 },
{ label: '周五', value: 9 },
{ label: '周六', value: 16 },
{ label: '周日', value: 14 }
];
const BIRD_CHART: BirdChartItem[] = [
{ label: '雁鸭类', value: 28, color: '#4A7C8C' },
{ label: '鸻鹬类', value: 24, color: '#E8945A' },
{ label: '鸣禽雀形', value: 22, color: '#6FA37D' },
{ label: '猛禽隼形', value: 16, color: '#D9A05B' },
{ label: '鹭鹳鹮类', value: 10, color: '#C76B5A' }
];
const TYPE_LIST: WatchTypeItem[] = [
{ name: '观鸟导赏', desc: '领队带看 · 器材共享', icon: '🔭', hot: true },
{ name: '鸟种图鉴', desc: '收录 · 拍摄 · 鉴定', icon: '📖', hot: true },
{ name: '猛禽救助', desc: '值班 · 放飞 · 守护', icon: '🦅', hot: false },
{ name: '照片鉴定', desc: '上传照片定种', icon: '📸', hot: false }
];
const COURSE_LIST: CourseItem[] = [
{ title: '观鸟入门与器材选择', week: '第 1 周', progress: 100, color: '#4A7C8C' },
{ title: '常见水鸟快速识别', week: '第 2 周', progress: 84, color: '#E8945A' },
{ title: '雀形目鸣禽辨声', week: '第 3 周', progress: 52, color: '#6FA37D' },
{ title: '猛禽过境监测方法', week: '第 4 周', progress: 30, color: '#D9A05B' },
{ title: '鸟类调查数据上报', week: '第 5 周', progress: 8, color: '#C76B5A' }
];
// ============ 导航配置 ============
interface NavItem {
icon: string;
label: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '🪶', label: '首页' },
{ icon: '📖', label: '图鉴' },
{ icon: '📚', label: '课堂' },
{ icon: '👤', label: '我的' }
];
const SUB_NAV_LIST: string[] = ['精选', '鸟种橱窗', '我的观察', '观鸟路线', '鸟类课堂', '鸟友圈', '装备库'];
// ============ 顶层纯函数 ============
function barH(v: number, max: number): number {
return Math.round(118 * v / max);
}
function birdColor(v: number): string {
if (v > 9) {
return '#4A7C8C';
} else if (v > 6) {
return '#E8945A';
}
return '#D9A05B';
}
function featherA(tick: number, i: number): number {
return 0.1 + ((tick + i) % 5) * 0.05;
}
function featherX(tick: number, i: number): number {
return 30 + ((tick * 3 + i * 173) % 600);
}
function featherY(tick: number, i: number): number {
return 40 + ((tick * 9 + i * 211) % 640);
}
function featherS(tick: number, i: number): number {
return 4 + ((tick + i * 2) % 3) * 2;
}
function windA(tick: number, i: number): number {
return 0.05 + ((tick + i) % 4) * 0.04;
}
function windX(tick: number, i: number): number {
return 20 + ((tick * 11 + i * 233) % 580);
}
function statusColor(s: string): string {
if (s === '已记录') {
return '#6FA37D';
} else if (s === '观察中') {
return '#4A7C8C';
} else if (s === '待确认') {
return '#E8945A';
} else if (s === '鉴定中') {
return '#D9A05B';
}
return '#A4BBC3';
}
function statusProgress(s: string): number {
if (s === '已记录') {
return 100;
} else if (s === '观察中') {
return 68;
} else if (s === '待确认') {
return 44;
} else if (s === '鉴定中') {
return 28;
}
return 10;
}
function birdTagColor(g: string): string {
if (g.indexOf('鸦雀') >= 0 || g.indexOf('寿带') >= 0) {
return '#4A7C8C';
} else if (g.indexOf('翠鸟') >= 0 || g.indexOf('雨燕') >= 0) {
return '#E8945A';
} else if (g.indexOf('隼') >= 0) {
return '#D9A05B';
}
return '#6FA37D';
}
function rarityColor(s: string): string {
if (s === '常见') {
return '#6FA37D';
} else if (s === '季节') {
return '#E8945A';
} else if (s === '少见') {
return '#D9A05B';
}
return '#C76B5A';
}
@Entry
@Component
struct PageBirdWatching {
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State glow: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State editIdx: number = 0
@State delTarget: string = 'watch'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State postList: PostItem[] = POST_LIST
@State watchList: WatchItem[] = WATCH_LIST
@State fxTimer: number = -1
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.glow = (this.glow + 1) % 2;
}, 90);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
openEdit(index: number): void {
if (index >= 0 && index < this.watchList.length) {
this.editIdx = index;
this.editName = this.watchList[index].name;
this.editStyle = this.watchList[index].status;
this.editNote = this.watchList[index].item;
}
this.editOpen = true;
}
openDelA(): void {
this.delTarget = 'watch';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
doAdd(): void {
if (this.addTitle.length > 0) {
this.postList.unshift(new PostItem(999, '我的羽语', '🪶', this.addTitle, '刚刚', 0));
}
this.addOpen = false;
}
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.watchList.length) {
this.watchList.splice(this.editIdx, 1, new WatchItem(this.watchList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
doDel(): void {
if (this.delTarget === 'watch' && this.watchList.length > 0) {
this.watchList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3, 4], (i: number) => {
Column()
.width(featherS(this.tick, i))
.height(featherS(this.tick, i) * 2)
.borderRadius(featherS(this.tick, i) / 2)
.backgroundColor(COLORS.feather)
.opacity(featherA(this.tick, i))
.rotate({ angle: 24 })
.translate({ x: featherX(this.tick, i), y: featherY(this.tick, i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3], (i: number) => {
Row()
.width(46)
.height(3)
.borderRadius(2)
.backgroundColor(COLORS.primaryLight)
.opacity(windA(this.tick, i))
.rotate({ angle: 8 })
.translate({ x: windX(this.tick, i), y: 130 + i * 150 })
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
@Builder
header() {
Column({ space: 10 }) {
// 品牌行 + 定位
Row({ space: 10 }) {
Text('🪶')
.fontSize(24)
Column({ space: 2 }) {
Text('羽语观鸟')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('📍 北湖湿地公园 · 今晨已记录 22 种')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('签到')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(COLORS.accent)
.borderRadius(14)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
// 候鸟迁徙卡
Row({ space: 12 }) {
Column({ space: 4 }) {
Text('白鹭先头部队已抵达北湖')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('今晨 6:20 · 3 号观鸟屋 · 34 只落芦苇荡')
.fontSize(10)
.fontColor('#DCEBED')
Row({ space: 6 }) {
Text('迁徙高峰')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.feather)
.borderRadius(6)
Text('今日观测点 7 个')
.fontSize(9)
.fontColor('#DCEBED')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('查看路线')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.feather)
.borderRadius(14)
.onClick(() => {
this.openEdit(1);
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.primary)
.borderRadius(16)
// 周末导赏横幅
Row({ space: 10 }) {
Text('🔭')
.fontSize(22)
Column({ space: 2 }) {
Text('周末猛禽救助开放日 · 招志愿者')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('参与放飞一只康复红隼 · 记 4 学时')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('报名')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(COLORS.accent)
.borderRadius(14)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 数据四格
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('观察天数')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('126 天')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('记录鸟种')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('189 种')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('热门鸟点')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('北湖 3 号')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('羽币')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('¥86')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%')
.padding({ top: 10, bottom: 4 })
}
@Builder
subNav() {
Scroll() {
Row({ space: 10 }) {
ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
Column({ space: 4 }) {
Text(item)
.fontSize(this.subTab === idx ? 13 : 11)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.primaryDark : COLORS.textSecondary)
if (this.subTab === idx) {
Column()
.width(6)
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.accent)
} else {
Column()
.width(6)
.height(6)
.opacity(0)
}
}
.padding({ left: 8, right: 8, top: 6, bottom: 4 })
.backgroundColor(this.subTab === idx ? COLORS.primaryLight : COLORS.bg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.subTab = idx;
})
}, (item: string) => item)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 6, bottom: 10 })
}
@Builder
pageFeatured() {
Column({ space: 12 }) {
// 玩法四格
Column() {
Text('在羽语能玩什么')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ bottom: 10 })
Row({ space: 8 }) {
ForEach(TYPE_LIST, (it: WatchTypeItem) => {
Column({ space: 4 }) {
Text(it.icon)
.fontSize(24)
Text(it.name)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.desc)
.fontSize(9)
.fontColor(COLORS.textHint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(it.hot ? '热门' : ' ')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(it.hot ? COLORS.danger : COLORS.border)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 8, left: 4, right: 4 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}, (it: WatchTypeItem) => it.name)
}
.width('100%')
}
.width('100%')
// 周观察柱状图
Column() {
Row() {
Text('本周观鸟打卡次数')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('单位 次')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Row({ space: 10 }) {
ForEach(WEEK_CHART, (it: WeekChartItem) => {
Column({ space: 4 }) {
Column()
.width(20)
.height(barH(it.value, 16))
.borderRadius(5)
.backgroundColor(birdColor(it.value))
Text(`${it.value}`)
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text(it.label)
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (it: WeekChartItem) => it.label + it.value.toString())
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
.height(140)
.margin({ top: 12 })
Row({ space: 6 }) {
Text('周末出勤占比')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('68%')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 鸟种构成
Column() {
Text('本月目击鸟种构成')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(BIRD_CHART, (it: BirdChartItem) => {
Row({ space: 8 }) {
Text(it.label)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width(70)
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.value}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.layoutWeight(1)
Text(`${it.value}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (it: BirdChartItem) => it.label)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 热门鸟种双列
Column() {
Row() {
Text('人气鸟种图鉴')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('查看全部')
.fontSize(11)
.fontColor(COLORS.primary)
}
.width('100%')
.margin({ bottom: 10 })
ForEach(BIRD_LIST, (it: BirdItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
if (idx < BIRD_LIST.length) {
Column({ space: 6 }) {
Row() {
Text('🐦')
.fontSize(22)
.layoutWeight(1)
Text(BIRD_LIST[idx].rarity)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(rarityColor(BIRD_LIST[idx].rarity))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(BIRD_LIST[idx].name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${BIRD_LIST[idx].habitat} · 目击 ${BIRD_LIST[idx].seen}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
if (idx + 1 < BIRD_LIST.length) {
Column({ space: 6 }) {
Row() {
Text('🦅')
.fontSize(22)
.layoutWeight(1)
Text(BIRD_LIST[idx + 1].rarity)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(rarityColor(BIRD_LIST[idx + 1].rarity))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(BIRD_LIST[idx + 1].name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${BIRD_LIST[idx + 1].habitat} · 目击 ${BIRD_LIST[idx + 1].seen}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
}
.width('100%')
}
}, (it: BirdItem) => it.id.toString())
}
.width('100%')
// 鸟友动态
Column({ space: 10 }) {
Row() {
Text('鸟友动态')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发羽语')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(22)
Column({ space: 3 }) {
Row() {
Text(it.nick)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`🪶 ${it.likes}`)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: PostItem) => it.id.toString())
}
.width('100%')
}
.width('100%')
}
@Builder
pageBirds() {
Column({ space: 10 }) {
Row() {
Text('鸟种橱窗')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('上传照片鉴定')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(BIRD_LIST, (it: BirdItem) => {
Column({ space: 6 }) {
Row() {
Column()
.width(4)
.height(38)
.borderRadius(2)
.backgroundColor(birdTagColor(it.name))
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.rarity)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(rarityColor(it.rarity))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.bg)
.borderRadius(6)
}
.width('100%')
Row({ space: 8 }) {
Text(`🌿 ${it.habitat}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`👀 目击 ${it.seen}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${it.seen}%`)
.height(6)
.borderRadius(3)
.backgroundColor(birdTagColor(it.name))
}
.width('100%')
Row({ space: 10 }) {
Text(`人气值 ${it.seen}`)
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('加入图鉴夹')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.primary)
.borderRadius(8)
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: BirdItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageWatches() {
Column({ space: 10 }) {
Text('我的观察')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(this.watchList, (it: WatchItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('🔭')
.fontSize(24)
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.status)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(statusColor(it.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(`🦜 ${it.item}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${statusProgress(it.status)}%`)
.height(6)
.borderRadius(3)
.backgroundColor(statusColor(it.status))
}
.width('100%')
Row() {
Text(`观察进度 ${statusProgress(it.status)}%`)
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('编辑记录')
.fontSize(11)
.fontColor(COLORS.primary)
.onClick(() => {
this.openEdit(idx);
})
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: WatchItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageRoute() {
Column({ space: 10 }) {
Text('观鸟路线')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('今日出发')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('6:20')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('路线全长')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('4.6 km')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('观测点')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('7 个')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('预计鸟种')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('35+')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
ForEach(BIRD_LIST.slice(0, 5), (it: BirdItem, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.primary : COLORS.textHint)
.width(20)
Column({ space: 3 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${it.habitat} · 稀有度 ${it.rarity}`)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(`目击 ${it.seen}`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(birdTagColor(it.name))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: BirdItem) => it.id.toString())
Text('装备速购')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ top: 4 })
ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
if (idx < GEAR_LIST.length) {
Column({ space: 6 }) {
Text(GEAR_LIST[idx].icon)
.fontSize(26)
Text(GEAR_LIST[idx].name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(GEAR_LIST[idx].desc)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('加购')
.fontSize(11)
.fontColor(COLORS.primary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
if (idx + 1 < GEAR_LIST.length) {
Column({ space: 6 }) {
Text(GEAR_LIST[idx + 1].icon)
.fontSize(26)
Text(GEAR_LIST[idx + 1].name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(GEAR_LIST[idx + 1].desc)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('加购')
.fontSize(11)
.fontColor(COLORS.primary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
}
.width('100%')
}
}, (it: GearItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageCourse() {
Column({ space: 10 }) {
Text('鸟类课堂')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(COURSE_LIST, (it: CourseItem) => {
Column({ space: 8 }) {
Row() {
Text(it.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.week)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.progress}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.width('100%')
Row() {
Text('已学')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(`${it.progress}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: CourseItem) => it.title)
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.opacity(0.9)
.onClick(() => {
this.openDelB();
})
Text('退课')
.fontSize(14)
.fontColor(COLORS.white)
.margin({ left: -28, top: -32 })
}
.width('100%')
}
@Builder
pageCircle() {
Column({ space: 10 }) {
Row() {
Text('鸟友圈')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发羽语')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.postList, (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(26)
Column({ space: 4 }) {
Row() {
Text(it.nick)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(13)
.fontColor(COLORS.textSecondary)
Text(`🪶 ${it.likes} · 💬 回复`)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: PostItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageGear() {
Column({ space: 10 }) {
Text('装备库')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('🔭')
.fontSize(28)
Text('光学区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('双筒 · 单筒')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text('📷')
.fontSize(28)
Text('影像区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('长焦 · 记录')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text('🕸️')
.fontSize(28)
Text('蹲守区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('伪装 · 帐篷')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
ForEach(GEAR_LIST, (it: GearItem) => {
Row({ space: 10 }) {
Text(it.icon)
.fontSize(20)
Column({ space: 3 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.desc)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('加购')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.primary)
.borderRadius(8)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: GearItem) => it.id.toString())
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.opacity(0.9)
.onClick(() => {
this.openDelA();
})
Text('取消观察')
.fontSize(14)
.fontColor(COLORS.white)
.margin({ left: -56, top: -32 })
}
.width('100%')
}
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🪶')
.fontSize(40)
Column({ space: 4 }) {
Text('羽语观鸟人')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('白鹭会员 · 观察 126 天 · 鸟种 189 · 救助 6 次')
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column({ space: 10 }) {
Row() {
Text('我的观察档案')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.watchList.length} 单`)
.fontSize(12)
.fontColor(COLORS.primary)
}
.width('100%')
ForEach(this.watchList.slice(0, 3), (it: WatchItem) => {
Row() {
Text(it.name)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(it.status)
.fontSize(11)
.fontColor(statusColor(it.status))
}
.width('100%')
}, (it: WatchItem) => it.id.toString())
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column({ space: 10 }) {
Row() {
Text('我的羽语')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.postList.length} 条`)
.fontSize(12)
.fontColor(COLORS.primary)
}
.width('100%')
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row() {
Text(it.nick)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(it.time)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
}, (it: PostItem) => it.id.toString())
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
}
.width('100%')
}
@Builder
modalOverlay() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.6)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
})
}
.width('100%')
.height('100%')
}
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发布羽语')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '一句话观鸟日常…', text: this.addTitle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.addTitle = v;
})
TextArea({ placeholder: '补充细节:地点、鸟种、观察心得…', text: this.addContent })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(90)
.onChange((v: string) => {
this.addContent = v;
})
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.addOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
this.doAdd();
})
Text('发布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
@Builder
editModalBody() {
Column({ space: 12 }) {
Text('编辑观察记录')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '观察名称', text: this.editName })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.editName = v;
})
TextInput({ placeholder: '状态(如 观察中)', text: this.editStyle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.editStyle = v;
})
TextArea({ placeholder: '观察内容与备注说明', text: this.editNote })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(70)
.onChange((v: string) => {
this.editNote = v;
})
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.editOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
this.doEdit();
})
Text('保存')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
@Builder
delModalBody() {
Column({ space: 12 }) {
Text(this.delTarget === 'watch' ? '取消观察记录' : '退出鸟类课堂')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(this.delTarget === 'watch' ? '将取消首条观察记录并归档数据,确认执行?' : '将退出首门进行中的课程,确认执行?')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.delOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.danger)
.borderRadius(8)
.onClick(() => {
this.doDel();
})
Text('确认')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
@Builder
bottomBar() {
Row() {
ForEach(NAV_LIST, (it: NavItem, idx: number) => {
Column({ space: 2 }) {
Text(it.icon)
.fontSize(20)
.opacity(this.mainTab === idx ? 1 : 0.55)
Text(it.label)
.fontSize(10)
.fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.mainTab = idx;
})
}, (it: NavItem) => it.label)
}
.width('100%')
.height(56)
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16 })
}
@Builder
mainContent() {
Column() {
this.header()
if (this.mainTab === 0) {
this.subNav()
Scroll() {
Column() {
if (this.subTab === 0) {
this.pageFeatured()
} else if (this.subTab === 1) {
this.pageBirds()
} else if (this.subTab === 2) {
this.pageWatches()
} else if (this.subTab === 3) {
this.pageRoute()
} else if (this.subTab === 4) {
this.pageCourse()
} else if (this.subTab === 5) {
this.pageCircle()
} else {
this.pageGear()
}
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 1) {
Scroll() {
Column() {
this.pageBirds()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 2) {
Scroll() {
Column() {
this.pageCourse()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else {
Scroll() {
Column() {
this.pageMine()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
}
}
.width('100%')
.height('100%')
}
build() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
this.fxLayer()
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
if (this.addOpen) {
Stack() {
this.modalOverlay()
Column() {
this.addModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.editOpen) {
Stack() {
this.modalOverlay()
Column() {
this.editModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.delOpen) {
Stack() {
this.modalOverlay()
Column() {
this.delModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
.width('100%')
.height('100%')
}
}
16.9 最后

"羽语·城市观鸟与猛禽救助预约平台"虽然只是一个单文件的演示项目,但它浓缩了HarmonyOS ArkTS声明式UI开发的核心技术和最佳实践。从色彩系统到数据模型,从状态管理到布局架构,从特效实现到交互设计,每一个模块都体现了对声明式开发范式的深入理解和灵活运用。
湖水蓝与橙色的色彩搭配不仅营造了观鸟主题的视觉氛围,更建立了一套完整的语义化色彩系统;@Observed装饰器不仅实现了精细化的状态管理,更为后续的功能扩展埋下了伏笔;纯函数驱动的特效不仅实现了流畅的动画效果,更展示了函数式编程在UI开发中的独特魅力。展示了如何从零开始构建一个结构清晰、功能完整、视觉精美的声明式UI应用。对于有经验的开发者来说,这个项目中的某些设计思路(如居中圆点指示导航、纯函数特效、设计令牌系统)也值得参考和借鉴。
更多推荐




所有评论(0)