鸿蒙操作系统(HarmonyOS)作为华为推出的面向全场景的分布式操作系统,其应用开发框架 ArkUI 提供了一套基于 TypeScript 扩展的声明式开发范式——ArkTS。ArkTS 在 TypeScript 的基础上增加了装饰器语法、状态管理、组件化构建等特性,使得开发者能够以极简的声明式语法构建出高性能、高可维护性的用户界面。在 ArkTS 的开发模式中,一切 UI 皆组件,一切状态皆响应式,开发者只需描述界面"是什么",框架便会自动处理"怎么渲染"以及"何时更新"的问题。这种范式转变彻底改变了传统命令式 UI 开发中手动操作 DOM 节点、管理渲染时序的繁琐模式,让开发者能够将精力集中在业务逻辑与用户体验本身。

ArkTS 的核心思想可以概括为三个层次:声明式 UI 描述响应式状态驱动组件化架构组织。声明式 UI 描述意味着开发者通过链式调用的方式构建组件树,每个组件的属性、事件、子组件都以声明的方式给出;响应式状态驱动则通过 @State@Prop@Link 等装饰器实现数据到视图的自动绑定,当状态变量发生变化时,框架会自动触发依赖该变量的 UI 组件重新渲染;组件化架构组织则通过 @Component 装饰器将 struct 标记为可复用的 UI 单元,通过 @Builder 装饰器提取可复用的 UI 片段,形成从原子组件到页面组件的完整层级体系。

在现代移动应用开发中,同城社交类应用是一个极具代表性的场景。这类应用通常包含信息流展示、多维度筛选、表单交互、即时通讯、数据可视化、个人中心等多种功能模块,几乎涵盖了移动端 UI 开发的所有常见模式。本文将以一个同城兴趣组局平台为例,深入剖析其 ArkTS 源码的每一个技术细节,从配色系统的设计到数据模型的定义,从纯函数的筛选逻辑到组件的状态管理,从布局容器的嵌套使用到自定义弹框的构建方式,从动画特效的实现到自绘底部导航栏的绘制,全面覆盖 ArkTS 开发的核心知识点。

一、配色系统与常量定义

1.1 配色接口的定义

interface PlayColor {
  bg: string
  card: string
  primary: string
  accent: string
  blue: string
  yellow: string
  ink: string
  sub: string
  hint: string
  lav: string
  sand: string
  cyan: string
  mint: string
  line: string
  danger: string
  redBg: string
  green: string
  white: string
}

在这里插入图片描述

在 ArkTS 中,interface 关键字用于定义对象的类型结构。这里定义了一个名为 PlayColor 的接口,它声明了 18 个字符串类型的字段,分别对应应用中使用的 18 种颜色。这种做法是前端工程中"设计令牌(Design Token)"思想的体现:将所有视觉相关的色彩值集中管理,避免在代码中散落硬编码的颜色字符串。

接口中的 bg 代表页面背景色,card 代表卡片背景色,primary 是主色调(落日紫),accent 是强调色(活力橙),blue 是辅助色(湖蓝),yellow 是点缀色(柠檬黄)。ink 代表正文文字颜色,sub 代表次要文字颜色,hint 代表提示文字颜色,这三者形成了文字颜色的三级层次。lav(薰衣草色)、sand(沙色)、cyan(青色)、mint(薄荷色)则是用于不同品类标签背景的浅色系。line 是分割线颜色,dangerredBg 用于警告和危险操作的色彩表达,green 用于成功状态的展示。

设计令牌的集中管理是大型应用可维护性的基石。当设计团队要求调整某个色值时,开发者只需修改一处常量定义,所有引用该常量的组件都会自动更新,这比在数百处代码中逐一查找替换要安全得多。在 ArkTS 中,由于类型系统的存在,如果某个颜色字段被重命名,编译器会在所有引用处报错,进一步保障了重构的安全性。

1.2 配色常量的实例化

const PC: PlayColor = {
  bg: '#FFF9F4',
  card: '#FFFFFF',
  primary: '#7C5CBF',
  accent: '#FF8A3D',
  blue: '#38B6C9',
  yellow: '#FFD662',
  ink: '#2E2438',
  sub: '#8B8494',
  hint: '#BDB6C7',
  lav: '#F1EBFA',
  sand: '#FFF1E3',
  cyan: '#E3F5F8',
  mint: '#E9F6EE',
  line: '#F1E7D9',
  danger: '#E5484D',
  redBg: '#FDECEC',
  green: '#4CAF7D',
  white: '#FFFFFF'
}

在这里插入图片描述

这里使用 const 关键字声明了一个 PlayColor 类型的常量 PC,并赋予了所有字段具体的十六进制颜色值。const 确保了这个引用不会被重新赋值,但由于对象属性的值理论上仍可修改,在实际 ArkTS 编译器的严格模式下,这种常量对象通常被视为只读。

