HarmonyOS 6效果实现:提供了 LazyForEach 用于大数据量的懒加载渲染,以及 ContentSlot 用于动态内容插槽等高级渲染能力
鸿蒙操作系统(HarmonyOS)作为华为推出的面向全场景的分布式操作系统,其应用开发框架 ArkUI 提供了一套基于声明式编程范式的 UI 构建体系。ArkTS 作为 ArkUI 的开发语言,在 TypeScript 的基础上进行了深度扩展与静态类型约束,使得开发者能够以更安全、更高效的方式构建跨设备应用界面。ArkTS 的核心设计哲学在于"状态驱动视图"——即 UI 是状态的函数映射,当状态数据发生变化时,框架自动触发与之关联的 UI 组件重新渲染,开发者无需手动操作 DOM 节点或调用 imperative API 更新界面。这种声明式的开发模式大幅降低了状态同步的复杂度,同时也为编译器层面的优化提供了空间。
ArkTS 的组件化开发思想是其另一大核心特性。在 ArkUI 框架中,一切可见的界面元素都被抽象为组件(Component)。每个组件拥有独立的状态空间、生命周期回调与构建逻辑,组件之间通过参数传递和事件回调进行通信。这种设计使得复杂界面可以被拆解为若干个职责单一的子组件,每个子组件专注于自身的渲染逻辑,通过组合(Composition)而非继承(Inheritance)来构建复杂的界面层级。在本文所分析的这个同城摄影师约拍平台中,整个应用由一个入口主组件和六个功能各异的 Tab 子组件构成,形成了一个典型的多 Tab 页面架构。
声明式 UI 的另一个关键概念是装饰器(Decorator)。ArkTS 提供了丰富的装饰器体系来标注组件的类型与行为。
@Entry标识应用的入口组件,一个页面只能有一个入口组件;@Component将一个 struct 声明为可复用的 UI 组件;@State定义组件内部的响应式状态变量,当其值改变时触发组件重渲染;@Builder将一段 UI 构建逻辑封装为可复用的构建函数。这些装饰器共同构成了 ArkTS 组件化开发的骨架,使得开发者能够以结构化、可维护的方式组织界面代码。此外,ArkTS 还提供了@Prop、@Link、@Provide/@Consume等装饰器用于实现父子组件间的数据传递与同步,@Watch用于监听状态变化,@StorageLink用于跨组件级别的状态共享,形成了完整的响应式状态管理生态。
在布局系统方面,ArkUI 提供了多种布局容器组件来满足不同的排版需求。
Column是垂直线性布局容器,子元素沿主轴(纵轴)方向从上到下排列;Row是水平线性布局容器,子元素沿主轴(横轴)方向从左到右排列;Stack是层叠布局容器,子元素可以在 Z 轴方向上重叠,后声明的子元素覆盖在先声明的子元素之上;Flex是弹性布局容器,支持换行(Wrap)与多种对齐模式,比 Row 和 Column 提供了更灵活的排列能力。这些布局容器可以嵌套使用,形成复杂的界面结构。每个布局容器都提供了丰富的属性来控制子元素的对齐方式、间距、权重分配等,开发者可以通过链式调用(Method Chaining)的方式为组件设置属性,代码可读性强且表达力丰富。
在列表渲染方面,ArkUI 提供了 ForEach 组件来实现基于数据数组的循环渲染。ForEach 接受一个数据源数组、一个子组件生成函数和一个键值生成函数(可选),框架会根据数据源的变化自动增删和更新对应的子组件。这种数据驱动的渲染方式使得开发者只需关注数据模型本身,而无需手动管理列表项的创建与回收。在本文分析的代码中,ForEach 被大量使用于渲染客片列表、摄影师列表、日历格、套餐列表、订单列表等场景,是整个应用界面动态生成的核心驱动力。此外,ArkUI 还提供了 LazyForEach 用于大数据量的懒加载渲染,以及 ContentSlot 用于动态内容插槽等高级渲染能力。
本文将以一个完整的同城摄影师约拍平台为例,逐段、逐组件地深入剖析其 ArkTS 源码实现。该平台涵盖了作品展示、摄影师列表、档期日历、套餐对比、约拍单管理和个人中心六大功能模块,涉及数据建模、纯函数封装、多组件架构、状态管理、动画系统、弹框模态、图表绘制等鸿蒙开发的方方面面。我们将从配色系统与数据模型出发,逐步深入到每个组件的构建逻辑,最终梳理出整个应用的架构脉络与设计思路。
一、配色系统与设计令牌
interface LensColor {
bg: string
card: string
primary: string
brown: string
olive: string
ink: string
sub: string
hint: string
line: string
cream: string
danger: string
gold: string
white: string
}

这段代码定义了一个名为 LensColor 的接口(interface),用于约束配色方案对象的结构类型。在 ArkTS 中,接口是一种纯类型声明,它不产生运行时对象,仅用于编译期的类型检查。LensColor 接口定义了十三个字符串类型的属性,分别对应应用中的不同视觉语义角色:背景色 bg、卡片色 card、主色 primary、暗棕色 brown、橄榄绿 olive、深色文字 ink、次要文字 sub、提示文字 hint、分割线 line、奶油色 cream、危险色 danger、金色 gold 和白色 white。
将颜色抽象为语义化角色而非直接使用十六进制色值,是一种成熟的前端工程实践。这种做法的核心优势在于:当需要切换主题或调整配色方案时,只需修改一个集中式的常量对象,所有引用了该语义角色的组件会自动应用新的颜色值,无需逐一搜索替换。同时,语义化命名使得代码可读性大幅提升——当开发者看到 CL.primary 时,立刻知道这是"主色调",而看到 #C25A2B 则需要额外的心智负担去理解这个颜色的用途。
const CL: LensColor = {
bg: '#F5F1E8',
card: '#FDFBF6',
primary: '#C25A2B',
brown: '#4A3A2E',
olive: '#7A8450',
ink: '#33281F',
sub: '#8C7A66',
hint: '#BFAE95',
line: '#E6DCC8',
cream: '#EFE6D3',
danger: '#A63D2F',
gold: '#B08430',
white: '#FFFFFF'
}
这里声明了一个 CL 常量并为其赋了具体的色值。const 关键字确保该引用不可被重新赋值,而 : LensColor 类型标注则让编译器在赋值时检查每个属性是否符合接口定义。从色值可以看出,整个应用采用了胶片复古风格的配色方案:米白色背景 #F5F1E8 营造出温暖的纸质感,砖橙色主色 #C25A2B 呼应了胶片冲洗药液的色调,暗棕色 #4A3A2E 和深棕色文字 #33281F 提供了沉稳的层次感,橄榄绿 #7A8450 作为点缀色增添自然气息,金色 #B08430 用于强调和装饰。这套配色体系贯穿了整个应用的所有界面,从头部搜索框到底部 Tab 栏,从卡片背景到按钮文字,全部统一引用 CL 对象中的语义色值,保证了视觉风格的高度一致性。
在鸿蒙 ArkUI 开发中,
const声明的全局常量在编译期即可确定其值,编译器能够对其进行内联优化和静态分析。当常量对象在多个组件中被引用时,框架不需要为每个组件创建副本,而是共享同一份引用,这在内存效率上具有显著优势。此外,由于 ArkTS 的类型系统在编译期即完成检查,接口定义的常量对象在运行时不会产生额外的类型信息开销。
二、Tab 定义与头部胶囊配置
interface TabDef {
icon: string
label: string
}
const FILM_TABS: TabDef[] = [
{ icon: '🎞️', label: '作品' },
{ icon: '📷', label: '摄影师' },
{ icon: '📅', label: '档期' },
{ icon: '💎', label: '套餐' },
{ icon: '📝', label: '约拍单' },
{ icon: '👤', label: '我的' }
]

这段代码定义了底部导航栏的六个 Tab 项。TabDef 接口包含 icon(图标 Emoji 字符)和 label(文字标签)两个属性。FILM_TABS 是一个 TabDef 类型的数组,包含了六个 Tab 配置项,分别对应作品、摄影师、档期、套餐、约拍单和我的六个功能页面。
使用数组来定义 Tab 配置是一种数据驱动的设计思路。相比于在 build() 方法中硬编码六个 Tab 项的 UI,这种方式将数据与视图分离——Tab 的数量和内容由数据源决定,视图层只需遍历数据源即可生成对应的 UI 元素。当需要增减 Tab 或修改图标文字时,只需修改这个数组,无需改动视图代码。在后面的 build() 方法中,我们可以看到主组件通过索引 FILM_TABS[0]、FILM_TABS[1] 等方式逐个调用 tabItem 构建函数来渲染底部导航栏。
interface HeaderCapsule {
name: string
tab: number
}
const HEADER_CAPS: HeaderCapsule[] = [
{ name: '🎞️ 客片作品', tab: 0 },
{ name: '📷 摄影师', tab: 1 },
{ name: '📅 档期日历', tab: 2 },
{ name: '💎 拍摄套餐', tab: 3 },
{ name: '📝 约拍单', tab: 4 },
{ name: '👤 我的暗房', tab: 5 }
]

HeaderCapsule 接口定义了头部风格胶囊的数据结构,包含 name(显示名称)和 tab(对应的目标 Tab 索引)。HEADER_CAPS 数组定义了六个胶囊项,每个胶囊关联一个 Tab 索引。当用户点击某个胶囊时,主组件的 activeTab 状态变量会被设置为该胶囊对应的 tab 值,从而切换到对应的功能页面。
这种"胶囊导航"模式在移动端应用中非常常见,它提供了一种快捷的页面跳转方式,用户不需要先滚动到底部 Tab 栏再点击,而是直接在头部区域即可切换页面。tab 字段的存在使得胶囊与底部 Tab 栏保持同步——两者控制的是同一个 activeTab 状态变量,无论从哪个入口切换页面,底部 Tab 栏的高亮状态都会自动更新。
三、数据模型体系
本应用定义了十五个数据模型接口,覆盖了平台运营所需的全部业务实体。这些接口构成了应用的数据层基础,所有的静态数据和渲染逻辑都围绕这些模型展开。
interface BannerItem {
id: number
title: string
sub: string
icon: string
tag: string
bg: string
}

BannerItem 定义了头部横幅广告的数据结构。id 是唯一标识符,title 是主标题(如"九月约拍季"),sub 是副标题(如"写真档期首单立减 300"),icon 是 Emoji 图标,tag 是标签角标(如"爆款"、“旅拍”),bg 是背景色值。每个 Banner 项可以拥有不同的背景色,从而在视觉上形成色彩斑斓的轮播效果。
interface WorkItem {
id: number
title: string
style: string
author: string
likes: number
shots: number
color: string
tag: string
}

WorkItem 定义了客片作品的数据模型。title 是作品名称(如"旧巷斜阳"),style 是拍摄风格(如"复古胶片"、“日系清新”),author 是摄影师名,likes 是点赞数,shots 是底片张数,color 是用于渲染色块的背景色,tag 是分类标签(如"热门"、“上新”)。color 字段的设计值得注意——由于这是一个纯前端演示应用,没有真实的图片资源,开发者通过为每个作品指定不同的颜色值来模拟图片缩略图的视觉效果。
interface PhotographerItem {
id: number
name: string
icon: string
city: string
style: string
rating: number
orders: number
price: number
years: number
level: string
master: string
color: string
}

PhotographerItem 定义了摄影师信息模型,是整个应用中最复杂的数据模型之一。除了基本的 name、icon、city、style 字段外,它还包含 rating(评分 1-5)、orders(累计订单数)、price(起拍价)、years(从业年限)、level(等级头衔如"金牌胶片师"、“签约大师”)、master(代表作名称)和 color(头像色块背景色)。这些字段在摄影师列表卡片和详情抽屉中被全面展示。
interface DayCell {
id: number
day: number
status: string
slots: number
}
interface PkgItem {
id: number
name: string
tier: string
price: number
origPrice: number
hours: number
retouch: number
dresses: number
scenes: number
sales: number
tag: string
}

DayCell 定义了日历格的数据结构,day 是日期数字,status 是档期状态(“可约”、“已满”、“拍摄中”、“休整”),slots 是剩余可预约时段数。PkgItem 定义了拍摄套餐模型,tier 是套餐档位(“轻享”、“经典”、“尊享”),price 和 origPrice 分别是现价和原价,hours 是拍摄时长,retouch 是精修张数,dresses 是服装套数,scenes 是场景数,sales 是销量。
在 ArkTS 的类型系统中,接口(interface)是一种结构化类型(Structural Typing)定义。与 class 不同,接口不会在运行时产生原型对象或构造函数,它仅存在于编译期用于类型检查。这意味着接口定义不会增加运行时的内存开销或代码体积,是纯零成本的抽象机制。在鸿蒙应用开发中,推荐使用接口而非 class 来定义纯数据模型,除非需要封装行为方法。
interface OrderItem {
id: number
no: string
photographer: string
style: string
date: string
time: string
status: string
amount: number
step: number
}
interface ReviewItem {
id: number
user: string
icon: string
rating: number
date: string
content: string
likes: number
pkg: string
}
interface FavorItem {
id: number
title: string
author: string
style: string
likes: number
color: string
}