从色彩搭配的角度来看,这个配色方案采用了"多巴胺活力浅色风":奶油白底(#FFF9F4)作为全局背景,营造出温暖柔和的基调;落日紫(#7C5CBF)作为主色,传达出年轻时尚的品牌调性;活力橙(#FF8A3D)作为强调色,用于关键操作按钮和价格信息,刺激用户行动;湖蓝(#38B6C9)和柠檬黄(#FFD662)作为辅助色,分别用于不同品类的标识区分。这种配色策略在社交类应用中非常常见,既保证了视觉层次分明,又营造出活泼愉悦的氛围。

1.3 底部 Tab 定义

interface TabDef {
  icon: string
  label: string
}
const PLAY_TABS: TabDef[] = [
  { icon: '🧭', label: '发现' },
  { icon: '🎯', label: '组局' },
  { icon: '🤝', label: '搭子' },
  { icon: '🏟️', label: '场地' },
  { icon: '💬', label: '消息' },
  { icon: '👤', label: '我的' }
]

这里定义了底部导航栏的 6 个标签项。TabDef 接口包含 icon(图标)和 label(文字标签)两个字段。PLAY_TABS 是一个 TabDef 类型的数组,包含了 6 个标签定义。值得注意的是,图标使用的是 Emoji 字符而非图片资源,这种做法在小规模纯 UI 演示项目中是合理的——Emoji 字符无需额外的图片资源文件,且在不同设备上均能正常显示,极大简化了资源管理。

在 ArkTS 中,数组类型使用 类型[] 的语法声明,这与 TypeScript 完全一致。数组字面量使用方括号包裹,元素之间用逗号分隔。这个数组将在主入口组件中通过 ForEach 循环渲染为底部的 6 个 Tab 按钮。

二、数据模型体系

2.1 兴趣标签数据模型

interface TagItem {
  id: number
  name: string
  icon: string
  hot: number
}
const TAG_LIST: TagItem[] = [
  { id: 1, name: '全部', icon: '✨', hot: 1 },
  { id: 2, name: '剧本杀', icon: '🎭', hot: 3 },
  { id: 3, name: '飞盘', icon: '🥏', hot: 2 },
  { id: 4, name: '骑行', icon: '🚴', hot: 2 },
  { id: 5, name: '桌游', icon: '🎲', hot: 1 },
  { id: 6, name: '爬山', icon: '⛰️', hot: 1 },
  { id: 7, name: '露营', icon: '⛺', hot: 1 },
  { id: 8, name: 'Live现场', icon: '🎤', hot: 0 },
  { id: 9, name: '羽毛球', icon: '🏸', hot: 0 },
  { id: 10, name: 'CityWalk', icon: '🚶', hot: 0 }
]

TagItem 接口定义了兴趣标签的数据结构,包含 id(唯一标识)、name(标签名称)、icon(图标 Emoji)和 hot(热度等级)四个字段。TAG_LIST 数组包含了 10 个兴趣标签,从"全部"到"CityWalk",覆盖了当前流行的各种同城社交活动品类。

这种数据驱动的 UI 模式是 ArkTS 开发的核心理念之一。开发者不需要在 UI 代码中硬编码每个标签,而是通过 ForEach 遍历数据数组动态生成标签组件。当需要增加新的兴趣品类时,只需在数据数组中添加一条记录,UI 会自动渲染出新的标签,无需修改任何组件代码。

2.2 本周热门 Banner 数据模型

interface BannerItem {
  id: number
  title: string
  sub: string
  icon: string
  tag: string
  bg: string
}

在这里插入图片描述

BannerItem 接口定义了横幅广告条目的数据结构。title 是主标题,sub 是副标题,tag 是标签(如"本周热门"、“免费体验”),bg 是背景色。每个 Banner 条目都携带自己的背景色信息,这使得不同 Banner 可以呈现不同的视觉风格,增强了页面的视觉丰富度。

const BANNER_LIST: BannerItem[] = [
  { id: 1, title: '周五剧本杀之夜', sub: '6 人本 · 换装沉浸 · DM 带车', icon: '🎭', tag: '本周热门', bg: '#7C5CBF' },
  { id: 2, title: '周末飞盘联赛', sub: '新手教学 · 装备全包', icon: '🥏', tag: '免费体验', bg: '#FF8A3D' },
  { id: 3, title: '夜骑珠江 15km', sub: '滨江路线 · 收尾撸串', icon: '🚴', tag: '夜行局', bg: '#38B6C9' },
  { id: 4, title: '白云山看日出', sub: '周末清晨 · 轻徒步', icon: '⛰️', tag: '治愈系', bg: '#4CAF7D' },
  { id: 5, title: '桌游血战到底', sub: '狼人杀 · 德州 · 卡坦岛', icon: '🎲', tag: '通宵局', bg: '#E5484D' },
  { id: 6, title: '露营观星过夜局', sub: '天幕烧烤 · 星空摄影', icon: '⛺', tag: '小众精品', bg: '#2E2438' }
]

在这里插入图片描述

BANNER_LIST 包含了 6 个 Banner 条目,每个条目的 bg 字段对应不同的主题色。在 UI 渲染时,这些背景色会与渐变效果配合,生成具有视觉冲击力的横幅卡片。

2.3 同城活动数据模型

interface EventItem {
  id: number
  name: string
  icon: string
  cat: string
  zone: string
  time: string
  joined: number
  cap: number
  fee: number
  host: string
  hot: number
  level: string
  picH: number
  desc: string
}

EventItem 是整个应用中最核心的数据模型,定义了同城活动的完整信息结构。cat 代表活动品类,zone 代表地点区域,joinedcap 分别代表已报名人数和人数上限,fee 代表人均费用,host 代表活动发起人(局长),hot 代表热度值,level 代表难度等级,picH 代表封面图片高度(用于瀑布流不等高布局),desc 代表活动描述。

EVENT_LIST 数组包含了 16 条活动数据,覆盖了剧本杀、骑行、飞盘、爬山、桌游、露营、CityWalk、Live 现场、羽毛球等多个品类。每条数据都是一个完整的活动信息单元,在 UI 中会被渲染为瀑布流卡片。

瀑布流布局(Waterfall Layout)是社交类应用中常见的内容展示方式,它的核心特点是列内元素等宽不等高,形成参差错落的视觉效果。在本项目中,瀑布流的实现采用了手动分列的策略:通过 eventsLefteventsRight 两个纯函数,将活动数据按索引奇偶性分成左右两列,再分别用 ForEach 渲染。这种方式虽然不如专门的瀑布流组件灵活,但在纯 UI 演示场景下足够简洁有效。

2.4 图表数据模型

interface HeatDay {
  day: string
  games: number
  mates: number
}
const WEEK_HEAT: HeatDay[] = [
  { day: '周一', games: 12, mates: 26 },
  { day: '周二', games: 15, mates: 31 },
  { day: '周三', games: 21, mates: 44 },
  { day: '周四', games: 26, mates: 52 },
  { day: '周五', games: 38, mates: 76 },
  { day: '周六', games: 44, mates: 89 },
  { day: '周日', games: 35, mates: 71 }
]

在这里插入图片描述

HeatDay 接口定义了一周内每天的组局热度数据,games 代表当日组局数,mates 代表当日参与搭子数。这组数据将用于渲染柱状图,展示一周内的组局热度趋势。在 ArkTS 中,柱状图的实现并不需要引入第三方图表库——开发者可以利用 Column 组件的高度属性直接绘制柱体,每个柱子的高度由数据值乘以一个缩放系数得出。

interface CatShare {
  name: string
  share: number
  count: number
  color: string
}
const CAT_SHARE: CatShare[] = [
  { name: '剧本杀', share: 32, count: 124, color: '#7C5CBF' },
  { name: '飞盘', share: 26, count: 101, color: '#FF8A3D' },
  { name: '骑行', share: 18, count: 69, color: '#38B6C9' },
  { name: '桌游', share: 12, count: 46, color: '#FFD662' },
  { name: '爬山', share: 8, count: 31, color: '#4CAF7D' },
  { name: '露营', share: 4, count: 15, color: '#E5484D' }
]

CatShare 接口定义了活动品类分布的占比数据,share 是百分比,count 是场次数量,color 是该品类的主题色。这组数据将用于渲染环形占比图,通过嵌套多个 Progress 环形组件来实现同心圆环的视觉效果。

2.5 其他核心数据模型

interface MyGame {
  id: number
  name: string
  icon: string
  cat: string
  time: string
  place: string
  joined: number
  cap: number
  status: string
  fee: number
  income: number
  note: string
}

在这里插入图片描述

MyGame 接口定义了用户发起的组局数据结构,status 字段表示组局状态(报名中、已成局、进行中、已结束),income 字段表示预收费用总额,note 字段存储组局备注信息。MY_GAME_LIST 数组包含 8 条数据,模拟了用户作为局长管理的各类活动。

interface MemberItem {
  id: number
  name: string
  avatar: string
  role: string
  age: number
  tag: string
  match: number
  status: string
}

MemberItem 接口定义了活动成员的数据结构,role 字段标识成员角色(局长、DM、副局、成员),match 字段表示与当前用户的匹配度,status 字段表示到场状态(已到场、已报名、候补中)。MEMBER_LIST 数组包含 10 条成员数据。

interface MateItem {
  id: number
  name: string
  avatar: string
  gender: string
  age: number
  city: string
  hobby: string
  match: number
  joined: number
  tag: string
  credit: string
}

在这里插入图片描述

MateItem 接口定义了搭子(社交伙伴)的数据结构,gender 表示性别,city 表示所在区域,hobby 表示爱好,joined 表示共同组局次数,credit 表示信用等级(AAA、AA、A)。MATE_LIST 数组包含 12 位搭子数据。

interface VenueItem {
  id: number
  name: string
  icon: string
  kind: string
  dist: number
  price: number
  origPrice: number
  rating: number
  rooms: number
  tag: string
  hot: number
  support: string
}

在这里插入图片描述

VenueItem 接口定义了活动场地的数据结构,dist 表示距离(公里),priceorigPrice 分别表示现价和原价,rating 表示评分,rooms 表示空间数量,support 表示支持的玩法类型。VENUE_LIST 数组包含 10 个场地数据。

interface ChatItem {
  id: number
  name: string
  avatar: string
  last: string
  time: string
  unread: number
  kind: string
  online: number
}

ChatItem 接口定义了消息会话的数据结构,last 表示最后一条消息,unread 表示未读消息数,kind 表示会话类型(群聊、私聊、系统),online 表示在线人数。CHAT_LIST 数组包含 12 条会话数据。

interface BadgeItem {
  id: number
  name: string
  icon: string
  got: number
  desc: string
}

BadgeItem 接口定义了成就徽章的数据结构,got 字段使用 0 和 1 表示是否已获得。BADGE_LIST 数组包含 10 个徽章,其中 6 个已解锁、4 个未解锁。

interface NoticeItem {
  id: number
  title: string
  time: string
  icon: string
  kind: string
}

NoticeItem 接口定义了系统通知的数据结构,kind 字段标识通知类型(报名、成局、申请、报告、徽章、局规、场地、搭子)。NOTICE_LIST 数组包含 8 条通知数据。

interface MyJoinItem {
  id: number
  name: string
  icon: string
  cat: string
  date: string
  status: string
}

MyJoinItem 接口定义了用户参加的活动数据结构,比 MyGame 更简洁,只保留了展示所需的核心字段。MY_JOIN_LIST 数组包含 6 条数据。

2.6 常量数组定义

const TIME_SLOTS: string[] = [
  '本周五 19:00', '本周五 20:00', '本周六 10:00', '本周六 14:00',
  '本周六 19:00', '本周日 09:00', '本周日 15:00', '下周六 10:00'
]
const FEE_OPTIONS: number[] = [0, 30, 58, 88, 128]
const VENUE_TAGS: string[] = ['全部', '剧本杀', '桌游', '飞盘', '骑行', '露营']
const HOBBY_TAGS: string[] = ['剧本杀', '飞盘', '骑行', '桌游', '爬山', '露营', 'CityWalk', '羽毛球']

这些常量数组分别定义了时间段选项、费用档位选项、场地筛选标签和爱好标签。它们在表单弹框和筛选组件中通过 ForEach 渲染为可选择的标签按钮。FEE_OPTIONS 数组中的 0 代表免费,其余值代表不同的人均费用档位。

三、全局纯函数体系

3.1 瀑布流分列函数

function eventsLeft(cat: string): EventItem[] {
  let r: EventItem[] = []
  for (let i = 0; i < EVENT_LIST.length; i++) {
    if (i % 2 === 0 && (cat === '全部' || EVENT_LIST[i].cat === cat)) {
      r.push(EVENT_LIST[i])
    }
  }
  return r
}

function eventsRight(cat: string): EventItem[] {
  let r: EventItem[] = []
  for (let i = 0; i < EVENT_LIST.length; i++) {
    if (i % 2 === 1 && (cat === '全部' || EVENT_LIST[i].cat === cat)) {
      r.push(EVENT_LIST[i])
    }
  }
  return r
}

这两个函数实现了瀑布流的左右分列逻辑。eventsLeft 筛选索引为偶数的活动(0, 2, 4, …),eventsRight 筛选索引为奇数的活动(1, 3, 5, …)。两个函数都接受一个 cat 参数用于品类筛选:当 cat 为"全部"时返回所有对应列的活动,否则只返回指定品类的活动。

这种设计巧妙地将瀑布流分列和品类筛选两个需求合并在一个函数中完成。使用传统的 for 循环而非数组的 filter 方法,是因为需要同时访问元素索引和元素值。在 ArkTS 中,函数声明使用 function 关键字,参数和返回值都需要明确标注类型,这是 ArkTS 作为强类型语言的体现。

纯函数(Pure Function)是指给定相同的输入永远产生相同输出、且不产生副作用的函数。本项目中的所有全局函数都是纯函数——它们不修改任何外部状态,不依赖任何可变的全局变量,只根据输入参数计算并返回结果。纯函数的使用使得 UI 渲染结果可预测、可测试,当数据源不变时,多次调用同一函数得到的结果完全一致,这是响应式 UI 系统稳定运行的基础。

3.2 排序与筛选函数

function hotEvents(): EventItem[] {
  let r: EventItem[] = EVENT_LIST.slice()
  r.sort((a: EventItem, b: EventItem) => b.hot - a.hot)
  return r
}

function mateSorted(): MateItem[] {
  let r: MateItem[] = MATE_LIST.slice()
  r.sort((a: MateItem, b: MateItem) => b.match - a.match)
  return r
}

hotEvents 函数返回按热度值降序排列的活动列表,mateSorted 函数返回按匹配度降序排列的搭子列表。两个函数都使用了 slice() 方法先创建数组的副本,再对副本进行排序,避免了修改原始数据。排序比较函数 (a, b) => b.hot - a.hot 表示当 b 的热度大于 a 时 b 排在前面,即降序排列。

function unreadChats(): ChatItem[] {
  let r: ChatItem[] = []
  for (let i = 0; i < CHAT_LIST.length; i++) {
    if (CHAT_LIST[i].unread > 0) {
      r.push(CHAT_LIST[i])
    }
  }
  return r
}

function venuesOf(tag: string): VenueItem[] {
  let r: VenueItem[] = []
  for (let i = 0; i < VENUE_LIST.length; i++) {
    if (tag === '全部' || VENUE_LIST[i].support.indexOf(tag) >= 0) {
      r.push(VENUE_LIST[i])
    }
  }
  return r
}

function cheapVenues(): VenueItem[] {
  let r: VenueItem[] = VENUE_LIST.slice()
  r.sort((a: VenueItem, b: VenueItem) => a.price - b.price)
  return r
}

function unreadCount(): number {
  let n: number = 0
  for (let i = 0; i < CHAT_LIST.length; i++) {
    n = n + CHAT_LIST[i].unread
  }
  return n
}

unreadChats 筛选出所有有未读消息的会话,venuesOf 按玩法标签筛选场地(使用 indexOf 检查场地的 support 字段是否包含指定玩法),cheapVenues 按价格升序排列场地,unreadCount 统计所有会话的未读消息总数。这些函数都是对原始数据的加工转换,为 UI 层提供精确的展示数据。

3.3 颜色映射函数

function catTint(cat: string): string {
  if (cat === '剧本杀') { return '#F1EBFA' }
  if (cat === '飞盘') { return '#FFF1E3' }
  if (cat === '骑行') { return '#E3F5F8' }
  if (cat === '爬山') { return '#E9F6EE' }
  if (cat === '桌游') { return '#FFF6DF' }
  return '#F6F1EA'
}

function catColor(cat: string): string {
  if (cat === '剧本杀') { return PC.primary }
  if (cat === '飞盘') { return PC.accent }
  if (cat === '骑行') { return PC.blue }
  if (cat === '爬山') { return PC.green }
  if (cat === '桌游') { return '#D9A400' }
  return PC.sub
}

catTint 返回品类对应的浅色背景色,catColor 返回品类对应的主色调。这两个函数通过 if 语句链实现品类到颜色的映射。每个品类都有自己专属的色彩主题:剧本杀对应紫色系,飞盘对应橙色系,骑行对应蓝色系,爬山对应绿色系,桌游对应黄色系。这种"一品一色"的设计使得用户能够通过颜色快速识别活动品类,提升了信息辨识效率。

function matchColor(m: number): string {
  if (m >= 90) { return PC.primary }
  if (m >= 80) { return PC.accent }
  return PC.blue
}

function rankColor(i: number): string {
  if (i === 0) { return PC.accent }
  if (i === 1) { return PC.primary }
  if (i === 2) { return PC.blue }
  return PC.sub
}

function statusColor(s: string): string {
  if (s === '报名中') { return PC.accent }
  if (s === '已成局') { return PC.green }
  if (s === '进行中') { return PC.primary }
  return PC.sub
}

matchColor 根据匹配度数值返回对应颜色(90 以上紫色、80 以上橙色、其余蓝色),rankColor 根据排名索引返回前三名的奖牌色(第一橙色、第二紫色、第三蓝色),statusColor 根据组局状态返回对应颜色。这些颜色映射函数将数据语义与视觉表达关联起来,使颜色不仅仅是装饰,更是信息传递的载体。

四、DiscoverContent 组件——发现页

DiscoverContent 组件

头部导航栏

可滚动内容区

报名弹框层

城市定位

搜索框

消息通知

大 Banner

迷你 Banner x2

兴趣标签横滑

双列瀑布流

热度柱状图

热度榜 TOP6

遮罩层

活动信息卡

报名人数选择

费用计算

协议勾选

操作按钮

4.1 组件声明与状态定义

@Component
struct DiscoverContent {
  @State selTag: string = '全部'
  @State selEvt: number = 0
  @State cityIdx: number = 0
  @State keyword: string = ''
  @State unreadTotal: number = 33
  @State showJoin: boolean = false
  @State joinCount: number = 1
  @State agreeRule: boolean = true
  @State hotScale: number = 1
  @State liveOp: number = 1

@Component 装饰器将 struct DiscoverContent 标记为一个自定义组件。在 ArkTS 中,struct 是一种特殊的语法结构,用于定义组件——它类似于类,但不能被实例化,而是由框架在渲染时自动创建和管理。每个 @Component 修饰的 struct 必须实现 build() 方法,该方法返回该组件的 UI 描述。

@State 装饰器是 ArkTS 状态管理体系中最基础的状态装饰器。被 @State 修饰的变量是组件内部的响应式状态:当这些变量的值发生变化时,框架会自动找到所有引用了该变量的 UI 组件并重新渲染它们。@State 变量必须在声明时初始化,不能为 undefinednull(除非类型允许)。

这里定义了 10 个状态变量:selTag 控制当前选中的兴趣标签(默认"全部"),selEvt 记录当前选中活动的索引,cityIdx 记录当前城市索引,keyword 存储搜索关键词,unreadTotal 记录未读消息总数,showJoin 控制报名弹框的显示与隐藏,joinCount 记录报名人数选择,agreeRule 记录是否同意协议,hotScaleliveOp 是用于动画效果的状态变量。

@State 装饰器的响应式机制基于"依赖追踪"原理:在 build() 方法执行期间,框架会记录每个状态变量被哪些 UI 组件引用。当某个状态变量发生变化时,框架只需重新执行依赖该变量的那部分 UI 构建,而非整个 build() 方法。这种细粒度的更新机制保证了 UI 渲染的高性能——即使页面包含数百个组件,修改一个状态变量也只会触发最小范围的重新渲染。

4.2 生命周期与动画初始化

aboutToAppear() {
  this.getUIContext().animateTo({
    duration: 900,
    iterations: -1,
    playMode: PlayMode.Alternate,
    curve: Curve.EaseInOut
  }, () => {
    this.hotScale = 1.18
  })
  this.getUIContext().animateTo({
    duration: 600,
    iterations: -1,
    playMode: PlayMode.Alternate,
    curve: Curve.EaseInOut
  }, () => {
    this.liveOp = 0.25
  })
}

aboutToAppear() 是 ArkTS 组件的生命周期回调函数,在组件创建后、build() 方法执行前被调用。这个时机适合进行状态初始化、数据请求和动画启动等操作。

这里启动了两个无限循环动画。第一个动画操作 hotScale 状态变量,在 900 毫秒内从 1 变化到 1.18,使用 PlayMode.Alternate(交替模式)使得动画在正向和反向之间来回切换,配合 iterations: -1(无限循环),形成了持续呼吸放缩的效果。这个动画作用于活动卡片上的热度火焰图标,营造出"热度正在燃烧"的视觉隐喻。

第二个动画操作 liveOp 状态变量,在 600 毫秒内从 1 变化到 0.25,形成透明度闪烁效果。这个动画作用于柱状图区域的"实时更新中"红点指示器,传达数据正在实时更新的信息。

animateTo 是 ArkUI 提供的显式动画 API,接受两个参数:动画选项对象和闭包函数。动画选项中的 duration 控制动画时长(毫秒),iterations 控制循环次数(-1 为无限循环),playMode 控制播放模式,curve 控制缓动曲线。闭包函数内对状态变量的修改会被框架捕获,并在动画选项的约束下平滑过渡,而非立即生效。

4.3 通用遮罩 Builder

@Builder modalOverlay(onClose: () => void) {
  Column().width('100%').height('100%').backgroundColor('rgba(46,36,56,0.6)').onClick(onClose)
}

@Builder 装饰器用于定义可复用的 UI 构建函数。与直接在 build() 中编写 UI 不同,@Builder 方法可以接受参数,并在多处被调用以复用相同的 UI 结构。这里定义了一个通用的全屏遮罩层:一个铺满整个屏幕的 Column,背景色为半透明的深紫色,点击时调用传入的 onClose 回调函数。

Column 是 ArkUI 中最基本的布局容器之一,它将其子组件按照垂直方向从上到下排列。通过设置 width('100%')height('100%'),这个 Column 会占据父容器的全部空间。backgroundColor 属性接受 RGBA 格式的颜色字符串,rgba(46,36,56,0.6) 表示深紫色带有 60% 的不透明度。

在 ArkTS 中,ColumnRow 是最常用的两个线性布局容器。Column 沿主轴(垂直方向)排列子组件,Row 沿主轴(水平方向)排列子组件。它们的交叉轴对齐方式通过 alignItems 属性控制,主轴对齐方式通过 justifyContent 属性控制。理解这两个容器的行为是掌握 ArkUI 布局体系的基础。

4.4 活动卡片 Builder

@Builder eventCard(e: EventItem) {
  Column() {
    Stack({ alignContent: Alignment.TopEnd }) {
      Column() {
        Text(e.icon).fontSize(34)
      }.width('100%').height(e.picH).backgroundColor(catTint(e.cat)).borderRadius(12).justifyContent(FlexAlign.Center)

      Text(e.level).fontSize(8).fontColor(PC.white).backgroundColor(catColor(e.cat)).padding({ left: 7, right: 7, top: 3, bottom: 3 }).borderRadius(8).margin(8)
    }.width('100%')

eventCard 是发现页瀑布流中每个活动卡片的 UI 构建 方法,接受一个 EventItem 类型的参数。卡片的整体结构是一个 Column,内部从上到下依次是封面区、标签行、标题、位置时间、描述、报名进度条。

封面区使用了 Stack 布局容器。Stack 是 ArkUI 中的层叠布局容器,它将子组件堆叠在一起,后声明的子组件覆盖在先声明的子组件之上。alignContent: Alignment.TopEnd 参数指定子组件在 Stack 内的对齐方式为右上角对齐。封面区的底层是一个 Column,其高度由 e.picH 决定——这是实现瀑布流不等高效果的关键:不同卡片的封面高度不同,形成参差错落的视觉节奏。封面上层叠了一个难度等级标签 Text(e.level),定位在右上角。

    Row() {
      Text(e.cat).fontSize(8).fontColor(catColor(e.cat)).backgroundColor(catTint(e.cat)).padding({ left: 7, right: 7, top: 3, bottom: 3 }).borderRadius(7)
      Column().layoutWeight(1)
      Text('🔥 ' + e.hot).fontSize(8).fontColor(PC.accent).fontWeight(FontWeight.Bold).scale({ x: this.hotScale, y: this.hotScale })
    }.width('100%').margin({ top: 8 })

标签行使用 Row 水平布局,包含品类标签和热度值。中间插入了一个 Column().layoutWeight(1) 作为弹性占位符——layoutWeight 属性使该组件占据父容器中剩余的空间,从而将热度值推到右侧。热度值文本应用了 scale 变换,缩放比例由 this.hotScale 状态变量驱动,配合 aboutToAppear 中启动的呼吸动画,形成了热度火焰持续放缩的效果。

    Text(e.name).fontSize(11).fontWeight(FontWeight.Bold).fontColor(PC.ink).width('100%').margin({ top: 4 }).maxLines(2)

    Text('📍 ' + e.zone + ' · 🕒 ' + e.time).fontSize(8).fontColor(PC.sub).width('100%').margin({ top: 4 }).maxLines(1)

    Text(e.desc).fontSize(8).fontColor(PC.hint).width('100%').margin({ top: 3 }).maxLines(1)

活动标题使用 FontWeight.Bold 加粗显示,maxLines(2) 限制最多显示两行。位置和时间信息使用 Emoji 前缀增强可读性,maxLines(1) 确保单行显示。描述文字使用更浅的颜色 PC.hint 和更小的字号,形成信息层次。

    Column() {
      Row() {
        Column().layoutWeight(e.joined).height(5).backgroundColor(catColor(e.cat)).borderRadius(3)
        Column().layoutWeight(e.cap - e.joined).height(5).backgroundColor('#F2EEE9').borderRadius(3)
      }.width('100%').height(5).borderRadius(3)

      Row() {
        Text(e.joined + '/' + e.cap + ' 已报名').fontSize(8).fontColor(PC.hint)
        Column().layoutWeight(1)
        if (e.fee === 0) {
          Text('免费').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PC.green)
        } else {
          Text('¥' + e.fee + '/人').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PC.accent)
        }
      }.width('100%').margin({ top: 4 })
    }.width('100%').margin({ top: 8 })
  }.width('100%').padding(9).backgroundColor(PC.card).borderRadius(14).margin({ top: 8 }).onClick(() => {
    this.selEvt = e.id - 1
    this.showJoin = true
  })

报名进度条是卡片中最巧妙的 UI 设计之一。它利用 layoutWeight 属性实现了比例条的效果:已报名部分使用 layoutWeight(e.joined),剩余部分使用 layoutWeight(e.cap - e.joined),两者的高度均为 5vp,背景色分别为品类主色和灰色。由于 layoutWeight 按权重分配父容器空间,已报名人数越多,彩色部分就越宽,直观地反映了报名进度。

费用展示使用了 ArkTS 的条件渲染语法:if (e.fee === 0) 判断是否为免费活动,免费活动显示绿色"免费"文字,收费活动显示橙色价格。这种 if-else 条件渲染是 ArkTS 声明式 UI 的核心能力之一。

整个卡片设置了 onClick 事件,点击后设置 selEvt 为当前活动索引并显示报名弹框。

4.5 报名确认弹框

@Builder joinModal() {
  Column() {
    this.modalOverlay(() => {
      this.showJoin = false
    })
    Column() {
      Row() {
        Text('🎯 确认报名').fontSize(16).fontWeight(FontWeight.Bold).fontColor(PC.ink)
        Column().layoutWeight(1)
        Text('✕').fontSize(16).fontColor(PC.hint).onClick(() => {
          this.showJoin = false
        })
      }.width('100%').padding({ left: 18, right: 18, top: 16, bottom: 10 })

报名弹框是一个居中显示的卡片式弹框。整体结构是一个铺满屏幕的 Column,包含两部分:底层是通用遮罩(通过调用 this.modalOverlay 复用),上层是实际的弹框卡片。遮罩的 onClick 回调将 showJoin 设为 false,实现点击遮罩关闭弹框的效果。

弹框卡片的头部使用 Row 水平排列标题和关闭按钮,中间用 Column().layoutWeight(1) 推开。关闭按钮是一个简单的 Text('✕'),点击后关闭弹框。

      Divider().color(PC.line)

      Column() {
        Text(EVENT_LIST[this.selEvt].icon).fontSize(34)
        Text(EVENT_LIST[this.selEvt].name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(PC.white).margin({ top: 6 })
        Text('🕒 ' + EVENT_LIST[this.selEvt].time + ' · 📍 ' + EVENT_LIST[this.selEvt].zone).fontSize(9).fontColor('#FFF3E3').margin({ top: 5 })
        Text('局长 ' + EVENT_LIST[this.selEvt].host + ' · ' + EVENT_LIST[this.selEvt].level).fontSize(9).fontColor(PC.yellow).margin({ top: 4 })
      }.width('100%').padding({ top: 16, bottom: 14 }).alignItems(HorizontalAlign.Center).linearGradient({ direction: GradientDirection.Right, colors: [[PC.primary, 0], [PC.accent, 1]] })

弹框的活动信息区使用了 linearGradient 线性渐变背景。linearGradient 接受一个包含 direction(渐变方向)和 colors(颜色停靠点数组)的参数对象。GradientDirection.Right 表示从左到右渐变,colors: [[PC.primary, 0], [PC.accent, 1]] 表示起点为紫色、终点为橙色。渐变背景上的文字使用白色和浅黄色,确保在深色背景上的可读性。

Divider 组件是 ArkUI 提供的分割线组件,它默认渲染为一条水平线。通过 color 属性可以自定义分割线颜色,通过 strokeWidth 属性可以调整线宽。在列表和卡片中,Divider 常用于在视觉上分隔不同区域的内容。

      Column() {
        Row() {
          Text('剩余名额').fontSize(11).fontColor(PC.sub)
          Column().layoutWeight(1)
          Text((EVENT_LIST[this.selEvt].cap - EVENT_LIST[this.selEvt].joined) + ' 个').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PC.green)
        }.width('100%').margin({ top: 12 })

        Row() {
          Text('报名人数').fontSize(11).fontColor(PC.sub)
          Column().layoutWeight(1)
          Text('-').fontSize(15).fontWeight(FontWeight.Bold).fontColor(this.joinCount > 1 ? PC.ink : PC.hint).width(30).height(30).textAlign(TextAlign.Center).backgroundColor(PC.lav).borderRadius(15).onClick(() => {
            if (this.joinCount > 1) {
              this.joinCount = this.joinCount - 1
            }
          })
          Text(' ' + this.joinCount + ' 人 ').fontSize(12).fontWeight(FontWeight.Bold).fontColor(PC.ink).margin({ left: 10, right: 10 })
          Text('+').fontSize(15).fontWeight(FontWeight.Bold).fontColor(PC.primary).width(30).height(30).textAlign(TextAlign.Center).backgroundColor(PC.lav).borderRadius(15).onClick(() => {
            if (this.joinCount < 5) {
              this.joinCount = this.joinCount + 1
            }
          })
        }.width('100%').margin({ top: 12 }).alignItems(VerticalAlign.Center)

报名人数选择器是一个自定义的步进器组件。减号按钮和加号按钮都是圆形的 Text 组件(通过 borderRadius(15)width(30).height(30) 实现圆形),点击时修改 joinCount 状态变量。减号按钮在 joinCount 为 1 时变灰(颜色判断使用三元表达式 this.joinCount > 1 ? PC.ink : PC.hint),加号按钮在 joinCount 为 5 时不再增加。由于 joinCount@State 变量,其变化会自动触发费用计算文本的更新。

        Row() {
          Text('活动费用').fontSize(11).fontColor(PC.sub)
          Column().layoutWeight(1)
          if (EVENT_LIST[this.selEvt].fee === 0) {
            Text('免费局 · 其他消费 AA').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PC.green)
          } else {
            Text('¥' + EVENT_LIST[this.selEvt].fee + ' x ' + this.joinCount + ' 人').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PC.ink)
          }
        }.width('100%').margin({ top: 12 })

费用计算行根据活动是否免费显示不同的文案。收费活动的费用文本中引用了 this.joinCount,当用户通过步进器调整报名人数时,费用文本会自动更新——这就是 @State 响应式机制的直接体现。

        Row() {
          Toggle({ type: ToggleType.Switch, isOn: this.agreeRule }).selectedColor(PC.primary).switchPointColor(PC.white).scale({ x: 0.8, y: 0.8 }).onChange((isOn: boolean) => {
            this.agreeRule = isOn
          })
          Text('我已阅读并同意《组局须知》与《免责声明》').fontSize(9).fontColor(PC.sub).margin({ left: 8 }).onClick(() => {
            this.agreeRule = !this.agreeRule
          })
        }.width('100%').margin({ top: 14 }).alignItems(VerticalAlign.Center)

Toggle 组件是 ArkUI 提供的开关组件,type: ToggleType.Switch 将其渲染为滑动开关样式。isOn 属性绑定到 this.agreeRule 状态变量,onChange 回调在开关状态变化时更新该变量。selectedColor 控制开关打开时的轨道颜色,switchPointColor 控制滑块颜色,scale({ x: 0.8, y: 0.8 }) 将开关缩小到 80% 以适配弹框的紧凑布局。

        Row() {
          Text('再看看').fontSize(12).fontColor(PC.sub).backgroundColor(PC.lav).padding({ left: 26, right: 26, top: 10, bottom: 10 }).borderRadius(20).onClick(() => {
            this.showJoin = false
          })
          Text('确认报名').fontSize(12).fontColor(PC.white).backgroundColor(PC.primary).padding({ left: 26, right: 26, top: 10, bottom: 10 }).borderRadius(20).margin({ left: 12 }).onClick(() => {
            this.showJoin = false
            this.joinCount = 1
          })
        }.width('100%').justifyContent(FlexAlign.Center).padding({ top: 16, bottom: 18 })

操作按钮区域使用 justifyContent(FlexAlign.Center) 使两个按钮居中排列。在 ArkTS 中,Text 组件不仅可以用于展示文本,还可以通过设置 backgroundColorpaddingborderRadiusonClick 模拟按钮的视觉效果和交互行为。这种做法在纯 UI 演示中非常普遍,避免了创建 Button 组件时默认样式与设计稿不一致的问题。

justifyContent 属性控制子组件在主轴方向上的对齐方式。FlexAlign.Center 表示居中对齐,FlexAlign.Start 表示起始端对齐,FlexAlign.End 表示末尾端对齐,FlexAlign.SpaceBetween 表示两端对齐且首尾贴边、中间等距分布,FlexAlign.SpaceAround 表示每个子组件两侧等距分布。这些对齐选项与 CSS Flexbox 规范一致。

4.6 发现页主构建方法

build() {
  Stack() {
    Column() {
      Row() {
        Row() {
          Text('📍').fontSize(15)
          Text(CITY_NAMES[this.cityIdx]).fontSize(13).fontWeight(FontWeight.Bold).fontColor(PC.ink)
          Text('▾').fontSize(10).fontColor(PC.sub).margin({ left: 2 })
        }.alignItems(VerticalAlign.Center).onClick(() => {
          this.cityIdx = (this.cityIdx + 1) % 3
        })

        TextInput({ placeholder: '搜剧本杀 / 飞盘 / 爬山搭子' }).layoutWeight(1).height(36).fontSize(11).fontColor(PC.ink).placeholderColor(PC.hint).placeholderFont({ size: 11 }).backgroundColor(PC.lav).borderRadius(18).padding({ left: 14, right: 14 }).margin({ left: 10 }).onChange((v: string) => {
          this.keyword = v
        })

        Stack({ alignContent: Alignment.TopEnd }) {
          Text('🔔').fontSize(18)
          if (this.unreadTotal > 0) {
            Column().width(8).height(8).borderRadius(4).backgroundColor(PC.danger).margin({ top: 2, right: 2 })
          }
        }.margin({ left: 10 }).onClick(() => {
          this.unreadTotal = 0
        })
      }.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 6 }).alignItems(VerticalAlign.Center)

build() 方法是每个 @Component 必须实现的方法,返回该组件的完整 UI 描述。发现页的 build() 方法以 Stack 为根容器,内部分为内容层和弹框层。

头部导航栏是一个 Row,包含三个部分:城市定位选择器、搜索框和消息通知按钮。城市定位器点击后通过取模运算 this.cityIdx = (this.cityIdx + 1) % 3 在三个城市之间循环切换。TextInput 是 ArkUI 的文本输入组件,placeholder 设置占位提示文字,onChange 回调在输入内容变化时更新 keyword 状态变量。消息通知按钮使用 Stack 层叠布局,在铃铛图标右上角叠加一个红色未读小圆点(通过条件渲染 if (this.unreadTotal > 0) 控制其显示)。

点击卡片

弹框层

报名确认弹框

内容滚动区

大 Banner

迷你 Banner x2

兴趣标签横滑

双列瀑布流

热度柱状图

热度榜 TOP6

头部导航

城市定位

搜索框 TextInput

消息通知 Stack

      Scroll() {
        Column() {
          Stack({ alignContent: Alignment.BottomStart }) {
            Column().width('100%').height(122).linearGradient({ direction: GradientDirection.Right, colors: [[BANNER_LIST[0].bg, 0], [PC.accent, 1]] }).borderRadius(16)
            Row() {
              Column() {
                Text(BANNER_LIST[0].tag).fontSize(8).fontColor(PC.ink).backgroundColor(PC.yellow).padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8)
                Text(BANNER_LIST[0].title).fontSize(17).fontWeight(FontWeight.Bold).fontColor(PC.white).margin({ top: 7 })
                Text(BANNER_LIST[0].sub).fontSize(9).fontColor('#FFF3E3').margin({ top: 4 })
              }.alignItems(HorizontalAlign.Start).layoutWeight(1)
              Text(BANNER_LIST[0].icon).fontSize(46).margin({ right: 18 })
            }.width('100%').padding({ left: 16, bottom: 14 }).alignItems(VerticalAlign.Bottom)
          }.width('100%').height(122).margin({ top: 6 }).onClick(() => {
            this.selEvt = 0
            this.showJoin = true
          })

内容区域包裹在 Scroll 组件中,使内容可以纵向滚动。Scroll 内部是一个 Column,所有内容元素垂直排列。

大 Banner 使用 Stack 层叠布局,底层是渐变背景(从 Banner 自身的背景色渐变到活力橙),上层是文字信息和图标。alignContent: Alignment.BottomStart 使内容在 Stack 的左下角对齐。Banner 的点击事件会设置 selEvt 并显示报名弹框。

          Row() {
            Stack({ alignContent: Alignment.BottomStart }) {
              Column().width('100%').height(74).linearGradient({ direction: GradientDirection.Right, colors: [[BANNER_LIST[1].bg, 0], [PC.yellow, 1]] }).borderRadius(14)
              Column() {
                Text(BANNER_LIST[1].title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(PC.white)
                Text(BANNER_LIST[1].sub).fontSize(8).fontColor('#FFF3E3').margin({ top: 3 })
              }.padding({ left: 12, bottom: 10 }).alignItems(HorizontalAlign.Start)
              Text(BANNER_LIST[1].icon).fontSize(28).position({ x: '72%', y: 10 })
            }.layoutWeight(1).height(74).margin({ right: 5 }).onClick(() => {
              this.selEvt = 2
              this.showJoin = true
            })

两个迷你 Banner 使用 Row 并排排列,各占 layoutWeight(1) 的一半宽度。迷你 Banner 的结构与大 Banner 类似,但尺寸更小。其中图标使用了 position 属性进行绝对定位,将图标放置在 Banner 右上角的位置。position 属性接受一个包含 xy 的对象,支持百分比和具体数值。

          Scroll() {
            Row() {
              ForEach(TAG_LIST, (t: TagItem) => {
                if (this.selTag === t.name) {
                  Text(t.icon + ' ' + t.name).fontSize(11).fontColor(PC.white).backgroundColor(PC.primary).padding({ left: 14, right: 14, top: 7, bottom: 7 }).borderRadius(17).margin({ right: 8 }).onClick(() => {
                    this.selTag = t.name
                  })
                } else {
                  Text(t.icon + ' ' + t.name).fontSize(11).fontColor(PC.sub).backgroundColor(PC.card).padding({ left: 14, right: 14, top: 7, bottom: 7 }).borderRadius(17).margin({ right: 8 }).onClick(() => {
                    this.selTag = t.name
                  })
                }
              })
            }.padding({ left: 12, right: 12 })
          }.scrollable(ScrollDirection.Horizontal).width('100%').margin({ top: 12 }).scrollBar(BarState.Off)

兴趣标签区域使用了横向滚动的 Scroll 组件。scrollable(ScrollDirection.Horizontal) 将滚动方向设置为水平,scrollBar(BarState.Off) 隐藏滚动条。内部使用 ForEach 遍历 TAG_LIST 数组渲染标签按钮。

ForEach 是 ArkTS 中用于列表渲染的核心组件。它接受三个参数:数据源数组、子组件生成函数和键值生成函数(可选)。子组件生成函数对每个数据项生成对应的 UI 组件。当数据源发生变化(增删改)时,ForEach 会根据键值高效地更新 DOM,而非全量重新渲染。

这里使用了 if-else 条件渲染来区分选中态和未选中态的标签样式:选中标签使用紫色背景白色文字,未选中标签使用白色背景灰色文字。点击标签时更新 selTag 状态变量,由于 selTag@State 变量,其变化会触发 ForEach 内部的条件判断重新执行,从而更新所有标签的视觉状态。同时,瀑布流区域的 eventsLeft(this.selTag)eventsRight(this.selTag) 也会因 selTag 的变化而重新计算,实现筛选联动效果。

          Row() {
            Column() {
              ForEach(eventsLeft(this.selTag), (e: EventItem) => {
                this.eventCard(e)
              })
            }.layoutWeight(1)

            Column().width(8)

            Column() {
              ForEach(eventsRight(this.selTag), (e: EventItem) => {
                this.eventCard(e)
              })
            }.layoutWeight(1)
          }.width('100%').padding({ left: 6, right: 6 }).alignItems(VerticalAlign.Top)

双列瀑布流的实现是发现页的亮点之一。两个 Column 各占 layoutWeight(1) 的宽度,中间用 width(8) 的空白 Column 作为间距。左列通过 ForEach(eventsLeft(this.selTag), ...) 渲染偶数索引的活动,右列通过 ForEach(eventsRight(this.selTag), ...) 渲染奇数索引的活动。alignItems(VerticalAlign.Top) 确保两列从顶部对齐。每个活动卡片通过 this.eventCard(e) 调用之前定义的 @Builder 方法渲染。

4.7 热度柱状图

          Row() {
            ForEach(WEEK_HEAT, (d: HeatDay, i: number) => {
              Column() {
                Text(d.games.toString()).fontSize(8).fontColor(PC.sub).margin({ bottom: 3 })
                Column().width(20).height(d.games * 2.4).borderRadius(8).backgroundColor(i === 5 ? PC.accent : PC.primary).opacity(i === 5 ? 1 : 0.82)
                Text(d.day).fontSize(9).fontColor(PC.hint).margin({ top: 5 })
              }.layoutWeight(1).alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.End).height(126)
            })
          }.width('100%').margin({ top: 12 })

柱状图的实现完全使用原生 ArkUI 组件,无需任何第三方图表库。每个柱子是一个 Column,其高度由 d.games * 2.4 计算得出——将数据值乘以一个固定的缩放系数转换为像素高度。柱子的宽度固定为 20vp,圆角为 8vp。周六(索引 5)的柱子使用强调色 PC.accent 和完全不透明度,其余柱子使用主色 PC.primary 和 82% 不透明度,突出周末的热度峰值。

每个柱子的容器是一个 Column,从上到下排列数值标签、柱体和星期标签,总高度 126vp,使用 justifyContent(FlexAlign.End) 使内容底部对齐——这样不同高度的柱子都会从底部开始生长,形成标准的柱状图效果。

FlexAlign 枚举在 justifyContentalignItems 中都使用。在 Column 中,justifyContent 控制垂直方向的对齐,alignItems 控制水平方向的对齐。在 Row 中则相反。这种交叉的行为模型是 ArkUI 布局体系中需要特别注意的知识点。

4.8 热度榜

          ForEach(hotEvents(), (e: EventItem, i: number) => {
            if (i < 6) {
              Row() {
                Text((i + 1).toString()).fontSize(11).fontWeight(FontWeight.Bold).fontColor(rankColor(i)).width(20)
                Text(e.icon).fontSize(16)
                Column() {
                  Text(e.name).fontSize(10).fontWeight(FontWeight.Medium).fontColor(PC.ink).maxLines(1)
                  Text(e.cat + ' · ' + e.zone).fontSize(8).fontColor(PC.hint).margin({ top: 2 }).maxLines(1)
                }.width(110).alignItems(HorizontalAlign.Start).padding({ left: 8 })

                Row() {
                  Column().layoutWeight(e.hot).height(6).backgroundColor(rankColor(i)).borderRadius(3)
                  Column().layoutWeight(1000 - e.hot).height(6).backgroundColor(PC.lav).borderRadius(3)
                }.layoutWeight(1).height(6)

                Text(e.hot + ' 热').fontSize(8).fontColor(PC.sub).width(44).textAlign(TextAlign.End)
              }.width('100%').padding({ top: 8, bottom: 8 }).onClick(() => {
                this.selEvt = e.id - 1
                this.showJoin = true
              })
            }
          })

热度榜展示了按热度排序的前 6 名活动。hotEvents() 函数返回降序排列的活动列表,ForEach 遍历时使用 if (i < 6) 限制只渲染前 6 条。每行包含排名序号(使用 rankColor(i) 根据排名着色)、活动图标、活动名称和品类信息、热度进度条和热度数值。

热度进度条同样利用 layoutWeight 实现比例条效果:已填充部分使用 layoutWeight(e.hot),未填充部分使用 layoutWeight(1000 - e.hot),以热度值在 1000 中的占比来表示相对热度。

五、GameContent 组件——组局管理页

GameContent 组件

局长面板头部

统计四宫格

品类环形图

我发起的局列表

浮动发起按钮

弹框层

发起组局三步抽屉

成员名单抽屉

解散警告卡

编辑规则表单

步骤1: 选品类

步骤2: 填信息

步骤3: 选时间

5.1 状态定义与动画

@Component
struct GameContent {
  @State showCreate: boolean = false
  @State createStep: number = 1
  @State selCatIdx: number = 0
  @State gameName: string = ''
  @State gamePlace: string = ''
  @State gameFee: string = '88'
  @State gameCap: string = '12'
  @State gameIntro: string = ''
  @State selSlot: string = ''
  @State showMember: boolean = false
  @State showDissolve: boolean = false
  @State showEdit: boolean = false
  @State selGame: number = 0
  @State ruleAudit: boolean = true
  @State ruleFriend: boolean = false
  @State ruleText: string = '准时到场 鸽子移出群聊'
  @State feeIdx: number = 3
  @State fabRotate: number = 0
  @State glowOp: number = 0.45

组局管理页定义了 18 个状态变量,是所有组件中状态最复杂的一个。showCreateshowMembershowDissolveshowEdit 分别控制四个弹框的显示,createStep 跟踪发起组局的三步流程当前处于哪一步,selCatIdx 记录选中的品类索引,gameNamegamePlacegameFeegameCapgameIntro 存储表单输入值,selSlot 记录选择的时间段,selGame 记录当前操作的组局索引,ruleAuditruleFriendruleTextfeeIdx 存储规则编辑表单的状态,fabRotateglowOp 是动画状态变量。

aboutToAppear() {
  this.getUIContext().animateTo({
    duration: 1600,
    iterations: -1,
    playMode: PlayMode.Alternate,
    curve: Curve.EaseInOut
  }, () => {
    this.fabRotate = 135
  })
  this.getUIContext().animateTo({
    duration: 1200,
    iterations: -1,
    playMode: PlayMode.Alternate,
    curve: Curve.EaseInOut
  }, () => {
    this.glowOp = 1
  })
}

组局管理页启动了两个动画:浮动按钮的旋转动画(fabRotate 从 0 度变化到 135 度,周期 1600ms)和成局率徽章的光晕呼吸(glowOp 从 0.45 变化到 1,周期 1200ms)。旋转动画使浮动"+"按钮持续缓慢旋转,吸引用户注意并暗示其可点击性。

5.2 发起组局分步抽屉

@Builder createModal() {
  Column() {
    this.modalOverlay(() => {
      this.showCreate = false
    })
    Column() {
      Column().width(44).height(5).borderRadius(3).backgroundColor(PC.line).margin({ top: 10 })

发起组局弹框采用了底部抽屉的交互模式。抽屉的顶部有一个 44x5vp 的圆角条作为拖拽指示器,这是 Material Design 中底部抽屉的标准视觉元素。整个弹框的 justifyContent(FlexAlign.End) 使抽屉贴底显示。

      Row() {
        Text('🚀 发起组局').fontSize(16).fontWeight(FontWeight.Bold).fontColor(PC.ink)
        Column().layoutWeight(1)
        Text('第 ' + this.createStep + ' / 3 步').fontSize(10).fontColor(PC.primary).backgroundColor(PC.lav).padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
        Text('✕').fontSize(15).fontColor(PC.hint).margin({ left: 12 }).onClick(() => {
          this.showCreate = false
        })
      }.width('100%').padding({ left: 18, right: 18, top: 12, bottom: 10 })

抽屉头部显示了当前步骤进度 “第 X / 3 步”,通过引用 this.createStep 状态变量,步骤指示器会随着用户点击"上一步"或"下一步"自动更新。

      Scroll() {
        Column() {
          if (this.createStep === 1) {
            // 步骤1:选择品类
          } else if (this.createStep === 2) {
            // 步骤2:填写信息
          } else {
            // 步骤3:选择时间
          }

          Row() {
            if (this.createStep > 1) {
              Text('上一步').fontSize(12)...
            }
            if (this.createStep < 3) {
              Text('下一步').fontSize(12)...
            } else {
              Text('🚀 发布组局').fontSize(12)...
            }
          }.width('100%').justifyContent(FlexAlign.Center)
        }
      }.constraintSize({ maxHeight: '52%' }).scrollBar(BarState.Off)

分步表单的核心是 if-else if-else 条件渲染:根据 createStep 的值显示不同的表单内容。constraintSize({ maxHeight: '52%' }) 限制抽屉内容区的最大高度为屏幕的 52%,当内容超出时可以通过 Scroll 滚动查看。底部按钮区域根据当前步骤动态显示"上一步"(step > 1 时)、“下一步”(step < 3 时)或"发布组局"(step === 3 时)。

步骤1中品类选择使用 catCell Builder 渲染品类网格:

@Builder catCell(m: CreateCat) {
  if (this.selCatIdx === m.id - 1) {
    Column() {
      Text(m.icon).fontSize(24)
      Text(m.name).fontSize(11).fontWeight(FontWeight.Bold).fontColor(PC.white).margin({ top: 5 })
      Text(m.desc).fontSize(8).fontColor('#FFF3E3').margin({ top: 3 }).maxLines(1)
      Text('本周 ' + m.hot + ' 局').fontSize(8).fontColor(PC.yellow).margin({ top: 3 })
    }.layoutWeight(1).padding({ top: 12, bottom: 12 }).backgroundColor(PC.primary).borderRadius(14).alignItems(HorizontalAlign.Center).onClick(() => {
      this.selCatIdx = m.id - 1
    })
  } else {
    Column() { ... }.layoutWeight(1).padding({ top: 12, bottom: 12 }).backgroundColor(PC.lav).borderRadius(14).alignItems(HorizontalAlign.Center).onClick(() => {
      this.selCatIdx = m.id - 1
    })
  }
}

品类格使用 if-else 区分选中态和未选中态:选中态使用紫色背景白色文字,未选中态使用浅紫色背景深色文字。点击品类格更新 selCatIdx 状态变量,触发所有品类格重新渲染以更新选中状态。

步骤2包含多个 TextInput 表单字段,包括局名称、集合地点、人数上限、人均费用和局简介。人数上限和费用使用 type(InputType.Number) 限制为数字输入。

步骤3展示时间选择标签和确认信息汇总卡,汇总卡使用 PC.sand 沙色背景区分于其他区域,展示用户在前两步填写的信息。

5.3 成员名单抽屉

@Builder memberDrawer() {
  Column() {
    this.modalOverlay(() => { this.showMember = false })
    Column() {
      Column().width(44).height(5).borderRadius(3).backgroundColor(PC.line).margin({ top: 10 })

      Row() {
        Text('👥 成员名单').fontSize(16).fontWeight(FontWeight.Bold).fontColor(PC.ink)
        Column().layoutWeight(1)
        Text(MY_GAME_LIST[this.selGame].joined + '/' + MY_GAME_LIST[this.selGame].cap + ' 人')...
      }

      Scroll() {
        Column() {
          ForEach(MEMBER_LIST, (m: MemberItem) => {
            Row() {
              Stack({ alignContent: Alignment.BottomEnd }) {
                Column() {
                  Text(m.avatar).fontSize(22)
                }.width(44).height(44).borderRadius(22).backgroundColor(PC.lav).justifyContent(FlexAlign.Center)
                if (m.role !== '成员') {
                  Text(m.role).fontSize(7).fontColor(PC.white).backgroundColor(PC.accent).padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(6)
                }
              }.width(44).height(44)

成员名单抽屉使用 ForEach 遍历 MEMBER_LIST 渲染成员行。每个成员的头像使用 Stack 层叠布局,头像底部右侧叠加角色标签(仅非"成员"角色显示)。成员行右侧展示匹配度进度条,同样使用 layoutWeight 实现比例条效果。

5.4 解散组局警告卡

@Builder dissolveModal() {
  Column() {
    this.modalOverlay(() => { this.showDissolve = false })
    Column() {
      Text('⚠️').fontSize(38).margin({ top: 22 })
      Text('解散组局').fontSize(17).fontWeight(FontWeight.Bold).fontColor(PC.danger).margin({ top: 8 })
      Text('确认解散「' + MY_GAME_LIST[this.selGame].name + '」?').fontSize(11).fontColor(PC.ink).margin({ top: 10 }).maxLines(1)
      Text('解散后无法恢复 请谨慎操作').fontSize(9).fontColor(PC.sub).margin({ top: 6 })

      Column() {
        Text('· 已报名的 ' + MY_GAME_LIST[this.selGame].joined + ' 位成员将收到通知')...
        Text('· 已收活动费将原路退回 · 到场积分不返还')...
        Text('· 连续解散 3 次将影响局长信用分')...
      }.width('100%').padding(12).backgroundColor(PC.redBg).borderRadius(12).margin({ top: 14 })

解散组局弹框是一个居中显示的警告卡片,使用红色系配色(PC.danger 文字色、PC.redBg 背景色)传达危险操作的视觉警示。警告信息区域列出了解散操作的三条后果,其中第三条"连续解散 3 次将影响局长信用分"使用红色文字特别强调。

5.5 编辑组局规则表单

@Builder editRuleModal() {
  Column() {
    this.modalOverlay(() => { this.showEdit = false })
    Column() {
      // ... 头部 ...
      Scroll() {
        Column() {
          // 局规则文本输入
          TextInput({ placeholder: '输入局规则 如准时到场', text: this.ruleText }).onChange((v: string) => {
            this.ruleText = v
          })

          // 费用档位选择
          Row() {
            ForEach(FEE_OPTIONS, (f: number, i: number) => {
              if (this.feeIdx === i) {
                Text(f === 0 ? '免费' : '¥' + f)...
              } else {
                Text(f === 0 ? '免费' : '¥' + f)...
              }
            })
          }

          // 报名审核开关
          Row() {
            Column() {
              Text('报名需局长审核')...
              Text('开启后报名需人工通过')...
            }.layoutWeight(1)
            Toggle({ type: ToggleType.Switch, isOn: this.ruleAudit }).selectedColor(PC.primary).onChange((isOn: boolean) => {
              this.ruleAudit = isOn
            })
          }

          // 允许带朋友开关
          Row() {
            Column() {
              Text('允许成员带朋友')...
              Text('拼场更快成局')...
            }.layoutWeight(1)
            Toggle({ type: ToggleType.Switch, isOn: this.ruleFriend }).selectedColor(PC.blue).onChange((isOn: boolean) => {
              this.ruleFriend = isOn
            })
          }

编辑规则弹框是一个居中显示的表单卡片。费用档位选择使用 ForEach 遍历 FEE_OPTIONS 数组渲染可选标签,每个标签通过 this.feeIdx === i 判断是否为当前选中项。两个 Toggle 开关分别控制"报名需审核"和"允许带朋友",绑定的状态变量分别是 ruleAuditruleFriend,使用不同的 selectedColor(紫色和蓝色)进行视觉区分。

5.6 品类环形图

Row() {
  Stack() {
    Progress({ value: CAT_SHARE[0].share, total: 100, type: ProgressType.Ring }).color(CAT_SHARE[0].color).backgroundColor('#F6F1EA').style({ strokeWidth: 13 }).width(124).height(124)
    Progress({ value: CAT_SHARE[1].share, total: 100, type: ProgressType.Ring }).color(CAT_SHARE[1].color).backgroundColor('rgba(0,0,0,0)').style({ strokeWidth: 13 }).width(98).height(98)
    Progress({ value: CAT_SHARE[2].share, total: 100, type: ProgressType.Ring }).color(CAT_SHARE[2].color).backgroundColor('rgba(0,0,0,0)').style({ strokeWidth: 13 }).width(72).height(72)
    Progress({ value: CAT_SHARE[3].share, total: 100, type: ProgressType.Ring }).color(CAT_SHARE[3].color).backgroundColor('rgba(0,0,0,0)').style({ strokeWidth: 13 }).width(46).height(46)
    Column() {
      Text('386').fontSize(15).fontWeight(FontWeight.Bold).fontColor(PC.ink)
      Text('本周场次').fontSize(8).fontColor(PC.hint).margin({ top: 2 })
    }
  }.width(136).height(136).margin({ left: 4 })

环形图的实现是一个极具创意的方案。它通过 Stack 层叠四个不同大小的 Progress 环形组件,形成同心圆环的视觉效果。最外层环尺寸 124x124vp,向内依次缩小为 98、72、46vp。只有最外层环设置了可见的 backgroundColor(灰色),内层环的背景色设为透明 rgba(0,0,0,0),避免互相遮挡。最中心放置一个 Column 显示总数和标签。每个环的 value 属性绑定到对应品类的 share 值,color 属性使用品类的专属颜色。

Progress 组件是 ArkUI 内置的进度指示组件,支持 Linear(线性)、Ring(环形)、Eclipse(月食)等多种类型。通过 type: ProgressType.Ring 可以渲染为环形进度条,style({ strokeWidth: 13 }) 控制环的线宽。本例巧妙地利用了 Progress 的环形特性,通过嵌套多个不同尺寸的环实现了多品类占比的可视化。

六、MateContent 组件——搭子匹配页

6.1 状态与动画

@Component
struct MateContent {
  @State selMate: number = 0
  @State showMate: boolean = false
  @State heartScale: number = 1

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 900,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.heartScale = 1.22
    })
  }

搭子匹配页定义了 3 个状态变量,动画效果是爱心图标的持续心跳放缩(从 1 到 1.22,周期 900ms)。这个心跳动画作用于页面头部的爱心图标和搭子资料卡弹框中的爱心图标,传达"匹配心动"的情感隐喻。

6.2 头像墙

@Builder avatarCell(m: MateItem) {
  Column() {
    Stack({ alignContent: Alignment.BottomEnd }) {
      Column() {
        Text(m.avatar).fontSize(24)
      }.width(50).height(50).borderRadius(25).backgroundColor(PC.lav).justifyContent(FlexAlign.Center)
      Column().width(12).height(12).borderRadius(6).backgroundColor(m.match >= 90 ? PC.accent : PC.blue).margin(2)
    }
    Text(m.name).fontSize(9).fontColor(PC.ink).fontWeight(FontWeight.Medium).margin({ top: 5 }).maxLines(1)
    Text(m.match + '%').fontSize(8).fontColor(matchColor(m.match)).margin({ top: 2 })
  }.width('23%').padding({ top: 10, bottom: 10 }).alignItems(HorizontalAlign.Center).onClick(() => {
    this.selMate = m.id - 1
    this.showMate = true
  })

头像墙单元使用 Stack 层叠布局,头像底部右侧叠加一个 12x12vp 的小色块作为匹配度指示器——匹配度 90 以上使用橙色,其余使用蓝色。每个单元宽度设为 23%,配合间距在 Row 中形成每行约 4 个头像的网格布局。点击头像单元设置 selMate 并显示搭子资料卡弹框。

6.3 搭子资料卡弹框

@Builder mateModal() {
  Column() {
    this.modalOverlay(() => { this.showMate = false })
    Column() {
      Column() {
        Stack() {
          Column() {
            Text(MATE_LIST[this.selMate].avatar).fontSize(38)
          }.width(74).height(74).borderRadius(37).backgroundColor('rgba(255,255,255,0.35)').justifyContent(FlexAlign.Center)
          Text('💖').fontSize(16).scale({ x: this.heartScale, y: this.heartScale }).position({ x: 52, y: 52 })
        }.width(74).height(74)

        Text(MATE_LIST[this.selMate].name).fontSize(17).fontWeight(FontWeight.Bold).fontColor(PC.white).margin({ top: 10 })
        Text(MATE_LIST[this.selMate].gender + ' · ' + MATE_LIST[this.selMate].age + ' 岁 · ' + MATE_LIST[this.selMate].city).fontSize(10).fontColor('#FFF3E3').margin({ top: 5 })
      }.width('100%').padding({ top: 22, bottom: 18 }).alignItems(HorizontalAlign.Center).linearGradient({ direction: GradientDirection.Right, colors: [[PC.primary, 0], [PC.blue, 1]] })

搭子资料卡弹框的头部使用从紫色到蓝色的渐变背景。头像区域使用 Stack 层叠布局,在头像右下角(position({ x: 52, y: 52 }))叠加一个心跳动画的爱心图标。渐变背景上的文字使用白色和浅色确保可读性。

      Column() {
        Row() {
          Stack() {
            Progress({ value: MATE_LIST[this.selMate].match, total: 100, type: ProgressType.Ring }).color(PC.accent).backgroundColor(PC.sand).style({ strokeWidth: 8 }).width(64).height(64)
            Column() {
              Text(MATE_LIST[this.selMate].match + '%').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PC.accent)
              Text('匹配').fontSize(7).fontColor(PC.hint).margin({ top: 1 })
            }
          }.width(64).height(64)

资料卡中使用了一个 Progress 环形组件展示匹配度,中心叠加匹配度数值和"匹配"文字。这种"环形进度+中心文字"的组合是数据可视化中常见的展示模式,通过 Stack 层叠实现。

        Row() {
          ForEach(HOBBY_TAGS, (h: string) => {
            if (MATE_LIST[this.selMate].hobby.indexOf(h) >= 0) {
              Text(h).fontSize(9).fontColor(PC.white).backgroundColor(PC.blue).padding({ left: 9, right: 9, top: 4, bottom: 4 }).borderRadius(10).margin({ right: 6, top: 6 })
            }
          })
        }.width('100%')

爱好标签区域使用 ForEach 遍历 HOBBY_TAGS 数组,通过 indexOf 检查当前搭子的爱好字符串是否包含该标签,仅渲染匹配的标签。这是一种简洁的标签筛选渲染方式。

七、VenueContent 组件——场地预约页

7.1 状态与动画

@Component
struct VenueContent {
  @State selVTag: string = '全部'
  @State selVenue: number = 0
  @State showVenue: boolean = false
  @State selSlotV: string = ''
  @State tagScale: number = 1

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 1100,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.tagScale = 1.15
    })
  }

场地预约页定义了 5 个状态变量。动画效果是优惠标签的跳动放缩(从 1 到 1.15,周期 1100ms),作用于场馆卡片上"省 ¥X"的折扣标签,吸引用户关注优惠信息。

7.2 场馆卡片

@Builder venueCard(v: VenueItem) {
  Column() {
    Row() {
      Stack({ alignContent: Alignment.TopEnd }) {
        Column() {
          Text(v.icon).fontSize(30)
        }.width(64).height(64).borderRadius(14).backgroundColor(PC.cyan).justifyContent(FlexAlign.Center)
        Text('省¥' + (v.origPrice - v.price)).fontSize(8).fontColor(PC.white).backgroundColor(PC.danger).padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(7).margin(4).scale({ x: this.tagScale, y: this.tagScale })
      }.width(64).height(64)

场馆卡片的图标区域使用 Stack 层叠布局,右上角叠加红色折扣标签。折扣金额通过 v.origPrice - v.price 计算得出,标签应用了 tagScale 缩放动画。

      Row() {
        Row() {
          Text('¥' + v.price).fontSize(16).fontWeight(FontWeight.Bold).fontColor(PC.accent)
          Text('/人起').fontSize(8).fontColor(PC.hint).margin({ left: 2 })
          Text('¥' + v.origPrice).fontSize(9).fontColor(PC.hint).margin({ left: 8 }).decoration({ type: TextDecorationType.LineThrough })
        }.alignItems(VerticalAlign.Bottom)

价格区域展示了现价(橙色大字)、单位(灰色小字)和原价(灰色带删除线)。decoration({ type: TextDecorationType.LineThrough }) 为原价文本添加删除线效果,这是电商类应用中表示折扣的标准视觉表达。

7.3 场地详情弹层

@Builder venueModal() {
  Column() {
    this.modalOverlay(() => { this.showVenue = false })
    Column() {
      Column().width(44).height(5).borderRadius(3).backgroundColor(PC.line).margin({ top: 10 })
      // ... 头部信息 ...

      Scroll() {
        Column() {
          Row() {
            Column() {
              Text(VENUE_LIST[this.selVenue].dist + ' km').fontSize(13).fontWeight(FontWeight.Bold).fontColor(PC.primary)
              Text('距离').fontSize(8).fontColor(PC.hint).margin({ top: 2 })
            }.layoutWeight(1).alignItems(HorizontalAlign.Center)
            // ... 空间数、支持玩法 ...
          }

          // 价格对比
          Column() {
            Row() {
              Text('本店价').fontSize(9).fontColor(PC.sub).width(38)
              Row() {
                Column().layoutWeight(VENUE_LIST[this.selVenue].price).height(10).backgroundColor(PC.accent).borderRadius(5)
                Column().layoutWeight(180 - VENUE_LIST[this.selVenue].price).height(10).backgroundColor(PC.sand).borderRadius(5)
              }.layoutWeight(1).height(10)
              Text('¥' + VENUE_LIST[this.selVenue].price)...
            }
            Row() {
              Text('原价')...
              Row() {
                Column().layoutWeight(VENUE_LIST[this.selVenue].origPrice).height(10).backgroundColor(PC.sub).borderRadius(5)
                Column().layoutWeight(180 - VENUE_LIST[this.selVenue].origPrice).height(10).backgroundColor(PC.lav).borderRadius(5)
              }.layoutWeight(1).height(10)
            }
          }

场地详情弹层是底部抽屉式弹框。内容区包含三个统计格(距离、空间数、支持玩法)、价格对比条和时段选择。价格对比条展示了"本店价"和"原价"两个横条,使用 layoutWeight 实现比例条效果——填充部分的 layoutWeight 等于价格值,未填充部分的 layoutWeight 等于 180 减去价格值(180 是一个预设的最大值,用于保证条形图的合理比例)。

八、MessageContent 组件——消息页

8.1 状态与动画

@Component
struct MessageContent {
  @State selChat: number = -1
  @State selNotice: number = 0
  @State readAll: boolean = false
  @State dotScale: number = 1

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 800,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.dotScale = 1.25
    })
  }

消息页定义了 4 个状态变量。selChat 初始值为 -1 表示未选中任何会话,readAll 控制是否已全部标记已读。动画效果是未读角标的脉冲放缩(从 1 到 1.25,周期 800ms),作用于会话列表和未读速览中的未读数字角标,持续吸引用户注意未读消息。

8.2 会话行

@Builder chatRow(c: ChatItem, i: number) {
  Row() {
    Stack({  }) {
      Column() {
        Text(c.avatar).fontSize(26)
      }.width(50).height(50).borderRadius(25).backgroundColor(PC.lav).justifyContent(FlexAlign.Center)
      if (c.online > 0) {
        Column().width(11).height(11).borderRadius(6).backgroundColor(PC.green).margin(2)
      }
    }.width(50).height(50)

    Column() {
      Row() {
        Text(c.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(PC.ink).maxLines(1).layoutWeight(1)
        Text(c.time).fontSize(8).fontColor(PC.hint)
      }.width('100%')

      Row() {
        Text(c.kind).fontSize(7).fontColor(PC.blue).backgroundColor(PC.cyan).padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(5)
        Text(' ' + c.last).fontSize(9).fontColor(PC.sub).maxLines(1).layoutWeight(1)
        if (!this.readAll && c.unread > 0) {
          Text(c.unread.toString()).fontSize(8).fontColor(PC.white).backgroundColor(PC.danger).padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(9).margin({ left: 6 }).scale({ x: this.dotScale, y: this.dotScale })
        }
      }.width('100%').margin({ top: 5 }).alignItems(VerticalAlign.Center)
    }.layoutWeight(1).padding({ left: 10 }).alignItems(HorizontalAlign.Start)
  }.width('100%').padding({ top: 10, bottom: 10 }).borderRadius(14).backgroundColor(this.selChat === i ? PC.lav : PC.card).margin({ top: 6 }).onClick(() => {
    this.selChat = i
  })
}

会话行使用 Row 水平布局,左侧是头像(Stack 层叠在线状态绿点),右侧是会话信息。会话信息包含两行:上行是会话名称和时间,下行是会话类型标签、最后消息和未读角标。未读角标使用条件渲染 if (!this.readAll && c.unread > 0) 控制显示——当用户点击"一键已读"后,readAll 变为 true,所有未读角标同时消失。选中会话时,背景色变为浅紫色 PC.lav,提供选中态的视觉反馈。

8.3 未读速览与系统通知

ForEach(unreadChats(), (c: ChatItem) => {
  Row() {
    Text('🔴').fontSize(8).width(14)
    Column() {
      Row() {
        Text(c.name).fontSize(11).fontWeight(FontWeight.Bold).fontColor(PC.ink).maxLines(1).layoutWeight(1)
        Text(c.time).fontSize(8).fontColor(PC.accent)
      }.width('100%')
      Text(c.last).fontSize(9).fontColor(PC.sub).maxLines(1).margin({ top: 4 })
    }.layoutWeight(1).alignItems(HorizontalAlign.Start)
    Text(c.unread.toString()).fontSize(8).fontColor(PC.white).backgroundColor(PC.danger).padding({ left: 7, right: 7, top: 3, bottom: 3 }).borderRadius(10).margin({ left: 8 }).scale({ x: this.dotScale, y: this.dotScale })
  }
})

未读速览区域使用 unreadChats() 纯函数获取所有有未读消息的会话,通过 ForEach 渲染为紧凑的未读列表。每行左侧是红色圆点 Emoji,中间是会话名称和最后消息,右侧是未读数角标(同样应用了 dotScale 脉冲动画)。

系统通知区域使用 noticeRow Builder 渲染通知行,通过 ForEach(NOTICE_LIST, ...) 遍历通知数据。通知行的选中态通过 this.selNotice === i 判断,选中时背景变为沙色 PC.sand

九、ProfileContent 组件——个人主页

9.1 状态与动画

@Component
struct ProfileContent {
  @State showEditP: boolean = false
  @State nickName: string = '陈皮猫'
  @State selGender: number = 0
  @State selHobby: string = '剧本杀'
  @State selBadge: number = 0
  @State motto: string = '周末不躺平 局局都到场'
  @State shineOp: number = 0.4

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 1300,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.shineOp = 1
    })
  }

个人主页定义了 7 个状态变量。nickNameselGenderselHobbymotto 存储用户的个人资料信息,这些变量在编辑资料弹框中被修改后会自动更新到主页头部展示。selBadge 记录当前选中的徽章索引。动画效果是闪光图标的透明度呼吸(从 0.4 到 1,周期 1300ms),作用于页面头部的闪光图标。

9.2 徽章墙

@Builder badgeCell(b: BadgeItem) {
  if (b.got === 1) {
    Column() {
      Text(b.icon).fontSize(22)
      Text(b.name).fontSize(8).fontColor(PC.ink).margin({ top: 4 }).maxLines(1)
    }.layoutWeight(1).padding({ top: 10, bottom: 10 }).backgroundColor(PC.lav).borderRadius(12).alignItems(HorizontalAlign.Center).onClick(() => {
      this.selBadge = b.id - 1
    })
  } else {
    Column() {
      Text(b.icon).fontSize(22).opacity(0.3)
      Text(b.name).fontSize(8).fontColor(PC.hint).margin({ top: 4 }).maxLines(1)
    }.layoutWeight(1).padding({ top: 10, bottom: 10 }).backgroundColor(PC.bg).borderRadius(12).alignItems(HorizontalAlign.Center).onClick(() => {
      this.selBadge = b.id - 1
    })
  }
}

徽章格使用 if-else 区分已解锁和未解锁两种状态:已解锁徽章使用正常不透明度和浅紫色背景,未解锁徽章使用 30% 不透明度和灰色背景。点击徽章格更新 selBadge 状态变量,触发底部徽章详情信息的更新。

徽章墙的布局使用手动排列而非 ForEach 循环,10 个徽章分成两行各 5 个,通过 Column().width(6) 在徽章之间插入间距。这种手动排列方式虽然代码量更多,但布局结果完全可控。

9.3 编辑资料弹框

@Builder editProfileModal() {
  Column() {
    this.modalOverlay(() => { this.showEditP = false })
    Column() {
      // ... 头部 ...
      Column() {
        Text('昵称')...
        TextInput({ placeholder: '输入新昵称', text: this.nickName }).onChange((v: string) => {
          this.nickName = v
        })

        Text('性别')...
        Row() {
          if (this.selGender === 0) {
            Text('🙋‍♂️ 小哥哥').fontSize(11).fontColor(PC.white).backgroundColor(PC.blue)...
          } else {
            Text('🙋‍♂️ 小哥哥').fontSize(11).fontColor(PC.sub).backgroundColor(PC.cyan)...
          }
          if (this.selGender === 1) {
            Text('🙋‍♀️ 小姐姐').fontSize(11).fontColor(PC.white).backgroundColor(PC.accent)...
          } else {
            Text('🙋‍♀️ 小姐姐').fontSize(11).fontColor(PC.sub).backgroundColor(PC.sand)...
          }
        }

        Text('兴趣偏好')...
        Row() {
          ForEach(HOBBY_TAGS, (h: string) => {
            if (this.selHobby === h) {
              Text(h)...
            } else {
              Text(h)...
            }
          })
        }

        Text('个性签名')...
        TextInput({ placeholder: '写一句个性签名', text: this.motto }).onChange((v: string) => {
          this.motto = v
        })

编辑资料弹框是一个居中显示的表单卡片。性别选择使用两个互斥的按钮,通过 if-else 判断 selGender 的值来切换选中态。兴趣偏好使用 ForEach 遍历 HOBBY_TAGS 渲染可选标签,通过 this.selHobby === h 判断选中态。所有表单字段都绑定到对应的 @State 变量,修改后自动同步到主页展示——例如修改昵称后,主页头部的昵称文字会立即更新。

9.4 个人主页头部

Stack({ alignContent: Alignment.TopEnd }) {
  Column().width('100%').height(150).linearGradient({ direction: GradientDirection.Right, colors: [[PC.primary, 0], [PC.accent, 1]] }).borderRadius({ bottomLeft: 24, bottomRight: 24 })
  Column() {
    Column() {
      Text('🐱').fontSize(40)
    }.width(76).height(76).borderRadius(38).backgroundColor('rgba(255,255,255,0.4)').justifyContent(FlexAlign.Center)
    Text(this.nickName).fontSize(18).fontWeight(FontWeight.Bold).fontColor(PC.white).margin({ top: 10 })
    Text('Lv.8 社交悍匪 · 广州 · ' + this.selHobby + '主理人').fontSize(9).fontColor('#FFF3E3').margin({ top: 5 })
    Text('「' + this.motto + '」').fontSize(9).fontColor(PC.yellow).margin({ top: 5 })
    Text('✏️ 编辑资料').fontSize(10).fontColor(PC.primary).backgroundColor(PC.white).padding({ left: 16, right: 16, top: 6, bottom: 6 }).borderRadius(15).margin({ top: 10 }).onClick(() => {
      this.showEditP = true
    })
  }.width('100%').padding({ top: 24 }).alignItems(HorizontalAlign.Center)
  Text('✨').fontSize(20).opacity(this.shineOp).position({ x: '84%', y: 16 })
}

个人主页头部使用了 Stack 层叠布局,底层是 150vp 高的渐变背景(底部圆角 24vp),上层是用户信息(头像、昵称、等级标签、个性签名、编辑按钮),右上角是闪光图标(应用 shineOp 透明度动画)。头部信息中的昵称、兴趣偏好和个性签名都引用了 @State 变量,在编辑资料弹框中修改后会自动更新。

十、Index 主入口组件——自绘底部 Tab 栏

Index 主入口组件

内容区 Column

分割线 Divider

底部 TabBar Row

activeTab === 0: DiscoverContent

activeTab === 1: GameContent

activeTab === 2: MateContent

activeTab === 3: VenueContent

activeTab === 4: MessageContent

activeTab === 5: ProfileContent

ForEach PLAY_TABS

图标 + 选中指示器

文字标签

onClick 切换 activeTab

10.1 组件声明

@Entry
@Component
struct Index {
  @State activeTab: number = 0

@Entry 装饰器将 Index 组件标记为应用的入口组件——每个 ArkTS 页面有且仅有一个 @Entry 组件,它是整个页面组件树的根节点。@State activeTab 是控制当前激活的 Tab 索引的状态变量,初始值为 0(发现页)。

10.2 内容区分发

build() {
  Column() {
    Column() {
      if (this.activeTab === 0) {
        DiscoverContent()
      } else if (this.activeTab === 1) {
        GameContent()
      } else if (this.activeTab === 2) {
        MateContent()
      } else if (this.activeTab === 3) {
        VenueContent()
      } else if (this.activeTab === 4) {
        MessageContent()
      } else {
        ProfileContent()
      }
    }.layoutWeight(1).width('100%')

内容区使用 if-else if-else 链根据 activeTab 的值渲染对应的子组件。layoutWeight(1) 使内容区占据除底部 Tab 栏外的所有剩余空间。当 activeTab 发生变化时,框架会卸载当前组件并加载新组件——这意味着每个 Tab 页面的状态是独立的,切换 Tab 时各页面的 @State 变量会重置为初始值。这种"条件渲染式 Tab 切换"是 ArkTS 中实现 Tab 导航的一种简洁方式,适用于不需要保持页面状态的场景。

在 ArkUI 中,Tab 导航可以通过 Tabs 组件实现,该组件内置了滑动切换、内容缓存等能力。但本项目选择使用条件渲染的方式手动实现 Tab 切换,这给了开发者对 Tab 栏外观的完全控制权——可以自定义图标大小、选中指示器、颜色过渡等所有视觉细节,不受 Tabs 组件默认样式的约束。

10.3 自绘底部 TabBar

    Divider().color(PC.line)

    Row() {
      ForEach(PLAY_TABS, (t: TabDef, i: number) => {
        Column() {
          Stack({ alignContent: Alignment.Top }) {
            Column() {
              if (this.activeTab === i) {
                Text(t.icon).fontSize(21).scale({ x: 1.12, y: 1.12 })
              } else {
                Text(t.icon).fontSize(21)
              }
            }.padding({ top: 4 })
            if (this.activeTab === i) {
              Column().width(16).height(3).borderRadius(2).backgroundColor(PC.primary).margin({ top: 0 })
            }
          }.width('100%')

          if (this.activeTab === i) {
            Text(t.label).fontSize(9).fontWeight(FontWeight.Bold).fontColor(PC.primary).margin({ top: 2 })
          } else {
            Text(t.label).fontSize(9).fontColor(PC.sub).margin({ top: 2 })
          }
        }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 6, bottom: 8 }).onClick(() => {
          this.activeTab = i
        })
      })
    }.width('100%').backgroundColor(PC.card)
  }.width('100%').height('100%').backgroundColor(PC.bg)

底部 TabBar 使用 ForEach 遍历 PLAY_TABS 数组渲染 6 个 Tab 按钮。每个 Tab 按钮是一个 Column,包含图标区域和文字标签。图标区域使用 Stack 层叠布局,选中状态下图标放大 1.12 倍并在顶部显示一个 16x3vp 的紫色圆角指示条。文字标签在选中状态下使用主色加粗显示,未选中状态下使用灰色常规显示。

每个 Tab 按钮的 onClick 回调将 activeTab 设为当前索引,触发内容区的条件渲染切换和所有 Tab 按钮的选中态更新。layoutWeight(1) 使 6 个 Tab 按钮等宽分布。

十一、技术架构总览

交互层

视图层

逻辑层

数据层

14 个接口定义

14 个常量数据数组

4 个常量配置数组

瀑布流分列函数

排序函数

筛选函数

颜色映射函数

DiscoverContent 发现页

GameContent 组局管理页

MateContent 搭子匹配页

VenueContent 场地预约页

MessageContent 消息页

ProfileContent 个人主页

Index 主入口

8 个全屏遮罩弹框

8 个动画特效

自绘底部 TabBar

十二、核心技术点对比分析

技术点类别作用说明使用位置典型用法特点与注意事项
@Component装饰器将 struct 声明为自定义组件所有 7 个 struct@Component struct XxxContent必须实现 build() 方法,不可手动实例化
@Entry装饰器标记页面入口组件Index 组件@Entry @Component struct Index每个页面仅一个 @Entry
@State状态管理组件内部响应式状态所有组件内部@State showJoin: boolean = false变化触发依赖 UI 重渲染,必须初始化
@Builder装饰器定义可复用 UI 片段所有组件内部@Builder eventCard(e: EventItem)可接受参数,在 build() 中通过 this 调用
Column布局容器垂直方向排列子组件几乎所有 UI 结构Column() { Text() Text() }主轴垂直,alignItems 控制水平对齐
Row布局容器水平方向排列子组件标签行、按钮行等Row() { Text() Text() }主轴水平,alignItems 控制垂直对齐
Stack布局容器层叠排列子组件头像、封面、徽章等Stack({ alignContent: Alignment.TopEnd })后声明覆盖先声明,alignContent 控制对齐
Scroll滚动容器使内容可滚动所有页面内容区Scroll() { Column() {...} }.scrollBar(BarState.Off)支持 Horizontal/Vertical 方向滚动
ForEach列表渲染遍历数组生成组件标签列表、卡片列表等ForEach(DATA, (item) => { Text(item.name) })数据变化时高效更新,支持键值生成
layoutWeight弹性布局按权重分配剩余空间占位符、进度条、等分布局Column().layoutWeight(1)值越大占据空间越多,0 表示不参与分配
linearGradient视觉效果线性渐变背景Banner、弹框头部、个人主页.linearGradient({ direction: GradientDirection.Right, colors: [[c1, 0], [c2, 1]] })colors 数组中第二个值为停靠位置 0~1
animateTo动画 API显式动画驱动状态变化所有 aboutToAppear 中this.getUIContext().animateTo({...}, () => { this.x = val })闭包内对 @State 的修改会平滑过渡
Progress内置组件进度展示环形图、匹配度展示Progress({ value: 80, total: 100, type: ProgressType.Ring })支持 Linear/Ring/Eclipse 类型
Toggle内置组件开关切换协议勾选、设置开关Toggle({ type: ToggleType.Switch, isOn: this.val })onChange 回调获取新状态
TextInput内置组件文本输入搜索框、表单输入TextInput({ placeholder: '...' }).onChange((v) => {...})支持 InputType.Number 等类型限制
Divider内置组件分割线列表项之间、弹框分区Divider().color(PC.line)默认水平方向,可通过 vertical() 改为垂直
position定位属性绝对定位图标叠加、浮动按钮.position({ x: '78%', y: '84%' })脱离文档流,相对于父容器定位
scale变换属性缩放变换动画效果、选中放大.scale({ x: 1.12, y: 1.12 })值为 1 表示原始大小,常用于动画
rotate变换属性旋转变换浮动按钮旋转.rotate({ angle: this.fabRotate })angle 单位为度,正值顺时针
opacity视觉属性透明度动画效果、未解锁状态.opacity(0.3)0 完全透明,1 完全不透明
borderRadius视觉属性圆角卡片、按钮、头像.borderRadius(14)单值四角统一,对象可分别设置
maxLines文本属性最大行数标题、描述文本.maxLines(2)超出部分自动截断
justifyContent对齐属性主轴对齐按钮居中、内容分布.justifyContent(FlexAlign.Center)Center/Start/End/SpaceBetween/SpaceAround
alignItems对齐属性交叉轴对齐内容垂直/水平对齐.alignItems(HorizontalAlign.Center)在 Column 中控制水平,在 Row 中控制垂直
aboutToAppear生命周期组件创建后回调所有组件在 build() 前执行,适合初始化和动画启动仅执行一次
zIndex层级属性控制 Z 轴层级弹框遮罩.zIndex(999)值越大越在上层
constraintSize尺寸约束限制最大/最小尺寸弹框内容区滚动.constraintSize({ maxHeight: '52%' })防止内容过多撑开布局

十三、总结

本文对一份完整的鸿蒙 ArkTS 同城兴趣组局平台源码进行了逐段、逐行、逐组件的深度技术解析。从整体架构来看,这份源码体现了 ArkTS 声明式 UI 开发的典型范式:以数据模型为基底,以纯函数为加工层,以 @Component 组件为视图单元,以 @State 状态变量为响应式驱动,以 @Builder 方法为复用手段,以 animateTo 动画为体验增色,构建出一个功能完整、交互丰富、视觉鲜明的多 Tab 社交应用界面。

在数据层设计上,源码定义了 14 个接口和对应的数据数组,覆盖了兴趣标签、活动信息、组局管理、成员名单、搭子资料、场地信息、消息会话、系统通知、徽章成就等全场景数据模型。所有数据模型都使用 interface 明确定义字段类型,确保了类型安全。数据数组在模块顶层以 const 声明,作为不可变的静态数据源供各组件引用。

在逻辑层设计上,源码实现了 12 个全局纯函数,分为三类:分列与排序函数(eventsLefteventsRighthotEventsmateSortedcheapVenues)、筛选函数(unreadChatsvenuesOfunreadCount)和颜色映射函数(catTintcatColormatchColorrankColorstatusColor)。这些纯函数不修改外部状态、不依赖可变变量,给定输入必定产生相同输出,是响应式 UI 系统稳定运行的基础。

在视图层设计上,源码包含 7 个 @Component 组件:6 个 Tab 页面组件和 1 个主入口组件。每个 Tab 页面组件都遵循统一的结构模式——@State 状态定义、aboutToAppear 动画初始化、@Builder modalOverlay 通用遮罩、若干业务专用 @Builder 方法、build() 主构建方法。这种统一的代码结构使得各组件的风格一致、可读性强、维护成本低。

在交互层设计上,源码实现了 8 个全屏遮罩弹框,涵盖了居中卡片、底部抽屉、警告卡片三种弹框模式。所有弹框都通过 @State 布尔变量控制显隐,通过条件渲染 if (this.showXxx)build() 方法中动态挂载。弹框内部使用 Scroll 容器处理内容溢出,使用 constraintSize 限制最大高度,使用 zIndex(999) 确保层级最高。8 个动画特效分别作用于不同组件的不同元素——热度火焰呼吸、LIVE 红点闪烁、发起按钮旋转、成局率徽章光晕、匹配心跳、优惠标签跳动、未读角标脉冲、徽章闪光——通过 aboutToAppear 中的 animateTo 启动,使用 PlayMode.Alternateiterations: -1 实现无限循环交替播放。


安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:

        }
      }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 6, bottom: 8 }).onClick(() => {
        this.activeTab = i
      })
    })
  }.width('100%').backgroundColor(PC.card)
}.width('100%').height('100%').backgroundColor(PC.bg)

}
}


---
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/f263507e6dc346b0953513d30831d6bb.png#pic_center)

在布局技术方面,源码充分运用了 `Column`、`Row`、`Stack` 三大布局容器的组合能力。`Column` 和 `Row` 用于线性排列,通过 `layoutWeight` 实现弹性分配和比例条效果;`Stack` 用于层叠覆盖,实现头像角标、封面标签、环形图嵌套等效果。`Scroll` 组件使内容可滚动,`ForEach` 组件使列表可动态渲染。条件渲染 `if-else` 实现了状态驱动的 UI 切换,包括标签选中态、弹框显隐、Tab 分发、表单分步等多种场景。

在图表实现方面,源码展示了如何在不引入第三方图表库的前提下,仅使用原生 ArkUI 组件实现柱状图、环形图、比例条等多种数据可视化效果。柱状图通过 `Column` 的高度与数据值成正比实现;环形图通过 `Stack` 层叠多个不同尺寸的 `Progress` 环形组件实现;比例条通过 `layoutWeight` 按数据值比例分配空间实现。这些自绘图表方案充分体现了 ArkUI 组件体系的灵活性和表现力。

从工程规范的角度来看,这份源码在配色管理、数据建模、函数设计、组件划分、动画使用等方面都展现了良好的实践。配色通过接口定义和常量实例化集中管理,数据通过类型安全的接口明确定义结构,逻辑通过纯函数实现可测试可预测的处理,组件通过统一的代码结构模式保持一致性,动画通过 `aboutToAppear` 生命周期统一启动。这些实践为鸿蒙 ArkTS 应用开发提供了一个可参考的代码组织范本。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