OrderItem 定义了约拍订单模型,no 是订单编号(如"YP260918001"),status 是订单状态(“待沟通”、“已定档”、“待拍摄”、“选片中”、“已交付”、“已取消”),step 是当前流程步骤编号(0-5)。ReviewItem 定义了客片评价模型,包含用户名、评分、评价内容和点赞数。FavorItem 定义了收藏作品模型,结构相对简洁。
interface StyleDist {
id: number
name: string
pct: number
color: string
}
interface MonthShoot {
id: number
month: string
studio: number
travel: number
orders: number
}
interface GearItem {
id: number
name: string
kind: string
icon: string
note: string
}
interface FlowNode {
id: number
step: string
title: string
desc: string
icon: string
day: string
}
interface SlotItem {
id: number
time: string
state: string
}
interface TipItem {
id: number
title: string
icon: string
desc: string
}
interface RollItem {
id: number
name: string
iso: number
tone: string
price: number
}
这些接口分别定义了风格分布(用于环形图表)、月度拍摄量(用于柱状图)、器材清单、约拍流程节点、时间段、小贴士和胶卷规格等数据模型。每个接口的字段都精确对应其在界面中的渲染需求。例如 FlowNode 的 step 字段是步骤编号字符串(“01”-“06”),day 字段是时间标记(“D0”、“D1”、"D-Day"等),用于在流程时间线中显示。
四、静态数据源
在定义完所有接口之后,代码声明了大量的静态数据数组作为数据源。这些数组在模块加载时即被初始化,为整个应用的 UI 渲染提供数据支撑。
const BANNER_LIST: BannerItem[] = [
{ id: 1, title: '九月约拍季', sub: '写真档期首单立减 300', icon: '🎞️', tag: '爆款', bg: '#C25A2B' },
{ id: 2, title: '大师旅拍团', sub: '洱海 3 天 2 夜跟拍', icon: '🏔️', tag: '旅拍', bg: '#7A8450' },
{ id: 3, title: '婚摄档期开抢', sub: '婚礼跟拍赠快剪', icon: '💍', tag: '婚摄', bg: '#4A3A2E' },
{ id: 4, title: '胶片复兴计划', sub: '港风夜景 5 折体验', icon: '🌃', tag: '5折', bg: '#B08430' },
{ id: 5, title: '古风汉服季', sub: '妆造服装一站配齐', icon: '🏮', tag: '送妆造', bg: '#A63D2F' },
{ id: 6, title: '老客回拍日', sub: '双倍底片加赠 20 张', icon: '📮', tag: '回馈', bg: '#8C7A66' }
]
BANNER_LIST 包含六条 Banner 数据,每条都有不同的背景色,形成了多彩的横幅展示效果。注意第一条 Banner 的 bg 值 #C25A2B 与配色常量 CL.primary 的值相同,这并非巧合——首页 Banner 通常使用品牌主色来强化品牌认知。
const WORK_LIST: WorkItem[] = [
{ id: 1, title: '旧巷斜阳', style: '复古胶片', author: '阿茉', likes: 482, shots: 36, color: '#C97B4A', tag: '热门' },
{ id: 2, title: '便利店之夜', style: '日系清新', author: '小野', likes: 356, shots: 42, color: '#9DB08C', tag: '上新' },
// ... 共 18 条
]
WORK_LIST 包含十八条客片作品数据,每条都有不同的 color 值用于模拟图片色块。十八这个数量是精心设计的——三列网格布局下,十八正好填满六行,形成整齐的网格墙效果。每个作品的 color 值都是从胶片摄影的典型色调中选取的:暖橙色、清绿色、暗棕色等,呼应了应用的胶片复古主题。
const PHOTOGRAPHER_LIST: PhotographerItem[] = [
{ id: 1, name: '阿茉', icon: '🌻', city: '同城·老城区', style: '复古胶片', rating: 5, orders: 386, price: 1288, years: 8, level: '金牌胶片师', master: '旧巷斜阳', color: '#C97B4A' },
// ... 共 12 条
]
const DAY_LIST: DayCell[] = [
{ id: 1, day: 1, status: '已满', slots: 0 },
{ id: 2, day: 2, status: '拍摄中', slots: 1 },
// ... 共 30 天
]
const PKG_LIST: PkgItem[] = [
{ id: 1, name: '轻享胶片·单套系', tier: '轻享', price: 699, origPrice: 899, hours: 1, retouch: 9, dresses: 1, scenes: 1, sales: 1866, tag: '入门' },
// ... 共 9 档
]
const ORDER_LIST: OrderItem[] = [
{ id: 1, no: 'YP260918001', photographer: '阿茉', style: '复古胶片', date: '2026-09-05', time: '15:00-18:00', status: '待沟通', amount: 1688, step: 1 },
// ... 共 12 条
]
这些数据数组覆盖了应用的所有业务实体。PHOTOGRAPHER_LIST 包含十二位摄影师,DAY_LIST 包含三十天的档期数据,PKG_LIST 包含九档套餐,ORDER_LIST 包含十二条约拍单。每个数组中的数据都是经过精心编排的——摄影师的 master 字段值与 WORK_LIST 中的作品 title 相对应,使得摄影师详情抽屉中可以筛选出该摄影师的代表作;订单列表中摄影师的名字也来自 PHOTOGRAPHER_LIST,形成了数据之间的关联关系。
const REVIEW_LIST: ReviewItem[] = [ /* 12 条评价 */ ]
const FAVOR_LIST: FavorItem[] = [ /* 9 件收藏 */ ]
const STYLE_DIST: StyleDist[] = [ /* 5 种风格分布 */ ]
const MONTH_SHOOT: MonthShoot[] = [ /* 12 个月 */ ]
const GEAR_LIST: GearItem[] = [ /* 8 件器材 */ ]
const FLOW_LIST: FlowNode[] = [ /* 6 个流程节点 */ ]
const SLOT_LIST: SlotItem[] = [ /* 8 个时间段 */ ]
const TIPS_LIST: TipItem[] = [ /* 10 条贴士 */ ]
const ROLL_LIST: RollItem[] = [ /* 6 卷胶卷 */ ]
const SPROCKETS: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const DEMAND_STYLES: string[] = ['复古胶片', '日系清新', '港风夜景', '古风汉服', '婚纱旅拍', '情侣跟拍', '儿童跟拍', '家庭纪实']
剩余的数据数组中,SPROCKETS 是一个简单的数字数组,用于渲染胶片齿孔装饰条——胶卷底片的上下两侧有一排均匀排列的齿孔,这是胶片摄影最具辨识度的视觉元素之一。DEMAND_STYLES 是拍摄风格选项的字符串数组,用于新建约拍需求表单中的风格选择标签。这些数据虽然结构简单,但都是 UI 渲染不可或缺的数据源。
五、全局纯函数封装
在数据数组之后,代码定义了一系列全局纯函数(Pure Function)。纯函数是指给定相同输入永远产生相同输出且不产生副作用的函数。这些函数封装了各种数据转换和计算逻辑,在组件的 build() 方法中被调用来动态计算 UI 属性值。
function starText(v: number): string {
let r: string = ''
for (const i of [1, 2, 3, 4, 5]) {
if (i <= v) { r = r + '★' } else { r = r + '☆' }
}
return r
}
starText 函数将数字评分转换为星形字符串。输入 5 返回 "★★★★★",输入 3 返回 "★★★☆☆"。这个函数使用了简单的 for...of 循环遍历 [1, 2, 3, 4, 5] 数组,根据当前索引 i 是否小于等于评分值 v 来决定拼接实心星还是空心星。这种将业务逻辑封装为纯函数的做法使得 UI 构建代码更加简洁——在组件中只需调用 starText(p.rating) 即可获得星星字符串,无需在构建方法中内联复杂的条件判断逻辑。
function dayWeek(d: number): string {
const w: string[] = ['二', '三', '四', '五', '六', '日', '一']
return '周' + w[(d - 1) % 7]
}
function likesW(v: number): string {
const r: number = v / 650 * 100
return r.toFixed(0) + '%'
}
function pctBar(v: number): string {
return v.toString() + '%'
}
dayWeek 函数根据日期数字计算星期几。由于 2026 年 9 月 1 日是周二,所以数组从"二"开始排列,通过 (d - 1) % 7 取模运算映射到数组索引。likesW 函数将点赞数转换为百分比宽度字符串,用于渲染热门客片榜中的进度条——以 650 赞为满宽基准(650 赞对应 100% 宽度)。pctBar 是一个简单的格式化函数,将数字转为百分比字符串。
function dayBg(s: string): string {
if (s === '可约') { return CL.card }
if (s === '已满') { return CL.cream }
if (s === '拍摄中') { return CL.primary }
return CL.bg
}
function dayColor(s: string): string {
if (s === '可约') { return CL.ink }
if (s === '已满') { return CL.danger }
if (s === '拍摄中') { return CL.white }
return CL.hint
}
dayBg 和 dayColor 是一对配套函数,分别根据档期状态返回日历格的背景色和文字色。"可约"状态使用卡片色背景配深色文字,视觉上表现为可点击的浅色格;"已满"状态使用奶油色背景配危险红色文字,传达"已不可用"的信息;"拍摄中"状态使用主色背景配白色文字,形成醒目的彩色块;"休整"状态使用背景色配提示色文字,呈现灰暗低调的效果。这两个函数完美诠释了"数据驱动样式"的理念——状态字符串决定了整个日历格的视觉表现。
function orderStatusColor(s: string): string {
if (s === '已交付') { return CL.olive }
if (s === '选片中') { return CL.gold }
if (s === '待拍摄' || s === '拍摄中') { return CL.primary }
if (s === '已定档') { return CL.brown }
if (s === '已取消') { return CL.hint }
return CL.danger
}
function tierColor(t: string): string {
if (t === '尊享') { return CL.brown }
if (t === '经典') { return CL.primary }
return CL.olive
}
orderStatusColor 函数将订单状态映射为颜色值,不同状态使用不同颜色以便用户快速识别订单进度。tierColor 函数将套餐档位映射为颜色值——"尊享"档用暗棕色显得沉稳高端,"经典"档用主色突出核心推荐,"轻享"档用橄榄绿传达轻松亲民的感觉。这种颜色编码策略在信息密集的列表界面中尤为重要,它利用人类对颜色的快速感知能力来提升信息浏览效率。
function maxShoot(): number {
let r: number = 0
for (const m of MONTH_SHOOT) {
if (m.studio + m.travel > r) { r = m.studio + m.travel }
}
return r
}
function studioBarH(v: number): string {
const r: number = v / maxShoot() * 68
return r.toFixed(0) + 'vp'
}
function travelBarH(v: number): string {
const r: number = v / maxShoot() * 68
return r.toFixed(0) + 'vp'
}
这三个函数共同实现了柱状图的动态高度计算。maxShoot 遍历全年十二个月的数据,找出写真和旅拍数量之和的最大值作为归一化基准。studioBarH 和 travelBarH 分别将给定数值除以最大值再乘以 68,得到以 vp(虚拟像素)为单位的柱状图高度字符串。vp 是鸿蒙中的虚拟像素单位,它会根据设备的屏幕密度自动进行缩放,保证在不同分辨率设备上呈现一致的视觉效果。
function hotWorks(): WorkItem[] {
let r: WorkItem[] = []
for (const w of WORK_LIST) {
if (w.likes >= 400) { r.push(w) }
}
r.sort((a: WorkItem, b: WorkItem) => b.likes - a.likes)
return r
}
function photoByOrders(): PhotographerItem[] {
let r: PhotographerItem[] = PHOTOGRAPHER_LIST.slice()
r.sort((a: PhotographerItem, b: PhotographerItem) => b.orders - a.orders)
return r
}
function photoByRating(): PhotographerItem[] {
let r: PhotographerItem[] = PHOTOGRAPHER_LIST.slice()
r.sort((a: PhotographerItem, b: PhotographerItem) => b.rating - a.rating || b.orders - a.orders)
return r
}
function photoByPrice(): PhotographerItem[] {
let r: PhotographerItem[] = PHOTOGRAPHER_LIST.slice()
r.sort((a: PhotographerItem, b: PhotographerItem) => a.price - b.price)
return r
}
这是一组数据筛选与排序函数。hotWorks 从作品列表中筛选出点赞数大于等于 400 的热门作品,并按点赞数降序排列。photoByOrders、photoByRating、photoByPrice 分别按照订单量、评分和价格对摄影师列表进行排序。这三个函数都使用了 slice() 方法先创建原数组的浅拷贝,然后在拷贝上进行排序操作,避免修改原始数据——这是一种不可变数据(Immutable Data)的编程实践。photoByRating 的排序比较函数使用了短路逻辑 b.rating - a.rating || b.orders - a.orders,当评分相同时再按订单数排序,实现了多级排序。
function pkgBySales(): PkgItem[] {
let r: PkgItem[] = PKG_LIST.slice()
r.sort((a: PkgItem, b: PkgItem) => b.sales - a.sales)
return r
}
function favorSorted(): FavorItem[] {
let r: FavorItem[] = FAVOR_LIST.slice()
r.sort((a: FavorItem, b: FavorItem) => b.likes - a.likes)
return r
}
function rollW(v: number): string {
const r: number = v / 100 * 100
return r.toFixed(0) + '%'
}
function savingPct(p: PkgItem): string {
const r: number = (p.origPrice - p.price) / p.origPrice * 100
return r.toFixed(0) + '%'
}
function albumPct(v: number): string {
return v.toString() + '%'
}
pkgBySales 和 favorSorted 分别按销量和点赞数对套餐和收藏列表进行排序。savingPct 计算套餐的节省百分比——用原价减去现价再除以原价乘以一百,这在电商类应用中是展示优惠力度的标准计算方式。rollW 计算胶卷价格的进度条宽度百分比,albumPct 是简单的数字转百分比格式化函数。所有这些函数都是无状态的纯函数,可以在任意位置安全调用而不会产生副作用。
在 ArkUI 的声明式渲染模型中,纯函数扮演着"计算桥梁"的角色。组件的
build()方法在每次重渲染时会重新调用这些纯函数来计算 UI 属性值,由于纯函数的输出仅取决于输入,框架可以安全地进行结果缓存和增量更新优化。如果这些计算逻辑被内联到build()方法中,不仅代码可读性下降,还可能因重复计算导致性能问题。将计算逻辑提取为独立函数是 ArkTS 开发中的推荐实践。
六、入口主组件架构
@Entry
@Component
struct LensFilmApp {
@State activeTab: number = 0
@State searchWord: string = ''
这是应用的入口主组件声明。@Entry 装饰器标记此组件为页面的入口点,一个页面只能有一个 @Entry 组件。@Component 装饰器将 LensFilmApp 这个 struct 声明为一个 ArkUI 组件,使其可以被框架识别和渲染。
@State 装饰器声明了两个响应式状态变量。activeTab 初始值为 0,表示当前激活的 Tab 索引(默认显示"作品"页面)。searchWord 初始值为空字符串,存储搜索框的输入内容。当 @State 变量的值发生变化时,框架会自动触发组件的 build() 方法重新执行,更新与该状态变量绑定的 UI 部分。这种"状态变更触发视图更新"的机制是 ArkUI 声明式 UI 的核心运行原理。
顶部品牌区构建
@Builder headerBar() {
Row() {
Column() {
Text('LENS').fontSize(15).fontWeight(FontWeight.Bold).fontColor(CL.primary)
Text('胶片盒子 · 同城约拍').fontSize(8).fontColor(CL.sub).margin({ top: 2 })
}
.width(96)
.alignItems(HorizontalAlign.Start)
@Builder 装饰器将 headerBar 方法声明为一个 UI 构建函数。构建函数是 ArkTS 中封装可复用 UI 片段的机制——被 @Builder 标注的方法可以在 build() 方法中通过 this.xxx() 的方式被调用,其返回的 UI 结构会被内联到调用位置。
headerBar 构建函数渲染的是顶部的品牌区域。最外层是 Row 水平布局容器,其第一个子元素是 Column 垂直布局容器,包含两行 Text 文本:品牌名"LENS"使用主色和粗体字重,副标题"胶片盒子 · 同城约拍"使用次要色和更小的字号。Column 的 width(96) 将品牌区宽度固定为 96vp,alignItems(HorizontalAlign.Start) 使子元素在交叉轴(水平方向)上左对齐。
Column 是 ArkUI 中的垂直线性布局容器。它的子元素沿主轴(垂直方向)从上到下依次排列,alignItems 属性控制子元素在交叉轴(水平方向)上的对齐方式。可选值包括 HorizontalAlign.Start(左对齐)、HorizontalAlign.Center(居中对齐)、HorizontalAlign.End(右对齐)。Column 还支持 justifyContent 属性来控制子元素在主轴方向上的分布方式,如 FlexAlign.Center(居中)、FlexAlign.SpaceBetween(两端对齐)、FlexAlign.SpaceAround(等间距分布)等。
Row() {
Text('🔍').fontSize(12)
TextInput({ placeholder: '写真 / 旅拍 / 婚摄 / 跟拍' })
.placeholderColor(CL.hint)
.placeholderFont({ size: 11 })
.fontSize(11)
.layoutWeight(1)
.padding({ left: 0, right: 0 })
.backgroundColor(Color.Transparent)
.onChange((v: string) => { this.searchWord = v })
Text('🎞️').fontSize(12).margin({ left: 4 })
}
.layoutWeight(1)
.height(34)
.padding({ left: 8, right: 8 })
.backgroundColor(CL.cream)
.border({ width: 1.5, color: CL.brown })
.borderRadius(4)
.margin({ left: 8 })
.alignItems(VerticalAlign.Center)
搜索框区域是一个嵌套的 Row 容器,包含搜索图标 Text('🔍')、TextInput 输入框和胶片图标 Text('🎞️') 三个子元素。TextInput 是 ArkUI 提供的文本输入组件,通过 placeholder 参数设置占位提示文字,placeholderColor 和 placeholderFont 分别设置占位文字的颜色和字体大小。
layoutWeight(1) 是 ArkUI 中的布局权重属性,它使 TextInput 占据父容器中剩余的空间。在 Row 容器中,如果其他子元素已经占据了固定宽度,设置了 layoutWeight 的子元素会自动填充剩余的水平空间。这种弹性布局机制类似于 CSS 中的 flex: 1,是响应式布局的基础。
搜索框的 onChange 回调将输入值赋给 this.searchWord 状态变量,触发响应式更新。外层 Row 设置了奶油色背景、1.5vp 暗棕色边框和 4vp 圆角,形成了类似胶片相机取景框的边框效果。margin({ left: 8 }) 在品牌区和搜索框之间留出 8vp 的间距。
Column() {
Text('📷').fontSize(17)
Text('发约拍').fontSize(8).fontColor(CL.sub).margin({ top: 2 })
}
.width(44)
.margin({ left: 8 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.activeTab = 4 })
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 8 })
.backgroundColor(CL.bg)
.alignItems(VerticalAlign.Center)
}
顶栏最右侧是"发约拍"快捷入口,由一个 Column 容器包含图标和文字组成。onClick 事件处理器将 this.activeTab 设置为 4,即约拍单 Tab 的索引,点击后直接跳转到约拍单页面。这种通过修改状态变量来触发页面切换的方式是 ArkUI 中导航的典型模式——不使用命令式的导航 API,而是通过状态变化驱动视图更新。
整个 headerBar 的外层 Row 设置了 width('100%') 占满宽度,内边距 padding 上下左右各 14/10/14/8vp,背景色为页面背景色 CL.bg,子元素在垂直方向上居中对齐。
头部 Banner 区构建
@Builder bannerRow() {
Row() {
Column() {
Text(BANNER_LIST[0].icon).fontSize(26)
Text(BANNER_LIST[0].title).fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.white).margin({ top: 4 })
Text(BANNER_LIST[0].sub + ' · ' + BANNER_LIST[0].tag).fontSize(9).fontColor('#EFE6D3').margin({ top: 4 })
Text('立即约拍 ›').fontSize(10).fontColor('#F5F1E8').backgroundColor('rgba(255,255,255,0.18)')
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(12).margin({ top: 8 })
.onClick(() => { this.activeTab = 4 })
}
.layoutWeight(1)
.height(106)
.padding(12)
.linearGradient({ direction: GradientDirection.Right, colors: [[CL.primary, 0], ['#A66A4C', 1]] })
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
.margin({ left: 12, right: 6 })
bannerRow 构建函数渲染头部的主 Banner 区域。主体是一个 Column 容器,高度 106vp,内边距 12vp,使用 linearGradient 线性渐变作为背景——从左侧的主色 CL.primary(#C25A2B)渐变到右侧的暖棕色 #A66A4C,方向为从左到右(GradientDirection.Right)。渐变背景的 colors 参数接受一个数组,每个元素是 [颜色值, 位置] 的元组,位置值范围 0 到 1。
Banner 内部包含图标、标题、副标题和行动按钮四个 Text 元素,垂直排列。标题使用白色粗体字,副标题使用半透明的奶油色 #EFE6D3。"立即约拍"按钮使用了半透明白色背景 rgba(255,255,255,0.18),在渐变背景上呈现出毛玻璃质感的效果。justifyContent(FlexAlign.Center) 使子元素在主轴(垂直方向)上居中分布。
linearGradient 是 ArkUI 中的线性渐变属性,它比纯色背景更能营造视觉层次感。渐变方向支持 GradientDirection.Right、Left、Top、Bottom 以及对角线方向等。colors 数组中可以定义多个色标,实现多色渐变效果。在本文的代码中,几乎所有头部装饰区域都使用了双色线性渐变来增强视觉表现力。
Column() {
Text('🏅').fontSize(16)
Text('摄影师榜').fontSize(9).fontColor(CL.primary).margin({ top: 4 })
Text('12 位在线').fontSize(8).fontColor(CL.hint).margin({ top: 2 })
}
.width(58).height(106)
.backgroundColor(CL.card).borderRadius(14)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
.margin({ right: 12 })
.onClick(() => { this.activeTab = 1 })
}
.width('100%')
.margin({ top: 4 })
}
Banner 右侧是一个宽度 58vp 的窄卡片,展示"摄影师榜"入口。它使用卡片色背景与主 Banner 的渐变背景形成对比,点击后跳转到摄影师列表页(Tab 1)。这个设计充分利用了 Banner 行的剩余空间,将辅助入口与主入口并排展示。
风格胶囊导航构建
@Builder capsRow() {
Scroll() {
Row() {
ForEach(HEADER_CAPS, (c: HeaderCapsule) => {
if (this.activeTab === c.tab) {
Text(c.name).fontSize(10).fontColor(CL.white).backgroundColor(CL.primary)
.padding({ left: 11, right: 11, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 4, right: 4 })
.onClick(() => { this.activeTab = c.tab })
} else {
Text(c.name).fontSize(10).fontColor(CL.sub).backgroundColor(CL.card)
.padding({ left: 11, right: 11, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 4, right: 4 })
.onClick(() => { this.activeTab = c.tab })
}
})
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(34)
.margin({ top: 6 })
}
capsRow 构建函数渲染头部的风格胶囊导航条。外层是 Scroll 滚动容器,设置了 scrollable(ScrollDirection.Horizontal) 使其支持水平滚动,scrollBar(BarState.Off) 隐藏滚动条。Scroll 内部是一个 Row 容器,通过 ForEach 遍历 HEADER_CAPS 数组生成六个胶囊标签。
ForEach 是 ArkUI 中的循环渲染组件,它接受三个参数:数据源数组、子组件生成函数和键值生成函数(可选)。这里遍历 HEADER_CAPS 数组,为每个 HeaderCapsule 生成一个 Text 标签。通过 if-else 条件判断 this.activeTab === c.tab,当前激活的 Tab 对应的胶囊使用主色背景和白色文字,其余的使用卡片色背景和次要色文字,形成"选中"与"未选中"两种视觉状态。
ForEach在 ArkUI 中不仅仅是简单的循环渲染。框架会根据数据源的键值(Key)来进行差分更新——当数据源发生变化时,框架会比较新旧数据的键值,只增删或更新发生变化的列表项,而非重建整个列表。因此,为ForEach提供唯一的键值生成函数可以显著提升列表渲染的性能。在本代码中,由于数据源是静态数组且不会变化,省略键值生成函数不会影响性能。
内容区分发器
@Builder contentArea() {
Column() {
if (this.activeTab === 0) {
WorksContent({ onGoto: (tab: number) => { this.activeTab = tab } })
} else if (this.activeTab === 1) {
PhotographersContent({ onGoto: (tab: number) => { this.activeTab = tab } })
} else if (this.activeTab === 2) {
ScheduleContent()
} else if (this.activeTab === 3) {
PkgContent({ onGoto: (tab: number) => { this.activeTab = tab } })
} else if (this.activeTab === 4) {
OrdersContent()
} else {
MineContent()
}
}
.layoutWeight(1)
}
contentArea 构建函数是整个应用的内容分发器。它通过 if-else if-else 条件链根据 this.activeTab 的值决定渲染哪个子组件。每个子组件都是一个独立的 @Component,通过构造参数传递初始数据和回调函数。
值得注意的是 onGoto 回调参数的传递模式。WorksContent、PhotographersContent 和 PkgContent 三个组件接收一个 onGoto: (tab: number) => void 类型的回调函数,该函数的实现是将 this.activeTab 设置为传入的 tab 值。这种通过回调函数实现子组件向父组件通信的模式在 ArkTS 中非常常见——子组件不直接修改父组件的状态,而是通过调用父组件传入的回调函数来间接触发状态更新,保持了数据流的单向性。
contentArea 外层的 Column 设置了 layoutWeight(1),使其占据除头部和底部 Tab 栏之外的所有剩余垂直空间。这种"头部固定 + 内容弹性 + 底部固定"的布局结构是移动端应用的经典页面框架。
底部 Tab 项构建
@Builder tabItem(icon: string, label: string, tab: number) {
Column() {
Text(icon).fontSize(18).opacity(this.activeTab === tab ? 1.0 : 0.45)
Text(label).fontSize(9)
.fontColor(this.activeTab === tab ? CL.primary : CL.hint)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 1 })
if (this.activeTab === tab) {
Column().width(16).height(3)
.backgroundColor(CL.primary).borderRadius(2).margin({ top: 2 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 5, bottom: 5 })
.onClick(() => { this.activeTab = tab })
}
tabItem 构建函数接收三个参数:icon(图标字符串)、label(文字标签)和 tab(Tab 索引)。它渲染一个底部 Tab 项,包含图标和文字两行。通过三元运算符 this.activeTab === tab ? ... : ... 实现"选中"与"未选中"两种视觉状态:选中时图标完全不透明、文字使用主色且加粗;未选中时图标半透明(opacity(0.45))、文字使用提示色且常规字重。
opacity 属性控制组件的透明度,取值范围 0 到 1。值 0 表示完全透明(不可见但仍占据布局空间),值 1 表示完全不透明。在底部 Tab 栏中使用半透明效果而非改变颜色来区分选中状态,是一种温和的视觉处理方式——未选中的 Tab 项不会完全消失,而是以较低的存在感提示用户其可点击性。
选中状态下还会渲染一个 16vp 宽、3vp 高的主色指示条作为额外的高亮标记。这种"图标 + 文字 + 指示条"的三段式 Tab 设计在移动应用中非常普遍。layoutWeight(1) 使每个 Tab 项等分底部栏的宽度,六个 Tab 项各占 1/6 的宽度。
主组件 build 方法
build() {
Column() {
this.headerBar()
this.bannerRow()
this.capsRow()
this.contentArea()
Row() {
this.tabItem(FILM_TABS[0].icon, FILM_TABS[0].label, 0)
this.tabItem(FILM_TABS[1].icon, FILM_TABS[1].label, 1)
this.tabItem(FILM_TABS[2].icon, FILM_TABS[2].label, 2)
this.tabItem(FILM_TABS[3].icon, FILM_TABS[3].label, 3)
this.tabItem(FILM_TABS[4].icon, FILM_TABS[4].label, 4)
this.tabItem(FILM_TABS[5].icon, FILM_TABS[5].label, 5)
}
.width('100%')
.backgroundColor(CL.card)
.padding({ top: 4, bottom: 4 })
.shadow({ radius: 10, color: '#1A33281F', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor(CL.bg)
}
build() 方法是每个 @Component 必须实现的方法,它返回组件的 UI 结构。主组件的 build() 方法将之前定义的所有 @Builder 构建函数按顺序组合在一起:headerBar(品牌搜索区)、bannerRow(Banner 区)、capsRow(胶囊导航条)、contentArea(内容区,弹性填充剩余空间)和底部 Row(Tab 导航栏)。
底部 Row 通过六次调用 this.tabItem() 生成六个 Tab 项,每次传入 FILM_TABS 数组中对应的图标、文字和索引。shadow 属性为底部栏添加了阴影效果——radius: 10 设置模糊半径,color: '#1A33281F' 使用了带 Alpha 通道的八位十六进制颜色值(前两位 1A 是 Alpha 值,表示约 10% 的不透明度),offsetY: -2 使阴影向上偏移 2vp,营造出底部栏悬浮在内容之上的视觉效果。
最外层 Column 设置了 width('100%') 和 height('100%') 占满整个屏幕,背景色为 CL.bg。这种"全屏 Column 容器 + 头部固定 + 中间弹性 + 底部固定"的结构是移动端应用页面布局的标准范式。
七、Tab0 作品页面组件
@Component
struct WorksContent {
onGoto: (tab: number) => void = () => {}
@State showStyleFilter: boolean = false
@State showReview: boolean = false
@State selStyle: string = '复古胶片'
@State onlyHot: boolean = false
@State reviewStyle: string = '复古胶片'
@State reviewRating: number = 5
@State reviewText: string = ''
@State reviewAnon: boolean = false
@State leakOp: number = 0.3
@State leakScale: number = 1
WorksContent 是作品页面的组件声明。它不是 @Entry 组件,而是通过 @Component 声明为可被子组件引用的普通组件。第一行 onGoto: (tab: number) => void = () => {} 是一个函数类型的成员属性,初始值为一个空函数。这种写法在 ArkTS 中用于声明组件接收的外部参数——当父组件在构造时传入 onGoto 回调函数时,该属性的值会被替换为父组件传入的函数。
@State 装饰器声明了十个响应式状态变量,覆盖了该页面的所有交互状态:showStyleFilter 控制风格筛选弹框的显隐、showReview 控制评价提交弹框的显隐、selStyle 记录当前选中的筛选风格、onlyHot 控制是否只看高赞作品、reviewStyle/reviewRating/reviewText/reviewAnon 分别记录评价表单中的风格选择、评分、文字内容和匿名开关。最后两个变量 leakOp 和 leakScale 用于控制胶片漏光呼吸动画的透明度和缩放比例。
生命周期与动画初始化
aboutToAppear() {
this.getUIContext().animateTo({
duration: 2200,
iterations: -1,
playMode: PlayMode.Alternate,
curve: Curve.EaseInOut
}, () => {
this.leakOp = 0.9
this.leakScale = 1.18
})
}
aboutToAppear() 是 ArkUI 组件的生命周期回调函数,在组件创建后、build() 方法执行前被调用。它通常用于初始化数据、启动动画、发起网络请求等操作。
这里使用 this.getUIContext().animateTo() 启动了一个无限循环的呼吸动画。animateTo 是 ArkUI 提供的显式动画 API,它接受两个参数:动画配置对象和状态变更闭包。配置对象中:duration: 2200 设置动画持续时间为 2200 毫秒;iterations: -1 设置无限循环(-1 表示无限次);playMode: PlayMode.Alternate 设置交替播放模式——动画在正向和反向之间交替执行,形成"呼吸"效果;curve: Curve.EaseInOut 设置缓动曲线为先加速后减速。
在闭包中,this.leakOp 被设置为 0.9(目标透明度),this.leakScale 被设置为 1.18(目标缩放比例)。由于这两个变量是 @State 装饰的,框架会在动画过程中持续更新它们的值,并触发与之绑定的 UI 组件的属性更新。配合 PlayMode.Alternate 交替模式,透明度会在 0.3 到 0.9 之间往复变化,缩放比例会在 1.0 到 1.18 之间往复变化,形成类似胶片漏光的呼吸闪烁效果。
aboutToAppear是组件生命周期中最早可执行自定义逻辑的节点。与aboutToDisappear(组件销毁前调用)、onPageShow(页面显示时调用)、onPageHide(页面隐藏时调用)等生命周期回调共同构成了 ArkUI 组件的完整生命周期管理。在aboutToAppear中启动的动画需要在aboutToDisappear中停止,以避免组件销毁后动画仍在执行导致的内存泄漏。不过在本代码中,由于动画是通过@State变量驱动的,组件销毁后状态变量也会被回收,不会产生泄漏问题。
弹框遮罩与模态层
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(51,40,31,0.55)')
.onClick(onClose)
}
modalOverlay 是一个通用的弹框遮罩构建函数,接收一个 onClose 回调函数作为参数。它渲染一个全屏的 Column(没有子元素,仅作为色块),背景色使用了带 Alpha 通道的 rgba(51,40,31,0.55)——暗棕色加 55% 的不透明度,形成半透明遮罩效果。点击遮罩区域会调用 onClose 回调关闭弹框。
这种"遮罩 + 弹框内容"的分层结构是移动端模态弹框的标准实现模式。遮罩层的作用有两个:一是视觉上将用户的注意力聚焦到弹框内容上,通过半透明背景遮盖底下的页面内容;二是捕获遮罩区域的点击事件,实现"点击外部关闭弹框"的交互行为。zIndex(999) 确保弹框层在 Z 轴上位于所有页面内容之上。
风格筛选弹框
@Builder styleFilterOverlay() {
Column() {
this.modalOverlay(() => { this.showStyleFilter = false })
Column() {
Row() {
Text('🎛️ 作品风格筛选').fontSize(15).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(CL.hint)
.onClick(() => { this.showStyleFilter = false })
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
styleFilterOverlay 构建函数渲染风格筛选弹框。最外层 Column 包含两个子元素:modalOverlay 遮罩层和弹框内容 Column。弹框内容顶部是一个 Row 标题栏,包含标题文字、弹性填充空隙的空 Column(layoutWeight(1))和关闭按钮 Text('✕')。关闭按钮的 onClick 将 this.showStyleFilter 设置为 false,触发弹框消失。
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
ForEach(STYLE_DIST, (s: StyleDist, idx: number) => {
if (this.selStyle === s.name) {
Text(s.name + ' ' + s.pct + '%').fontSize(10).fontColor(CL.white).backgroundColor(s.color)
.padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(13)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.selStyle = s.name })
} else {
Text(s.name + ' ' + s.pct + '%').fontSize(10).fontColor(CL.sub).backgroundColor(CL.cream)
.padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(13)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.selStyle = s.name })
}
})
}
.width('100%')
风格选择区域使用了 Flex 弹性布局容器。Flex 比 Row 更灵活——它支持 wrap: FlexWrap.Wrap 换行属性,当子元素在一行中排不下时会自动换到下一行。direction: FlexDirection.Row 设置主轴方向为水平方向。
Flex 是 ArkUI 中功能最强大的布局容器之一。它结合了 Row 和 Column 的线性排列能力,同时支持换行、弹性伸缩和多种对齐模式。Flex 的构造参数包括 direction(主轴方向)、wrap(换行模式)、justifyContent(主轴对齐)和 alignItems(交叉轴对齐)。在本代码中,Flex 的 Wrap 模式使得风格标签可以自动换行排列,适应不同屏幕宽度。
ForEach 遍历 STYLE_DIST 数组生成五个风格标签。每个标签显示风格名称和占比百分比,通过 if-else 区分选中与未选中状态。选中的标签使用风格对应的颜色(s.color)作为背景和白色文字;未选中的使用奶油色背景和次要色文字。点击标签会更新 this.selStyle 状态变量。
Row() {
Text('只看 400+ 赞客片').fontSize(11).fontColor(CL.ink)
Column().layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.onlyHot })
.selectedColor(CL.primary)
.width(44)
.onChange((v: boolean) => { this.onlyHot = v })
}
.width('100%').padding({ left: 16, right: 16, bottom: 10 })
筛选弹框中还包含一个 Toggle 开关组件,用于控制"只看 400+ 赞作品"的过滤条件。Toggle 是 ArkUI 提供的开关组件,type: ToggleType.Switch 将其渲染为滑动开关样式。isOn: this.onlyHot 绑定状态变量,selectedColor 设置开启状态下的滑块颜色。onChange 回调在开关状态变化时更新 this.onlyHot 变量。
Row() {
Text('重置').fontSize(11).fontColor(CL.sub)
.padding({ left: 16, right: 16, top: 8, bottom: 8 }).borderRadius(14)
.backgroundColor(CL.cream)
.onClick(() => { this.selStyle = '复古胶片'; this.onlyHot = false })
Column().layoutWeight(1)
Text('完成筛选').fontSize(11).fontColor(CL.white)
.padding({ left: 18, right: 18, top: 8, bottom: 8 }).borderRadius(14)
.backgroundColor(CL.primary)
.onClick(() => { this.showStyleFilter = false })
}
.width('100%').padding({ left: 16, right: 16, bottom: 16 })
}
.width('82%')
.backgroundColor(CL.card).borderRadius(14)
.position({ x: '9%', y: '16%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
弹框底部是"重置"和"完成筛选"两个按钮。“重置"按钮将筛选条件恢复默认值(selStyle 设为"复古胶片”,onlyHot 设为 false),"完成筛选"按钮关闭弹框。弹框内容 Column 的宽度设为 '82%',通过 position({ x: '9%', y: '16%' }) 将其定位在屏幕中央偏上位置。position 属性使用绝对定位方式设置组件的位置坐标,x 和 y 分别是水平和垂直方向的偏移量。最外层 Column 设置 zIndex(999) 确保弹框在所有内容之上。
评价提交弹框
@Builder reviewOverlay() {
Column() {
this.modalOverlay(() => { this.showReview = false })
Column() {
Row() {
Text('🖋️ 提交客片评价').fontSize(15).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(CL.hint)
.onClick(() => { this.showReview = false })
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
Divider().color(CL.line)
reviewOverlay 构建函数渲染评价提交弹框。结构与风格筛选弹框类似,顶部是标题栏加关闭按钮,使用 Divider 分割线分隔标题区和表单区。Divider 是 ArkUI 提供的分割线组件,默认渲染一条水平方向的细线,可以通过 color、strokeWidth 等属性自定义样式。
Text('① 评价套系').fontSize(11).fontColor(CL.sub).fontWeight(FontWeight.Bold).width('100%').margin({ top: 10 })
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
ForEach(DEMAND_STYLES, (s: string) => {
if (this.reviewStyle === s) {
Text(s).fontSize(10).fontColor(CL.white).backgroundColor(CL.primary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.reviewStyle = s })
} else {
Text(s).fontSize(10).fontColor(CL.sub).backgroundColor(CL.cream)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.reviewStyle = s })
}
})
}
.width('100%').padding({ left: 16, right: 16 })
评价表单分为三个步骤。第一步"评价套系"使用 Flex 换行布局渲染八个风格标签供用户选择。DEMAND_STYLES 数组包含八种拍摄风格字符串,通过 ForEach 遍历生成标签,选中状态使用主色背景。
Text('② 综合评分').fontSize(11).fontColor(CL.sub).fontWeight(FontWeight.Bold).width('100%').margin({ top: 8 })
Row() {
ForEach([1, 2, 3, 4, 5], (n: number) => {
Text(n <= this.reviewRating ? '★' : '☆').fontSize(24)
.fontColor(n <= this.reviewRating ? CL.gold : CL.hint)
.margin({ right: 8 })
.onClick(() => { this.reviewRating = n })
})
}
.width('100%').padding({ left: 16, right: 16, top: 6 })
第二步"综合评分"是一个星级评分选择器。ForEach 遍历 [1, 2, 3, 4, 5] 数组生成五个星星字符。每个星星根据 n <= this.reviewRating 的条件判断显示实心星 ★ 还是空心星 ☆,颜色分别使用金色和提示色。点击星星会将 this.reviewRating 设置为对应的数值,触发所有星星的重新渲染——这种"一次点击,五个星星联动更新"的效果正是响应式状态驱动的体现。
Text('③ 评价内容').fontSize(11).fontColor(CL.sub).fontWeight(FontWeight.Bold).width('100%').margin({ top: 10 })
TextArea({ placeholder: '色调、妆造、出片速度……写下你的真实感受', text: this.reviewText })
.placeholderColor(CL.hint).placeholderFont({ size: 10 })
.fontSize(10).fontColor(CL.ink)
.width('88%').height(76)
.backgroundColor(CL.cream).borderRadius(10)
.margin({ top: 6 })
.onChange((v: string) => { this.reviewText = v })
第三步"评价内容"使用 TextArea 多行文本输入组件。TextArea 与 TextInput 的区别在于它支持多行文本输入,适合输入较长内容。这里设置了宽度 88%、高度 76vp,使用奶油色背景和圆角边框。onChange 回调将输入内容同步到 this.reviewText 状态变量。
Row() {
Text('匿名发布').fontSize(10).fontColor(CL.sub)
Column().layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.reviewAnon })
.selectedColor(CL.olive)
.width(44)
.onChange((v: boolean) => { this.reviewAnon = v })
}
.width('88%').padding({ top: 10 })
Text('发布评价 · 得 50 底片券').fontSize(11).fontColor(CL.white)
.padding({ left: 22, right: 22, top: 9, bottom: 9 }).borderRadius(16)
.backgroundColor(CL.primary).margin({ top: 12, bottom: 16 })
.onClick(() => { this.showReview = false })
表单底部是匿名发布开关和提交按钮。匿名开关使用橄榄绿作为开启色,与评价表单的整体色调保持一致。提交按钮"发布评价 · 得 50 底片券"使用主色背景和圆角设计,点击后关闭弹框。在实际应用中,这里还应该包含表单验证和数据提交逻辑。
作品页面主构建
build() {
Stack() {
Column() {
Row() {
Text('🎞️ 客片作品墙').fontSize(15).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Column().layoutWeight(1)
Text('筛选').fontSize(10).fontColor(CL.primary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.backgroundColor(CL.cream)
.onClick(() => { this.showStyleFilter = true })
Text('写评价').fontSize(10).fontColor(CL.white)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.backgroundColor(CL.primary).margin({ left: 6 })
.onClick(() => { this.showReview = true })
}
.width('100%').padding({ left: 14, right: 14, top: 10 })
build() 方法是组件的核心构建逻辑。作品页面使用 Stack 层叠布局作为根容器——Stack 允许子元素在 Z 轴方向上重叠,这对于实现"页面内容在下、弹框在上"的模态效果至关重要。当 showStyleFilter 或 showReview 为 true 时,弹框层会叠加在页面内容之上。
Stack 是 ArkUI 中的层叠布局容器。它的子元素默认居中对齐堆叠,可以通过 alignContent 属性设置对齐方式(如 Alignment.Center、Alignment.Bottom、Alignment.TopStart 等)。后声明的子元素覆盖在先声明的子元素之上。在本代码中,页面内容 Column 先声明(位于底层),弹框 Column 后声明(位于上层),通过条件渲染控制弹框的显隐。
页面顶部是标题栏,包含"客片作品墙"标题、"筛选"按钮和"写评价"按钮。两个按钮的 onClick 分别将 showStyleFilter 和 showReview 设置为 true,触发对应弹框的显示。
Scroll() {
Column() {
Stack() {
Row() {
Text('🎞️ PORTRA 400 · 2026 客片精选第 38 卷').fontSize(9).fontColor(CL.cream)
}
.width('100%').height(22).justifyContent(FlexAlign.Center)
.backgroundColor(CL.brown)
Column()
.width(70).height(70).borderRadius(35)
.backgroundColor(CL.gold)
.opacity(this.leakOp)
.scale({ x: this.leakScale, y: this.leakScale })
.position({ x: '82%', y: -24 })
.blur(18)
}
.width('100%').height(22)
.margin({ left: 14, right: 14, top: 8 })
.borderRadius(4)
可滚动内容区的第一个元素是胶片横条装饰,使用 Stack 层叠布局实现。底层是一个暗棕色 Row 容器,显示胶卷型号文字;上层是一个金色圆形色块,通过 opacity(this.leakOp) 和 scale({ x: this.leakScale, y: this.leakScale }) 绑定到动画状态变量,实现呼吸闪烁的漏光效果。blur(18) 属性为色块添加了 18vp 的高斯模糊,使其呈现出柔和的光晕效果而非硬边色块。position({ x: '82%', y: -24 }) 将光晕定位到横条右侧偏上方,部分溢出横条边界,模拟胶片漏光的视觉效果。
blur 是 ArkUI 中的背景模糊属性,它对组件本身或其背景区域应用高斯模糊效果。模糊半径越大,效果越柔和。在胶片漏光效果中使用 blur 是非常合适的选择——漏光在真实胶片上就是柔和的光晕,而非清晰的色块边界。
环形占比图表
Column() {
Row() {
Text('🍩 风格类型分布').fontSize(13).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Column().layoutWeight(1)
Text('2026 · 平台大盘').fontSize(9).fontColor(CL.hint)
}.width('100%')
Row() {
Column() {
Row() {
Stack({ alignContent: Alignment.Center }) {
Progress({ value: STYLE_DIST[0].pct, total: 100, type: ProgressType.Ring })
.width(64).height(64)
.color(STYLE_DIST[0].color).backgroundColor(CL.cream)
.style({ strokeWidth: 9 })
Progress({ value: STYLE_DIST[1].pct, total: 100, type: ProgressType.Ring })
.width(96).height(96)
.color(STYLE_DIST[1].color).backgroundColor(Color.Transparent)
.style({ strokeWidth: 9 })
Progress({ value: STYLE_DIST[2].pct, total: 100, type: ProgressType.Ring })
.width(128).height(128)
.color(STYLE_DIST[2].color).backgroundColor(Color.Transparent)
.style({ strokeWidth: 9 })
Column() {
Text('18 张风格').fontSize(11).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Text('本月上新').fontSize(8).fontColor(CL.hint).margin({ top: 2 })
}
}
.width(128).height(128)
风格类型分布图表使用了三个嵌套的 Progress 环形进度条组件来实现多层环形图表效果。Progress 是 ArkUI 提供的进度展示组件,type: ProgressType.Ring 将其渲染为环形样式。三个环形进度条的尺寸从内到外依次递增(64vp、96vp、128vp),通过 Stack 层叠布局同心排列,每个环使用不同的颜色和百分比值。
Stack({ alignContent: Alignment.Center }) 的构造参数 alignContent 设置子元素的默认对齐方式为居中。这使得三个不同尺寸的环形进度条能够同心对齐——最小的环在中心,最大的环在外围。环与环之间的间隙通过设置中间两个环的 backgroundColor(Color.Transparent) 为透明来实现的,只有最内层的环使用奶油色背景填充。
中心位置叠加了一个 Column 显示"18 张风格"和"本月上新"文字,通过 Stack 的层叠特性放置在环形图表的正中央。这种"同心环 + 中心文字"的数据可视化设计在移动端应用中非常实用,它能在有限的屏幕空间内同时展示多个维度的占比数据。
Column() {
ForEach(STYLE_DIST, (s: StyleDist) => {
Row() {
Column().width(9).height(9).backgroundColor(s.color).borderRadius(3)
Text(s.name).fontSize(10).fontColor(CL.ink).margin({ left: 6 })
Column().layoutWeight(1)
Text(s.pct + '%').fontSize(10).fontColor(CL.sub).fontWeight(FontWeight.Bold)
}
.width(120).padding({ top: 5, bottom: 5 })
})
}
.margin({ left: 14 })
环形图表右侧是图例列表,通过 ForEach 遍历 STYLE_DIST 数组生成五行图例项。每行包含一个 9vp 的色块(使用对应风格的颜色)、风格名称和百分比值。Column().layoutWeight(1) 作为弹性填充元素将名称和百分比分隔到两端。
三列网格墙
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(WORK_LIST, (w: WorkItem, idx: number) => {
Column() {
Stack() {
Column().width('100%').height(92)
.backgroundColor(w.color).borderRadius(8)
Text('🎞️').fontSize(16)
.position({ x: '76%', y: 6 })
Text(w.tag).fontSize(7).fontColor(CL.white)
.backgroundColor('rgba(51,40,31,0.55)')
.padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6)
.position({ x: 6, y: 6 })
}
.width('100%').height(92)
Text(w.title).fontSize(10).fontWeight(FontWeight.Bold).fontColor(CL.ink)
.margin({ top: 4 }).maxLines(1)
Text(w.style + ' · ' + w.author).fontSize(8).fontColor(CL.sub).margin({ top: 2 }).maxLines(1)
Row() {
Text('❤ ' + w.likes).fontSize(8).fontColor(CL.primary)
Column().layoutWeight(1)
Text(w.shots + ' 张').fontSize(8).fontColor(CL.hint)
}.width('100%').margin({ top: 3 })
}
.width('31%')
.padding({ top: 6, bottom: 6 })
.onClick(() => { this.reviewStyle = w.style; this.showReview = true })
})
}
.width('100%').padding({ left: 12, right: 12 })
三列网格墙使用 Flex 弹性布局实现。justifyContent: FlexAlign.SpaceBetween 使每行的三个作品卡片均匀分布,两端对齐。每个卡片宽度设为 '31%',三个卡片加上两个间距约占满一行宽度,超出后自动换行。
每个作品卡片是一个 Column 容器,顶部是 Stack 层叠的色块区域(模拟图片缩略图),使用作品的 color 值作为背景色。色块上叠加了胶片图标(右上角)和标签角标(左上角),通过 position 绝对定位。色块下方是作品标题、风格与作者、点赞数和底片张数。maxLines(1) 限制文本最多显示一行,超出部分省略。
点击作品卡片会将 reviewStyle 设置为该作品的风格并打开评价弹框——这是一种便捷的用户流程设计,用户在浏览作品时可以直接对对应风格进行评价。
热门客片榜
Column() {
Row() {
Text('🔥 热门客片榜(400+ 赞)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Column().layoutWeight(1)
Text('按点赞').fontSize(9).fontColor(CL.hint)
}.width('100%')
ForEach(hotWorks(), (w: WorkItem, idx: number) => {
Row() {
Text((idx + 1).toString()).fontSize(11).fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? CL.primary : CL.hint).width(20)
Column().width(34).height(34).borderRadius(6).backgroundColor(w.color)
Column() {
Text(w.title + ' · ' + w.author).fontSize(10).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Row() {
Column().height(5).borderRadius(3).backgroundColor(CL.primary)
.width(likesW(w.likes))
Text(w.likes + ' 赞').fontSize(8).fontColor(CL.sub).margin({ left: 6 })
}.margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 8 })
Text('去评价 ›').fontSize(9).fontColor(CL.primary)
.onClick(() => { this.reviewStyle = w.style; this.showReview = true })
}
.width('100%').padding({ top: 8, bottom: 8 })
})
}
热门客片榜通过 ForEach 遍历 hotWorks() 纯函数的返回值——该函数从 WORK_LIST 中筛选出点赞数大于等于 400 的作品并按降序排列。每行包含排名序号、色块缩略图、作品信息(标题 + 作者 + 进度条 + 点赞数)和"去评价"链接。前三名的序号使用主色,其余使用提示色,形成视觉重点。
进度条使用 Column().height(5).width(likesW(w.likes)) 实现——likesW 函数将点赞数转换为百分比宽度字符串。这是一个利用 ArkUI 组件属性来实现简易数据可视化的典型例子:通过设置组件的宽度为百分比字符串,可以创建水平条形图效果。
弹框条件渲染
.width('100%').height('100%')
if (this.showStyleFilter) {
this.styleFilterOverlay()
}
if (this.showReview) {
this.reviewOverlay()
}
}
.width('100%').height('100%')
}
}
在 Stack 根容器中,页面内容 Column 之后是两个条件渲染的弹框。if (this.showStyleFilter) 和 if (this.showReview) 分别控制两个弹框的显隐。当条件为 true 时,对应的 @Builder 构建函数被调用,弹框 UI 被渲染到 Stack 的上层;当条件变为 false 时,弹框 UI 从渲染树中移除。
这种通过 if 条件渲染控制弹框显隐的方式是 ArkUI 中的标准做法。与始终渲染弹框但通过 visibility 属性控制显隐的方式相比,条件渲染在弹框不可见时不会创建任何组件实例,节省了内存和渲染开销。但代价是每次显示弹框时需要重新创建组件实例——对于频繁切换的弹框,visibility 方式可能更高效;对于偶尔显示的弹框,条件渲染更合适。
八、Tab1 摄影师页面组件
@Component
struct PhotographersContent {
onGoto: (tab: number) => void = () => {}
@State showDetail: boolean = false
@State selIdx: number = 0
@State sortMode: number = 0
@State focusOp: number = 0.35
@State focusScale: number = 1
PhotographersContent 组件管理摄影师列表页面的状态。showDetail 控制摄影师详情抽屉的显隐,selIdx 记录当前选中的摄影师索引,sortMode 记录排序模式(0=按单量、1=按评分、2=按价格)。focusOp 和 focusScale 用于取景框对焦呼吸动画。
aboutToAppear() {
this.getUIContext().animateTo({
duration: 1600,
iterations: -1,
playMode: PlayMode.Alternate,
curve: Curve.EaseInOut
}, () => {
this.focusOp = 1
this.focusScale = 1.1
})
}
摄影师页面的动画效果是"取景框对焦呼吸"——模拟相机取景框对焦时框线闪烁和缩放的效果。动画持续时间 1600 毫秒,比作品页面的漏光动画(2200 毫秒)更快,营造出相机自动对焦的节奏感。focusOp 在 0.35 到 1 之间变化,focusScale 在 1 到 1.1 之间变化,变化幅度较小,模拟取景框轻微缩放的对焦过程。
取景框头部
build() {
Stack() {
Column() {
Stack() {
Column().width('100%').height(88)
.linearGradient({ direction: GradientDirection.Right, colors: [[CL.brown, 0], [CL.primary, 1]] })
.borderRadius(14)
Column()
.width(56).height(56).border({ width: 1.5, color: CL.gold })
.borderRadius(6)
.opacity(this.focusOp)
.scale({ x: this.focusScale, y: this.focusScale })
.position({ x: '80%', y: 16 })
Column() {
Text('📷 同城摄影师').fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.white)
Text('12 位签约 · 金牌胶片师在线接单').fontSize(9).fontColor('#EFE6D3').margin({ top: 6 })
Text('大师代表作 · 客片直选 · 档期透明').fontSize(8).fontColor(CL.gold)
}
.width('100%').padding({ top: 12 }).alignItems(HorizontalAlign.Center)
}
.width('100%').height(88).margin({ left: 12, right: 12, top: 10 })
摄影师页面的头部使用了 Stack 层叠布局。底层是一个渐变背景 Column(从暗棕到主色),中间层是一个 56vp 的金色对焦框(通过 border 设置 1.5vp 金色边框,borderRadius(6) 设置圆角),上层是标题文字。对焦框通过 opacity(this.focusOp) 和 scale({ x: this.focusScale, y: this.focusScale }) 绑定到动画状态变量,形成闪烁缩放的对焦效果。
border 是 ArkUI 中设置边框的属性,它接受一个对象参数,包含 width(边框宽度)、color(边框颜色)和 radius(圆角半径)等子属性。也可以通过 borderRadius 单独设置圆角,通过 borderWidth、borderColor 分别设置宽度和颜色。在本代码中,对焦框使用 border({ width: 1.5, color: CL.gold }) 设置了 1.5vp 宽的金色边框,再通过 borderRadius(6) 设置了 6vp 的圆角,形成了取景框的经典矩形角标造型。
排序条
Row() {
if (this.sortMode === 0) {
Text('按单量').fontSize(10).fontColor(CL.white).backgroundColor(CL.primary)
.padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(12)
.margin({ right: 6 }).onClick(() => { this.sortMode = 0 })
} else {
Text('按单量').fontSize(10).fontColor(CL.sub).backgroundColor(CL.card)
.padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(12)
.margin({ right: 6 }).onClick(() => { this.sortMode = 0 })
}
排序条使用三个 if-else 条件块分别渲染"按单量"、“按评分”、"按价格"三个排序按钮。每个按钮根据 this.sortMode 的值判断是否为当前选中的排序模式,选中状态使用主色背景和白色文字,未选中状态使用卡片色背景和次要色文字。点击按钮会更新 this.sortMode,触发列表重新排序渲染。
这种通过 sortMode 数字变量控制排序模式的设计非常简洁。在列表渲染时,根据 sortMode 的值调用不同的排序纯函数:sortMode === 0 时调用 photoByOrders(),sortMode === 1 时调用 photoByRating(),sortMode === 2 时调用 photoByPrice()。由于这些排序函数是纯函数,每次调用都会返回新的排序数组,框架会自动进行列表的差分更新。
大师卡片列表
Scroll() {
Column() {
if (this.sortMode === 0) {
ForEach(photoByOrders(), (p: PhotographerItem) => {
Column() {
Row() {
Stack() {
Column().width(52).height(52).borderRadius(26).backgroundColor(p.color)
Text(p.icon).fontSize(24)
}
.width(52).height(52)
摄影师列表的核心区域使用 Scroll 滚动容器包裹 Column,内部通过 if-else if-else 条件链根据 sortMode 渲染不同排序的列表。每个摄影师卡片是一个 Column 容器,顶部是 Row 水平布局包含头像和基本信息。
头像使用 Stack 层叠布局实现——底层是一个 52vp 的圆形色块(borderRadius(26) 实现圆形),上层是 Emoji 图标文字。Stack 的层叠特性使得图标自动居中显示在色块之上,无需手动计算偏移量。这种"色块 + 图标"的头像设计在没有真实头像图片的情况下是一种常见的占位方案。
Column() {
Row() {
Text(p.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Text(p.level).fontSize(7).fontColor(CL.white).backgroundColor(tierColor(p.level === '新锐摄影师' ? '轻享' : '经典'))
.borderRadius(6).padding({ left: 5, right: 5, top: 1, bottom: 1 }).margin({ left: 6 })
}
Text(p.city + ' · 擅长 ' + p.style).fontSize(9).fontColor(CL.sub).margin({ top: 3 })
Row() {
Text(starText(p.rating)).fontSize(9).fontColor(CL.gold)
Text(p.orders + ' 单').fontSize(8).fontColor(CL.hint).margin({ left: 8 })
Text('从业 ' + p.years + ' 年').fontSize(8).fontColor(CL.hint).margin({ left: 8 })
}.margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text('¥' + p.price).fontSize(14).fontWeight(FontWeight.Bold).fontColor(CL.primary)
Text('/场 起').fontSize(8).fontColor(CL.hint).margin({ top: 2 })
}.alignItems(HorizontalAlign.End)
}
.width('100%')
摄影师信息区域使用 Column 垂直布局,包含三行信息:第一行是姓名和等级标签(等级标签的颜色通过 tierColor 函数动态计算),第二行是城市和擅长风格,第三行是星级评分(通过 starText 函数生成星星字符串)、订单量和从业年限。右侧是价格信息,使用 layoutWeight(1) 的空 Column 将价格推到行末。alignItems(HorizontalAlign.End) 使价格右对齐。
Row() {
Text('看详情').fontSize(10).fontColor(CL.brown)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 7, bottom: 7 }).borderRadius(14).backgroundColor(CL.cream)
.onClick(() => {
this.selIdx = p.id - 1
this.showDetail = true
})
Text('看档期').fontSize(10).fontColor(CL.olive)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 7, bottom: 7 }).borderRadius(14).backgroundColor('#EDF0E3')
.margin({ left: 8 })
.onClick(() => { this.onGoto(2) })
Text('约拍').fontSize(10).fontColor(CL.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 7, bottom: 7 }).borderRadius(14).backgroundColor(CL.primary)
.margin({ left: 8 })
.onClick(() => { this.onGoto(4) })
}
.width('100%').margin({ top: 10 })
每个摄影师卡片底部有三个操作按钮:“看详情”、“看档期"和"约拍”。三个按钮使用 layoutWeight(1) 等分宽度,textAlign(TextAlign.Center) 使文字居中。textAlign 是 Text 组件的文字对齐属性,可选值包括 TextAlign.Start(左对齐)、TextAlign.Center(居中对齐)和 TextAlign.End(右对齐)。
"看详情"按钮的点击事件先将 this.selIdx 设置为当前摄影师的索引(p.id - 1,因为 id 从 1 开始而数组索引从 0 开始),然后将 this.showDetail 设置为 true 打开详情抽屉。"看档期"和"约拍"按钮通过 this.onGoto() 回调跳转到对应的 Tab 页面——这正是父组件传入的 onGoto 函数的用途。
摄影师详情抽屉
@Builder photoDetailOverlay() {
Column() {
this.modalOverlay(() => { this.showDetail = false })
Column() {
Row() {
Column().width(38).height(4).borderRadius(2).backgroundColor(CL.line)
}
.width('100%').justifyContent(FlexAlign.Center).padding({ top: 8, bottom: 4 })
摄影师详情抽屉使用底部滑出式设计。顶部有一个 38vp 宽、4vp 高的拖拽指示条(圆角色块),这是移动端底部弹框的标准设计元素,提示用户可以下滑关闭。justifyContent(FlexAlign.Center) 使指示条在行中居中。
Scroll() {
Column() {
Row() {
Column() {
Text(PHOTOGRAPHER_LIST[this.selIdx].rating.toFixed(1)).fontSize(17).fontWeight(FontWeight.Bold).fontColor(CL.primary)
Text('评分').fontSize(8).fontColor(CL.hint).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text(PHOTOGRAPHER_LIST[this.selIdx].orders.toString()).fontSize(17).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Text('累计单量').fontSize(8).fontColor(CL.hint).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text('¥' + PHOTOGRAPHER_LIST[this.selIdx].price).fontSize(17).fontWeight(FontWeight.Bold).fontColor(CL.gold)
Text('起拍价/场').fontSize(8).fontColor(CL.hint).margin({ top: 2 })
}.layoutWeight(1)
}
.width('100%').padding({ top: 12, bottom: 12 })
详情抽屉的内容区使用 Scroll 滚动容器包裹,因为内容可能超出抽屉高度。数据展示区使用三个等宽 Column(各 layoutWeight(1))分别展示评分、累计单量和起拍价。toFixed(1) 是 JavaScript/TypeScript 的数字格式化方法,将数字保留一位小数。toString() 将数字转为字符串。
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(WORK_LIST, (w: WorkItem) => {
if (w.author === PHOTOGRAPHER_LIST[this.selIdx].name) {
Column() {
Column().width('100%').height(74).borderRadius(8).backgroundColor(w.color)
Text(w.title).fontSize(8).fontColor(CL.ink).margin({ top: 3 }).maxLines(1)
}
.width('31%').margin({ top: 6 })
}
})
}
.width('100%')
代表作展示区使用 ForEach 遍历 WORK_LIST 全部作品数组,通过 if (w.author === PHOTOGRAPHER_LIST[this.selIdx].name) 条件筛选出当前摄影师的作品。这种"遍历全部数据 + 条件筛选"的方式在没有数据库查询的场景下是一种实用的数据过滤方法。每个代表作使用 31% 宽度的色块加标题展示,三列排列。
九、Tab2 档期页面组件
@Component
struct ScheduleContent {
@State showPick: boolean = false
@State selDay: number = 4
@State selSlot: number = 4
@State selPhotoIdx: number = 0
@State shutterRot: number = 0
aboutToAppear() {
this.getUIContext().animateTo({
duration: 3000,
iterations: -1,
playMode: PlayMode.Alternate,
curve: Curve.Linear
}, () => {
this.shutterRot = 120
})
}
ScheduleContent 组件管理档期日历页面。showPick 控制档期选择弹框的显隐,selDay 记录选中的日期,selSlot 记录选中的时段索引,selPhotoIdx 记录选中的摄影师索引。shutterRot 用于快门光圈旋转动画。
快门旋转动画使用 Curve.Linear 线性缓动曲线(匀速运动),持续时间 3000 毫秒。shutterRot 在 0 到 120 度之间往复变化,模拟相机快门光圈叶片的旋转效果。PlayMode.Alternate 使旋转在正向和反向之间交替,形成来回摆动的视觉效果。
月历头与状态图例
build() {
Stack() {
Column() {
Stack() {
Column().width('100%').height(76)
.linearGradient({ direction: GradientDirection.Right, colors: [[CL.primary, 0], ['#A66A4C', 1]] })
.borderRadius(14)
Text('⚙️').fontSize(22)
.rotate({ angle: this.shutterRot })
.position({ x: '84%', y: 14 })
Column() {
Text('📅 2026 年 9 月 档期').fontSize(15).fontWeight(FontWeight.Bold).fontColor(CL.white)
Text('可约 17 天 · 已满 8 天 · 休整 3 天').fontSize(9).fontColor('#EFE6D3').margin({ top: 5 })
}
.width('100%').padding({ top: 10 }).alignItems(HorizontalAlign.Center)
}
.width('100%').height(76).margin({ left: 12, right: 12, top: 10 })
月历头部使用 Stack 层叠布局,渐变背景上叠加了一个旋转的齿轮图标 Text('⚙️')。rotate({ angle: this.shutterRot }) 属性将组件绕中心点旋转指定角度。angle 参数的单位是度,正值表示顺时针旋转。由于 shutterRot 绑定到动画状态变量,齿轮会持续旋转摆动。
rotate 是 ArkUI 中的旋转变换属性,它接受一个对象参数,包含 angle(旋转角度)、centerX 和 centerY(旋转中心点坐标)。默认情况下,旋转中心为组件的中心点。除了 rotate,ArkUI 还提供了 scale(缩放变换)、translate(平移变换)和 matrix(自定义矩阵变换)等变换属性,可以组合使用实现复杂的动画效果。
Scroll() {
Column() {
Row() {
Row() {
Column().width(10).height(10).borderRadius(3).backgroundColor(CL.card)
.border({ width: 1, color: CL.line })
Text('可约').fontSize(9).fontColor(CL.sub).margin({ left: 4 })
}
Row() {
Column().width(10).height(10).borderRadius(3).backgroundColor(CL.cream)
Text('已满').fontSize(9).fontColor(CL.sub).margin({ left: 4 })
}.margin({ left: 12 })
Row() {
Column().width(10).height(10).borderRadius(3).backgroundColor(CL.primary)
Text('拍摄中').fontSize(9).fontColor(CL.sub).margin({ left: 4 })
}.margin({ left: 12 })
Row() {
Column().width(10).height(10).borderRadius(3).backgroundColor(CL.bg)
.border({ width: 1, color: CL.line })
Text('休整').fontSize(9).fontColor(CL.sub).margin({ left: 4 })
}.margin({ left: 12 })
Column().layoutWeight(1)
Text('共 30 天').fontSize(9).fontColor(CL.hint)
}
.width('100%').padding({ left: 14, right: 14, top: 12 })
状态图例区域使用嵌套的 Row 容器展示四种日历格状态的颜色对照。每个图例项包含一个 10vp 的色块和状态文字,图例项之间通过 margin({ left: 12 }) 设置 12vp 的间距。"可约"和"休整"状态的色块使用边框而非填充色来区分——"可约"使用卡片色填充加浅色边框,"休整"使用背景色填充加浅色边框,这种细微的视觉差异需要用户仔细辨认,但在实际使用中通过文字标签辅助识别。
日历格渲染
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
ForEach(DAY_LIST, (d: DayCell) => {
Column() {
Text(d.day.toString()).fontSize(12).fontWeight(FontWeight.Bold)
.fontColor(dayColor(d.status))
if (d.status === '可约') {
Text(d.slots + ' 档').fontSize(7).fontColor(CL.olive).margin({ top: 2 })
} else if (d.status === '拍摄中') {
Text('进行').fontSize(7).fontColor(CL.cream).margin({ top: 2 })
} else {
Text(d.status).fontSize(7).fontColor(dayColor(d.status)).margin({ top: 2 })
}
}
.width('13%')
.padding({ top: 7, bottom: 7 })
.backgroundColor(dayBg(d.status))
.borderRadius(8)
.margin({ top: 4 })
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.selDay = d.day
this.showPick = true
})
})
}
.width('100%')
日历格使用 Flex 弹性布局容器,wrap: FlexWrap.Wrap 允许日历格自动换行。每个日历格宽度设为 '13%',七个格子约占 91% 的宽度(7 × 13% = 91%),加上间距后正好排满一行,自动换行后形成标准的七列日历布局。
每个日历格使用 Column 垂直布局,包含日期数字和状态文字。日期数字的颜色通过 dayColor(d.status) 纯函数动态计算,背景色通过 dayBg(d.status) 纯函数动态计算。这种"数据驱动样式"的方式使得同一个日历格组件根据不同的状态值呈现出完全不同的视觉效果。点击任意日历格会将 selDay 设置为该日期并打开档期选择弹框。
Flex的wrap: FlexWrap.Wrap属性在日历格布局中发挥着关键作用。如果使用Row容器,三十个日历格会挤在一行中无法换行;而Flex的换行能力使得日历格在排满一行后自动换到下一行,形成自然的七列日历布局。Flex在 ArkUI 中是处理不确定数量子元素自动排列的首选布局容器。
月度拍摄量柱状图
Stack({ alignContent: Alignment.Bottom }) {
Row() {
ForEach(MONTH_SHOOT, (m: MonthShoot, idx: number) => {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Column()
.width('100%').height(travelBarH(m.travel))
.backgroundColor(CL.olive)
.borderRadius({ topLeft: 3, topRight: 3 })
Column()
.width('100%').height(studioBarH(m.studio))
.backgroundColor(idx % 2 === 0 ? CL.primary : '#A66A4C')
.borderRadius({ topLeft: 3, topRight: 3 })
.position({ x: 0, y: travelBarH(m.travel) })
}
.width(14).height(72)
Text(m.month).fontSize(7).fontColor(CL.hint).margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%').alignItems(VerticalAlign.Bottom)
}
.width('100%').height(80)
.margin({ top: 10 })
月度拍摄量柱状图是本应用中最复杂的数据可视化组件之一。它使用嵌套的 Stack 布局实现堆叠柱状图效果。外层 Stack({ alignContent: Alignment.Bottom }) 使子元素在底部对齐;内层每个月份的 Stack({ alignContent: Alignment.Bottom }) 中包含两个 Column 色块——底层是旅拍数量(橄榄绿),上层是写真数量(主色或暖棕色交替),通过 position({ x: 0, y: travelBarH(m.travel) }) 将写真色块定位在旅拍色块上方,形成堆叠效果。
柱状图的高度通过 studioBarH() 和 travelBarH() 纯函数动态计算——这两个函数将拍摄数量除以全年最大值再乘以 68,得到以 vp 为单位的高度字符串。borderRadius({ topLeft: 3, topRight: 3 }) 只设置顶部两个角的圆角,模拟柱状图的标准样式。idx % 2 === 0 使偶数月和奇数月的写真色块使用不同的颜色交替,增加视觉层次感。
borderRadius 属性支持多种设置方式:可以传入数字统一设置四个角的圆角(如 borderRadius(8)),也可以传入对象分别设置每个角的圆角(如 borderRadius({ topLeft: 3, topRight: 3, bottomLeft: 0, bottomRight: 0 }))。在柱状图中,只设置顶部圆角使得柱子顶部呈圆角而底部保持直角,更符合柱状图的视觉惯例。
档期选择弹框
@Builder schedulePickOverlay() {
Column() {
this.modalOverlay(() => { this.showPick = false })
Column() {
Row() {
Text('📅 ' + DAY_LIST[this.selDay - 1].day + ' 日 · ' + dayWeek(DAY_LIST[this.selDay - 1].day) + ' 档期选择')
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(CL.hint)
.onClick(() => { this.showPick = false })
}
档期选择弹框根据选中的日期动态生成标题,使用 dayWeek() 纯函数计算星期几。弹框内容包含三个步骤:选择摄影师、选择时段和定金说明。
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
ForEach(PHOTOGRAPHER_LIST, (p: PhotographerItem, idx: number) => {
if (idx < 6) {
if (this.selPhotoIdx === idx) {
Text(p.name).fontSize(10).fontColor(CL.white).backgroundColor(p.color)
.padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(13)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.selPhotoIdx = idx })
} else {
Text(p.name).fontSize(10).fontColor(CL.sub).backgroundColor(CL.cream)
.padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(13)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.selPhotoIdx = idx })
}
}
})
}
摄影师选择区使用 ForEach 遍历 PHOTOGRAPHER_LIST 数组,通过 if (idx < 6) 条件限制只显示前六位摄影师。选中的摄影师使用其个人颜色作为背景,未选中的使用奶油色背景。这种使用摄影师个人颜色的高亮方式比统一使用主色更有辨识度。
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(SLOT_LIST, (s: SlotItem, idx: number) => {
Column() {
Text(s.time).fontSize(9).fontWeight(FontWeight.Bold)
.fontColor(this.selSlot === idx ? CL.white : CL.ink)
Text(s.state).fontSize(7)
.fontColor(this.selSlot === idx ? CL.cream : CL.hint)
.margin({ top: 2 })
}
.width('23%')
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.selSlot === idx ? CL.primary : CL.cream)
.borderRadius(10).margin({ top: 6 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.selSlot = idx })
})
}
时段选择区使用 Flex 布局,justifyContent: FlexAlign.SpaceBetween 使四个时段宫格在一行中等间距排列。每个时段宫格宽度 23%,显示时间段和状态描述(如"黄金晨光"、"落日黄金"等)。选中状态使用主色背景,通过 this.selSlot === idx 条判断。
十、Tab3 套餐页面组件
@Component
struct PkgContent {
onGoto: (tab: number) => void = () => {}
@State showCompare: boolean = false
@State selPkgIdx: number = 2
@State shineOp: number = 0.25
aboutToAppear() {
this.getUIContext().animateTo({
duration: 1800,
iterations: -1,
playMode: PlayMode.Alternate,
curve: Curve.EaseInOut
}, () => {
this.shineOp = 0.85
})
}
PkgContent 组件管理套餐页面。showCompare 控制套餐对比弹框的显隐,selPkgIdx 记录选中的套餐索引(默认为 2,即"经典"档的第三档套餐)。shineOp 用于金卡高光闪动动画,模拟金属质感的光泽流动效果。
三档价格速览
Row() {
Column() {
Text('轻享 LEAN').fontSize(11).fontWeight(FontWeight.Bold).fontColor(CL.olive)
Text('699-999').fontSize(15).fontWeight(FontWeight.Bold).fontColor(CL.olive).margin({ top: 4 })
Text('1-2 小时 · 单套系').fontSize(8).fontColor(CL.sub).margin({ top: 4 })
Text('2 款在售').fontSize(8).fontColor(CL.hint).margin({ top: 2 })
}
.layoutWeight(1).height(84)
.backgroundColor(CL.card).borderRadius(12)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
.onClick(() => { this.showCompare = true })
三档价格速览区使用 Row 水平布局,三个等宽 Column 分别展示轻享、经典和尊享三个档位的价格范围和基本信息。"轻享"档使用橄榄绿色调,传达入门级产品的亲民感。
Stack() {
Column() {
Text('经典 CLASSIC').fontSize(11).fontWeight(FontWeight.Bold).fontColor(CL.white)
Text('1688-2588').fontSize(15).fontWeight(FontWeight.Bold).fontColor(CL.white).margin({ top: 4 })
Text('3-6 小时 · 多套系').fontSize(8).fontColor(CL.cream).margin({ top: 4 })
Text('4 款在售 · 销量王').fontSize(8).fontColor(CL.cream).margin({ top: 2 })
}
.width('100%').height(84)
.linearGradient({ direction: GradientDirection.Right, colors: [[CL.primary, 0], ['#A66A4C', 1]] })
.borderRadius(12)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column()
.width(50).height(50).borderRadius(25)
.backgroundColor(CL.gold)
.opacity(this.shineOp)
.position({ x: '68%', y: -10 })
.blur(14)
}
.layoutWeight(1).height(84)
.margin({ left: 8, right: 8 })
.onClick(() => { this.showCompare = true })
"经典"档使用 Stack 层叠布局,渐变背景上叠加了一个金色光晕色块。光晕通过 opacity(this.shineOp) 绑定到动画状态变量,配合 blur(14) 高斯模糊,形成金属高光闪动的效果。position({ x: '68%', y: -10 }) 将光晕定位到卡片右上角偏上方,部分溢出卡片边界,模拟光源照射的效果。
套餐价格分层卡片
ForEach(PKG_LIST, (p: PkgItem, idx: number) => {
Column() {
Row() {
Text(p.tier).fontSize(9).fontColor(CL.white)
.backgroundColor(tierColor(p.tier)).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text(p.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(CL.ink)
.margin({ left: 8 }).layoutWeight(1).maxLines(1)
Text(p.tag).fontSize(8).fontColor(CL.gold)
.border({ width: 1, color: CL.gold }).borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}.width('100%')
Row() {
Text('¥' + p.price).fontSize(19).fontWeight(FontWeight.Bold).fontColor(tierColor(p.tier))
Text('¥' + p.origPrice).fontSize(10).fontColor(CL.hint)
.decoration({ type: TextDecorationType.LineThrough })
.margin({ left: 6 })
Column().layoutWeight(1)
Text('已省 ' + savingPct(p)).fontSize(9).fontColor(CL.danger)
}.width('100%').margin({ top: 8 })
套餐卡片列表使用 ForEach 遍历 PKG_LIST 数组生成九个套餐卡片。每个卡片包含档位标签(颜色通过 tierColor 函数动态计算)、套餐名称和分类标签。价格区域展示现价(大字号、档位色)、原价(小字号、删除线)和节省百分比。
decoration({ type: TextDecorationType.LineThrough }) 是 Text 组件的文字装饰属性,TextDecorationType.LineThrough 在文字上添加删除线效果。这是电商应用中展示原价的标配样式——通过删除线视觉地传达"原价已作废"的信息。TextDecorationType 枚举还包含 None(无装饰)、Underline(下划线)和 Overline(上划线)等选项。
Row() {
Column() {
Text(p.hours + 'h').fontSize(11).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Text('拍摄时长').fontSize(7).fontColor(CL.hint).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text(p.retouch + ' 张').fontSize(11).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Text('精修交付').fontSize(7).fontColor(CL.hint).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text(p.dresses + ' 套').fontSize(11).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Text('服装造型').fontSize(7).fontColor(CL.hint).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text(p.scenes + ' 处').fontSize(11).fontWeight(FontWeight.Bold).fontColor(CL.ink)
Text('场景机位').fontSize(7).fontColor(CL.hint).margin({ top: 2 })
}.layoutWeight(1)
}
.width('100%').padding({ top: 10, bottom: 10 })
每个套餐卡片还包含一个四列数据展示区,使用四个等宽 Column 分别展示拍摄时长、精修张数、服装套数和场景数。每个数据项上方是大字号数值,下方是小字号标签说明。这种"数值 + 标签"的数据展示模式在产品详情页中非常常见,它能在有限空间内高效传达多个维度的产品参数。
套餐对比弹框
@Builder pkgCompareOverlay() {
Column() {
this.modalOverlay(() => { this.showCompare = false })
Column() {
Row() {
Text('⚖️ 套餐对比 · ' + PKG_LIST[this.selPkgIdx].tier + '档').fontSize(15)
.fontWeight(FontWeight.Bold).fontColor(CL.ink)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(CL.hint)
.onClick(() => { this.showCompare = false })
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
Divider().color(CL.line)
Scroll() {
Column() {
Row() {
Column() {
Text('轻享档').fontSize(11).fontWeight(FontWeight.Bold).fontColor(CL.olive)
Text('¥' + PKG_LIST[0].price + ' 起').fontSize(10).fontColor(CL.olive).margin({ top: 3 })
Text(PKG_LIST[0].retouch + ' 张精修').fontSize(8).fontColor(CL.sub).margin({ top: 3 })
Text('人气 ' + PKG_LIST[0].sales).fontSize(8).fontColor(CL.hint).margin({ top: 3 })
}
.layoutWeight(1).padding({ top: 10, bottom: 10 })
.backgroundColor('#EDF0E3').borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column() {
Text('经典档').fontSize(11).fontWeight(FontWeight.Bold).fontColor(CL.white)
Text('¥' + PKG_LIST[3].price + ' 起').fontSize(10).fontColor(CL.white).margin({ top: 3 })
Text(PKG_LIST[3].retouch + ' 张精修').fontSize(8).fontColor(CL.cream).margin({ top: 3 })
Text('人气 ' + PKG_LIST[3].sales).fontSize(8).fontColor(CL.cream).margin({ top: 3 })
}
.layoutWeight(1).padding({ top: 10, bottom: 10 })
.backgroundColor(CL.primary).borderRadius(10).margin({ left: 8, right: 8 })
.alignItems(HorizontalAlign.Center)
套餐对比弹框使用三列并排展示轻享、经典和尊享三个档位的代表套餐。三列使用不同的背景色形成视觉对比:轻享档用浅绿色、经典档用主色、尊享档用奶油色。“经典"档居中使用主色背景突出显示,暗示这是推荐档位。底部三个按钮"选轻享”、“选经典”、"选尊享"分别将 selPkgIdx 设置为对应档位的套餐索引并关闭弹框。
十一、Tab4 约拍单页面组件
@Component
struct OrdersContent {
@State showNew: boolean = false
@State showEditTime: boolean = false
@State editIdx: number = 1
@State editDay: number = 10
@State editSlot: number = 4
@State demandTitle: string = ''
@State demandStyle: string = '复古胶片'
@State demandBudget: number = 2000
@State demandDress: boolean = true
@State demandRush: boolean = false
@State stampOp: number = 0.3
@State stampScale: number = 1
OrdersContent 组件管理约拍单页面,状态变量数量多达十二个。showNew 和 showEditTime 分别控制新建需求弹框和修改时间弹框的显隐。editIdx、editDay、editSlot 记录修改时间弹框中的选中状态。demandTitle、demandStyle、demandBudget、demandDress、demandRush 记录新建需求表单的填写内容。stampOp 和 stampScale 用于已定档印章闪烁动画。
印章闪烁动画
aboutToAppear() {
this.getUIContext().animateTo({
duration: 1400,
iterations: -1,
playMode: PlayMode.Alternate,
curve: Curve.EaseInOut
}, () => {
this.stampOp = 1
this.stampScale = 1.12
})
}
印章闪烁动画的持续时间为 1400 毫秒,是所有 Tab 页面动画中最快的。stampOp 在 0.3 到 1 之间变化,stampScale 在 1 到 1.12 之间变化。这种快速闪烁和轻微缩放的效果模拟了公章盖章时的墨迹深浅变化,给人一种"已盖章确认"的仪式感。
单据头与印章
Stack() {
Column().width('100%').height(84)
.linearGradient({ direction: GradientDirection.Right, colors: [[CL.brown, 0], ['#6B5B4C', 1]] })
.borderRadius(14)
Column() {
Text('📝 我的约拍单').fontSize(15).fontWeight(FontWeight.Bold).fontColor(CL.white)
Text('12 张单据 · 2 张待沟通 · 3 张选片中').fontSize(9).fontColor('#EFE6D3').margin({ top: 5 })
}
.width('100%').padding({ top: 12 }).alignItems(HorizontalAlign.Center)
Column() {
Text('已定档').fontSize(9).fontWeight(FontWeight.Bold).fontColor(CL.danger)
}
.border({ width: 2, color: CL.danger }).borderRadius(8)
.rotate({ angle: -14 })
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.opacity(this.stampOp)
.scale({ x: this.stampScale, y: this.stampScale })
.position({ x: '74%', y: 46 })
Text('+ 新建约拍').fontSize(10).fontColor(CL.brown)
.backgroundColor(CL.cream).borderRadius(12)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.position({ x: '70%', y: 14 })
.onClick(() => { this.showNew = true })
}
单据头部使用 Stack 层叠布局,渐变背景上叠加了标题文字、"已定档"印章和"新建约拍"按钮。印章使用 border({ width: 2, color: CL.danger }) 设置 2vp 的红色边框,rotate({ angle: -14 }) 旋转 -14 度——负角度表示逆时针旋转,使得印章呈现倾斜的盖章效果。印章通过 opacity(this.stampOp) 和 scale({ x: this.stampScale, y: this.stampScale }) 绑定到动画状态变量,形成闪烁缩放的动态效果。
订单流程时间线
Column() {
Text('🧭 约拍流程时间线').fontSize(13).fontWeight(FontWeight.Bold).fontColor(CL.ink).width('100%')
ForEach(FLOW_LIST, (f: FlowNode, idx: number) => {
Row() {
Column() {
if (idx < FLOW_LIST.length - 1) {
Column().width(2).layoutWeight(1).backgroundColor(CL.line)
}
this.delFavorOverlay()
}
}
.width('100%').height('100%')
}
}

在动画系统层面,六个 Tab 页面各实现了一个与页面主题相关的呼吸/旋转/闪烁动画,通过 animateTo API 驱动 @State 状态变量实现。所有动画使用 PlayMode.Alternate 交替播放模式和 iterations: -1 无限循环,形成持续运行的视觉效果。动画的持续时间从 1400 毫秒到 3000 毫秒不等,缓动曲线根据动画特性选择 EaseInOut(呼吸类动画)或 Linear(旋转类动画),体现了对动画节奏的精细把控。
在交互系统层面,应用实现了八个模态弹框,涵盖筛选、表单提交、详情展示、对比选择、时间修改和删除确认等交互场景。每个弹框都遵循"遮罩层 + 内容层 + zIndex(999)"的统一架构,通过 @State boolean 变量和 if 条件渲染控制显隐。弹框的定位方式根据交互场景选择——居中定位用于警告确认,底部滑出用于详情展示,顶部浮出用于筛选操作。
在数据可视化层面,应用在纯 ArkUI 组件的基础上实现了多种图表效果:多层环形进度条图表(风格类型分布)、堆叠柱状图(月度拍摄量)、水平条形图(热门客片榜和套餐对比)和环形容量图(相册容量)。这些图表完全使用 Column、Row、Stack、Progress 等基础组件构建,不依赖任何第三方图表库,展示了 ArkUI 基础组件在数据可视化方面的灵活性和表现力。
更多推荐




所有评论(0)