基于HarmonyOS API 24摄影社区APP深度解析:textPrimary用于主要文字,textSecondary用于次要说明文字,textHint用于提示文字和占位符
一、开头引言

在移动互联网时代,摄影爱好者需要一个专门的平台来展示作品、交流技术、记录成长。本文将深度剖析一款精心设计的摄影社区应用——它以"镜头灰"与"胶片橙"为主色调,构建了一个集作品展示、摄影师排行、器材管理、相册分类于一体的完整生态。这款应用不仅仅是一个图片展示工具,更是摄影师的数字工作室和社交家园。
从技术架构的角度来看,这款应用采用了典型的声明式UI开发范式,基于ArkTS语言和ArkUI框架构建。整体采用Tab导航的五页结构:精选首页、作品画廊、摄影师排行、器材库、个人中心。每个页面都经过精心设计,从颜色系统到数据模型,从状态管理到组件复用,都体现了成熟的移动应用开发思维。
技术亮点方面,这款应用有几个值得称道的设计。首先是颜色系统的规范化设计,通过接口定义颜色调色板,实现了主题一致性和可维护性。其次是数据模型层的清晰分层,每个业务实体都有对应的接口定义,为后续接入真实API打下了良好基础。第三是弹窗组件的复用设计,通过modalOverlay构建器统一实现遮罩层,各种业务弹窗在此基础上进行扩展。第四是柱状图的纯代码实现,不依赖第三方图表库,用基础组件组合出了精美的数据可视化效果。
这款应用的整体设计思路体现了"以摄影师为中心"的产品理念——首页的精选大图头卡配合曝光参数浮层,让专业摄影信息一目了然;周拍摄柱状图帮助用户追踪自己的创作频率;摄影师排行榜激发创作动力;器材库管理让设备信息井井有条;加密相册则保护隐私作品。每一个功能模块都围绕着摄影爱好者的真实需求展开。
二、整体架构流程图

下面通过Mermaid流程图展示这款摄影社区APP的整体架构,从Tab导航到各页面组件,再到数据模型和弹窗交互的完整链路。
三、颜色与主题系统详解

3.1 颜色调色板接口设计
颜色系统是整个应用视觉风格的基石。这款应用采用了接口+常量的设计模式,先通过接口定义颜色调色板的结构,再用常量对象提供具体的色值实现。
interface PhotoColorPalette {
primary: string
primaryLight: string
primaryDark: string
accent: string
accentLight: string
warm: string
bg: string
cardBg: string
textPrimary: string
textSecondary: string
textHint: string
border: string
success: string
warning: string
danger: string
white: string
}
这段接口定义了16种颜色角色,覆盖了移动应用UI设计的完整色板需求。主色系(primary系列)提供了基础、浅色、深色三个层级,用于构建应用的主视觉基调。强调色系(accent系列)提供了强调色和浅色两个层级,用于突出关键操作和重要信息。warm色作为暖色调背景,用于输入框、标签等需要温和视觉的区域。
文字颜色分为三级:textPrimary用于主要文字,textSecondary用于次要说明文字,textHint用于提示文字和占位符。这种三级文字色体系是移动应用的标准做法,通过颜色明度差异建立清晰的信息层级。
功能色方面,success(成功绿)、warning(警告橙)、danger(危险红)三色分别对应不同的业务状态反馈。边框色border用于分割线和卡片边框,背景色bg和cardBg分别对应页面背景和卡片背景,建立了明确的层次关系。
3.2 配色方案的设计理念
const COLORS: PhotoColorPalette = {
primary: '#455A64',
primaryLight: '#78909C',
primaryDark: '#263238',
accent: '#FF6F00',
accentLight: '#FFB74D',
warm: '#FFF3E0',
bg: '#FAFAFA',
cardBg: '#FFFFFF',
textPrimary: '#263238',
textSecondary: '#607D8B',
textHint: '#B0BEC5',
border: '#ECEFF1',
success: '#43A047',
warning: '#FB8C00',
danger: '#E53935',
white: '#FFFFFF'
}
这款应用的主色调选择了"镜头灰"(#455A64)作为primary色,这是一种偏蓝的深灰色,灵感来源于专业相机的金属机身质感,传递出专业、稳重、高端的品牌调性。primaryLight(#78909C)和primaryDark(#263238)构成了完整的灰色阶梯,用于渐变背景、阴影效果等场景。
强调色选择了"胶片橙"(#FF6F00),这是一种饱和度较高的橙色,灵感来源于柯达等经典胶片品牌的包装色彩。橙色与蓝灰色形成了鲜明的冷暖对比,在灰调为主的界面中起到画龙点睛的作用。橙色天然带有"创意"、“活力”、"温暖"的语义,非常契合摄影社区的产品定位。
warm色(#FFF3E0)是一种非常浅的暖橙色,用作输入框背景、标签背景等,既保持了视觉上的柔和感,又与主强调色系保持统一。背景色选择了接近白色的浅灰(#FAFAFA),既避免了纯白的刺眼感,又能让卡片内容更加突出。
3.3 常量配置方式
颜色常量采用顶层const的方式定义,这意味着在整个应用的任何组件中都可以直接访问COLORS对象。这种设计有几个优点:第一,全局唯一的颜色源,确保了整个应用视觉风格的一致性;第二,修改方便,只需修改一处即可全局生效;第三,配合TypeScript类型检查,使用时可以获得代码提示和类型安全保障。
除了颜色常量,应用还定义了一系列工具函数来根据业务状态返回对应的颜色。这种设计模式将"状态→颜色"的映射逻辑集中管理,避免了在组件中重复编写条件判断代码。
function getHotColor(isHot: boolean): string {
return isHot ? COLORS.accent : COLORS.primaryLight
}
function getFollowColor(isFollow: boolean): string {
return isFollow ? COLORS.border : COLORS.accent
}
function getLockBg(isLock: boolean): string {
return isLock ? COLORS.warm : COLORS.border
}
getHotColor函数根据作品是否热门返回不同的颜色——热门作品用强调色(橙色),非热门用主色浅色。getFollowColor函数根据关注状态返回颜色——已关注返回边框灰(表示"已完成"状态),未关注返回强调色(表示"可操作"状态)。getLockBg函数根据相册是否加密返回背景色——加密相册用暖色调背景,未加密用边框色背景。
这些工具函数的设计体现了"关注点分离"的原则,将颜色计算逻辑从UI组件中抽离出来,让组件代码更加简洁清晰,同时也便于统一维护和修改。
四、数据模型层深度解析

4.1 PhotoMeta 作品数据模型
interface PhotoMeta {
title: string
author: string
avatar: string
likes: number
camera: string
lens: string
aperture: string
iso: string
isHot: boolean
}
PhotoMeta是摄影作品的数据模型,包含9个字段。title是作品标题,author是作者名称,avatar是作者头像(用emoji表示),likes是点赞数。这四个字段构成了作品的基本信息层。
camera(相机型号)、lens(镜头焦距)、aperture(光圈值)、iso(感光度)这四个字段是摄影作品的专业参数信息,也是这款应用区别于普通图片社交应用的关键所在。对于摄影爱好者来说,这些EXIF信息是作品的重要组成部分,是学习和交流的核心内容。将这些信息作为独立字段存储,而不是混在描述文字中,便于后续进行筛选、排序、统计等高级功能开发。
isHot字段是一个布尔值,标记作品是否为热门作品。这个字段可以用于UI上的视觉差异化展示(如热门标签、特殊颜色等),也可以用于数据排序和筛选。在实际应用中,这个字段可能由后端根据点赞数、评论数、分享数等综合计算得出。
4.2 WeekShotMeta 周拍摄数据模型
interface WeekShotMeta {
label: string
value: number
}
WeekShotMeta是周拍摄统计的数据模型,结构非常简洁——label是日期标签(周一到周日),value是当天的拍摄数量(张数)。这个数据模型用于驱动首页的柱状图组件,展示用户一周内的拍摄活跃度。
虽然结构简单,但这个数据模型的设计很有代表性。它遵循了"标签-值"的经典数据结构,是图表类组件的标准数据格式。这种设计的好处是通用性强,同样的数据结构可以复用到月统计、年统计等不同时间维度的图表中,只需替换数据源即可。
4.3 PhotographerMeta 摄影师数据模型
interface PhotographerMeta {
name: string
avatar: string
followers: string
works: string
specialty: string
isFollow: boolean
}
PhotographerMeta是摄影师的数据模型,包含6个字段。name是摄影师名称,avatar是头像,这是用户的基本标识信息。followers是粉丝数,works是作品数,这两个数字是衡量摄影师影响力的核心指标。用string类型存储是因为粉丝数可能很大(如"12.8万"),需要格式化显示。
specialty字段表示摄影师的专长领域,如"风光·城市"、"星空·延时"等。这个字段帮助用户快速了解摄影师的风格定位,也为后续的推荐算法和分类浏览提供了数据基础。
isFollow字段是当前用户是否关注该摄影师的标记。这个布尔字段直接驱动UI上关注按钮的状态显示——已关注和未关注呈现不同的颜色和文字。在实际产品中,这个字段通常需要结合用户登录状态和关注关系表来确定。
4.4 CameraMeta 相机数据模型
interface CameraMeta {
brand: string
model: string
emoji: string
sensor: string
pixels: string
isMain: boolean
}
CameraMeta是相机设备的数据模型,用于器材库页面。brand是品牌(如Sony、Canon、Nikon),model是型号(如A7R5、EOS R5),这两个字段组合起来就是相机的完整名称。emoji字段用表情符号作为相机的图标表示,在没有真实图片资源的情况下提供了良好的视觉占位。
sensor字段表示传感器类型(全画幅、APS-C、M4/3等),这是相机的核心规格参数之一,对画质和镜头适配有决定性影响。pixels字段表示像素数量,是用户最关心的规格参数之一。用string类型存储是因为像素数通常带有"万"这样的单位后缀。
isMain字段标记是否为主力相机。这个字段让用户可以在多台相机中指定常用的主力机型,在UI上会有特殊的视觉标记(如"主力"标签)。这是一个很贴心的产品设计,体现了对多机身摄影师使用场景的理解。
4.5 AlbumMeta 相册数据模型
interface AlbumMeta {
name: string
emoji: string
count: string
date: string
isLock: boolean
}
AlbumMeta是相册的数据模型,用于个人中心的相册管理。name是相册名称,emoji是相册封面图标,count是照片数量,date是相册创建或更新日期。这四个字段构成了相册的基本信息。
isLock字段标记相册是否加密。这是一个很重要的功能特性——用户可能有一些私密的照片不希望被随意查看,加密相册功能满足了这一隐私保护需求。在UI上,加密相册会显示锁图标并有不同的背景色,让用户一目了然。
4.6 TagMeta 标签数据模型
interface TagMeta {
name: string
count: string
isHot: boolean
}
TagMeta是标签的数据模型,用于首页的热门标签区域。name是标签名称(带#号前缀),count是该标签下的作品数量,isHot标记是否为热门标签。
标签系统是内容型应用的标配功能,它既是内容分类的方式,也是用户发现感兴趣内容的入口。isHot字段可以用于突出展示热门标签,引导用户浏览。count字段展示作品数量,给用户一个内容丰富度的预期。
4.7 ContestMeta 比赛数据模型
interface ContestMeta {
title: string
emoji: string
deadline: string
prize: string
isJoin: boolean
}
ContestMeta是摄影比赛的数据模型,用于首页的摄影大赛区域。title是比赛名称,emoji是比赛主题图标,deadline是截止日期,prize是奖金金额。这四个字段提供了比赛的核心信息。
isJoin字段标记用户是否已参与该比赛。这个字段直接驱动"参与/已参与"按钮的状态显示。比赛活动是社区类应用提升用户活跃度的重要运营手段,通过奖金激励和竞技性激发用户的创作热情。
4.8 TabMeta 导航标签数据模型
interface TabMeta {
title: string
icon: string
}
TabMeta是底部导航标签的数据模型,title是标签文字,icon是标签图标(用emoji表示)。这个接口虽然简单,但它将导航配置从组件代码中抽离出来,使得导航栏的配置更加灵活。如果需要修改导航项的文字或图标,只需修改配置数据,不需要改动组件代码。
五、配置常量层分析

5.1 TAB_CONFIG 导航配置
const TAB_CONFIG: Record<string, TabMeta> = {
'HOME': { title: '精选', icon: '📷' },
'GALLERY': { title: '作品', icon: '🖼️' },
'RANK': { title: '排行', icon: '🏆' },
'GEAR': { title: '器材', icon: '🎥' },
'MINE': { title: '我的', icon: '👤' }
}
TAB_CONFIG是底部导航栏的配置常量,采用Record<string, TabMeta>的类型定义,键是枚举值的字符串形式,值是包含标题和图标的对象。这种配置驱动的设计模式有几个显著优点:
第一,集中管理。所有导航项的配置都在一个地方,修改和维护都很方便。如果产品经理要求把"精选"改成"首页",只需改一处配置即可。
第二,类型安全。配合PhotoTab枚举使用,键名与枚举值一一对应,减少了手写字符串可能导致的错误。
第三,易于扩展。如果后续需要增加新的导航项,只需在配置中添加一项,然后在枚举和contentArea中增加对应分支即可。
第四,支持国际化。通过配置的方式,可以方便地接入多语言方案,根据语言环境加载不同的标题文本。
5.2 业务配置常量的设计模式
应用中定义了多个业务数据常量,如PHOTO_LIST、WEEK_SHOT、PHOTOGRAPHER_LIST等。这些常量在开发阶段扮演着Mock数据的角色,同时也定义了各业务模块的数据结构。
从设计模式的角度来看,这些常量遵循了"数据驱动视图"的理念——组件只负责渲染逻辑,数据由外部提供。这种设计使得组件具有很好的复用性和可测试性。当需要接入真实后端API时,只需将数据源从常量替换为网络请求返回的数据即可,组件本身不需要做任何修改。
常量命名采用了"全大写下划线分隔"的风格,这是常量命名的通用约定,让人一眼就能看出这是不可变的数据。数组类型的常量都带有_LIST后缀,对象类型的常量带有_CONFIG后缀,命名清晰且具有自解释性。
5.3 枚举类型的使用
enum PhotoTab {
HOME,
GALLERY,
RANK,
GEAR,
MINE
}
PhotoTab枚举定义了五个Tab页的标识,使用数字枚举的方式(默认从0开始递增)。枚举类型在Tab导航场景中非常适用,它带来了以下好处:
一是代码可读性强。用PhotoTab.HOME代替魔法数字0,代码的意图更加清晰。
二是类型安全。TypeScript会对枚举值进行类型检查,防止传入无效的Tab值。
三是重构友好。如果需要调整Tab的顺序或增减Tab项,只需修改枚举定义,编译器会自动检查所有使用处。
四是智能提示。在IDE中输入PhotoTab.时会自动补全所有可用的枚举值,提升开发效率。
六、Mock数据层分析

6.1 模拟数据的设计思路
这款应用的Mock数据设计非常考究,不是简单的占位数据,而是具有真实业务场景特征的模拟数据。以PHOTO_LIST为例,12条作品数据涵盖了风光、人像、微距、街头、星空、胶片等多种摄影题材,相机品牌包括Sony、Canon、Nikon、Fuji、Olympus、Leica、Pentax等主流品牌,点赞数从980到5230不等,分布非常真实。
这种高质量的Mock数据有几个重要价值。第一,在开发阶段就能看到接近真实的界面效果,便于设计师和产品经理评估UI设计。第二,丰富的数据样例可以暴露更多边界情况,比如长标题、大数字、热门标签等,确保UI在各种数据状态下都能正常显示。第三,为后续的测试工作提供了数据基础,可以直接基于这些样例数据编写测试用例。
6.2 作品数据结构分析
const PHOTO_LIST: PhotoMeta[] = [
{ title: '城市天际线', author: '光影捕手', avatar: '🧑🎨', likes: 3280, camera: 'Sony A7R5', lens: '24-70mm', aperture: 'f/8', iso: 'ISO 100', isHot: true },
{ title: '雾中山林', author: '自然之眼', avatar: '🌲', likes: 2510, camera: 'Canon R5', lens: '70-200mm', aperture: 'f/4', iso: 'ISO 200', isHot: true },
// ... 更多数据
]
PHOTO_LIST包含12条作品数据,每条数据都有完整的9个字段。观察这些数据可以发现几个设计细节:
作者名称与头像emoji是对应的,比如"光影捕手"对应"🧑🎨",“星空猎人"对应"🌌”,“微距世界"对应"🌸”。这种对应关系让数据更有真实感,也帮助用户快速记忆和识别不同的摄影师。
同一个作者有多条作品,比如"光影捕手"有3条作品,"行走的镜头"有2条作品。这模拟了真实社区中多产作者的情况,也让摄影师排行页的数据更有依据。
isHot字段的分布也很合理,12条作品中有6条标记为热门,比例约为50%。热门作品通常点赞数较高,但也不是绝对的——这种不完全对应的关系更接近真实业务场景。
6.3 摄影师数据结构分析
const PHOTOGRAPHER_LIST: PhotographerMeta[] = [
{ name: '光影捕手', avatar: '🧑🎨', followers: '12.8万', works: '386', specialty: '风光·城市', isFollow: true },
{ name: '星空猎人', avatar: '🌌', followers: '9.6万', works: '215', specialty: '星空·延时', isFollow: true },
// ... 更多数据
]
PHOTOGRAPHER_LIST包含8位摄影师数据。粉丝数从2.1万到12.8万不等,呈现出合理的梯度分布。作品数从67到512不等,与粉丝数大致正相关但不完全对应——这符合真实情况,有些摄影师虽然作品不多但质量很高,粉丝数也很多。
specialty字段用"·"分隔两个专长领域,这种命名方式简洁而信息密度高。用户可以一眼看出每位摄影师的擅长方向。
isFollow字段有两位摄影师标记为已关注(true),模拟了当前用户已关注部分摄影师的状态。这种混合状态让关注按钮的两种视觉状态都能在页面中展示出来,便于UI验收。
6.4 相机数据结构分析
const CAMERA_LIST: CameraMeta[] = [
{ brand: 'Sony', model: 'A7R5', emoji: '📸', sensor: '全画幅', pixels: '6100万', isMain: true },
{ brand: 'Canon', model: 'EOS R5', emoji: '📸', sensor: '全画幅', pixels: '4500万', isMain: false },
// ... 更多数据
]
CAMERA_LIST包含8款相机数据,涵盖了当前市场上的主流品牌和型号。传感器类型包括全画幅、APS-C、M4/3三种,像素从2000万到6100万不等。这些数据对于摄影爱好者来说都是耳熟能详的机型,增强了应用的专业感和真实感。
isMain字段只有Sony A7R5标记为true,表示这是用户的主力相机。主力相机会在UI上有特殊的视觉标记,帮助用户快速识别常用设备。
6.5 相册数据结构分析
const ALBUM_LIST: AlbumMeta[] = [
{ name: '城市风光', emoji: '🏙️', count: '86张', date: '2024-08', isLock: false },
{ name: '自然风光', emoji: '🏔️', count: '124张', date: '2024-07', isLock: false },
{ name: '人像作品', emoji: '👤', count: '52张', date: '2024-06', isLock: true },
// ... 更多数据
]
ALBUM_LIST包含10个相册,涵盖了风光、人像、街头、星空、微距等常见分类。相册数量从28张到215张不等,日期从2024-01到2024-08,模拟了用户长期积累的相册数据。
其中有2个相册标记为加密(isLock: true),分别是"人像作品"和"黑白系列"。加密相册的设置体现了对用户隐私的尊重,也让相册列表的视觉效果更加丰富多样。
七、主入口组件逐段分析

7.1 状态管理设计
@Entry
@Component
struct PhotoApp {
@State activeTab: PhotoTab = PhotoTab.HOME
PhotoApp是应用的主入口组件,使用@Entry装饰器标记为页面入口,@Component装饰器标记为组件。@State装饰器是ArkUI框架中最基础也是最常用的状态管理装饰器。
@State装饰的变量activeTab是组件的内部状态,当它的值发生变化时,框架会自动重新渲染依赖这个状态的UI部分。在这个应用中,activeTab控制着当前显示的页面和底部导航栏的选中状态。
初始值设为PhotoTab.HOME,表示应用启动时默认显示首页。这是一个很合理的默认值,首页通常承载着应用的核心内容和入口功能。
使用枚举类型而不是字符串或数字来表示Tab状态,是一个很好的实践。枚举的类型安全性可以防止无效值的传入,同时也让代码更具可读性。
7.2 @Builder自定义构建器的设计
@Builder contentArea() {
Column() {
if (this.activeTab === PhotoTab.HOME) {
PhotoHomeContent()
} else if (this.activeTab === PhotoTab.GALLERY) {
PhotoGalleryContent()
} else if (this.activeTab === PhotoTab.RANK) {
PhotoRankContent()
} else if (this.activeTab === PhotoTab.GEAR) {
PhotoGearContent()
} else if (this.activeTab === PhotoTab.MINE) {
PhotoMineContent()
}
}
.width('100%')
}
contentArea是一个@Builder装饰的自定义构建函数,用于构建内容区域。@Builder是ArkUI框架提供的一种轻量复用机制,可以将一段UI构建逻辑封装成可复用的函数。
这个构建器的核心逻辑是一个if-else if链式判断,根据activeTab的值来决定渲染哪个页面组件。这种方式实现了Tab页的切换功能——当用户点击底部导航栏时,activeTab的值改变,触发UI重新渲染,contentArea就会显示对应的页面内容。
使用Column作为容器包裹页面组件,设置宽度为100%。虽然每个页面内部也有自己的布局容器,但外层再包一层Column可以确保内容区域的宽度铺满,同时也为将来可能增加的公共头部或公共底部预留了扩展空间。
7.3 tabItem方法的实现原理
@Builder tabItem(icon: string, label: string, tab: PhotoTab) {
Column() {
Text(icon).fontSize(20)
Text(label).fontSize(10)
.fontColor(this.activeTab === tab ? COLORS.accent : COLORS.textHint)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => { this.activeTab = tab })
}
tabItem构建函数用于构建单个Tab项,接收三个参数:icon(图标emoji)、label(文字标签)、tab(对应的枚举值)。
每个Tab项使用Column垂直布局,上方是图标(20号字体),下方是文字标签(10号字体)。图标使用emoji表示,这是一种简单而有效的图标方案——不需要引入图标库,跨平台一致性好,而且自带颜色和情感表达。
文字标签的颜色和字重会根据选中状态动态变化:选中时使用强调色(橙色)+ 粗体,未选中时使用提示色(浅灰)+ 常规字重。这种视觉差异化是Tab导航的标准交互设计,让用户清晰地知道当前所在的页面。
.layoutWeight(1)是关键的布局属性,它让每个Tab项在Row容器中平均分配宽度。不管有多少个Tab项,它们都会自动平分底部导航栏的宽度,保证布局的均匀和稳定。
.onClick事件处理函数将activeTab设置为当前Tab对应的枚举值。由于activeTab是@State状态变量,修改它会触发UI刷新,从而实现页面切换的效果。整个交互流程就是:点击Tab → 修改状态 → 触发重绘 → 页面切换 + Tab视觉更新。
7.4 contentArea内容区域切换逻辑
内容区域的切换逻辑是整个Tab导航机制的核心。当用户点击底部的某个Tab时,事件处理函数修改activeTab的值。由于activeTab被@State装饰,框架会检测到状态变化,然后重新执行build方法来更新UI。
在重新构建的过程中,contentArea构建器会根据新的activeTab值选择对应的页面组件进行渲染。同时,底部导航栏的tabItem也会根据新的activeTab值更新文字颜色和字重。
这种状态驱动的UI更新模式是声明式UI框架的核心思想。开发者不需要手动操作UI元素,只需要维护好状态,框架会自动根据状态计算出最新的UI并更新。这大大简化了UI编程的复杂度,也减少了因状态和UI不一致导致的bug。
7.5 build方法的整体布局结构
build() {
Column() {
this.contentArea()
Row() {
this.tabItem(TAB_CONFIG.HOME.icon, TAB_CONFIG.HOME.title, PhotoTab.HOME)
this.tabItem(TAB_CONFIG.GALLERY.icon, TAB_CONFIG.GALLERY.title, PhotoTab.GALLERY)
this.tabItem(TAB_CONFIG.RANK.icon, TAB_CONFIG.RANK.title, PhotoTab.RANK)
this.tabItem(TAB_CONFIG.GEAR.icon, TAB_CONFIG.GEAR.title, PhotoTab.GEAR)
this.tabItem(TAB_CONFIG.MINE.icon, TAB_CONFIG.MINE.title, PhotoTab.MINE)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.white)
.shadow({ radius: 10, color: '#455A6420', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor(COLORS.bg)
}
build方法是组件的UI构建函数,它定义了组件的整体布局结构。最外层是一个Column,包含两个子元素:上方的内容区域(contentArea)和下方的底部导航栏(Row包裹的tabItem列表)。
内容区域占据了大部分空间,底部导航栏固定在底部。底部导航栏使用Row水平排列五个Tab项,每个Tab项通过layoutWeight(1)平均分配宽度。
底部导航栏的样式设计也很讲究:白色背景、上下各8dp的内边距、顶部带有淡淡的阴影效果。阴影使用了主色调的透明版(#455A6420,即20%不透明度),offsetY设为-2让阴影向上投射,radius设为10让阴影边缘柔和。这种设计让导航栏与内容区域之间有清晰的视觉分隔,同时又不会显得突兀。
最外层的Column设置了宽高都是100%,背景色为bg色(#FAFAFA)。这确保了应用铺满整个屏幕,背景色在内容不足的区域也能正确显示。
八、各页面组件逐段代码分析

8.1 首页组件 PhotoHomeContent
8.1.1 状态与弹窗构建器
@Component
struct PhotoHomeContent {
@State showAddPhotoModal: boolean = false
@State photoTitle: string = ''
@State photoCamera: string = ''
首页组件定义了三个@State状态变量。showAddPhotoModal控制新增作品弹窗的显示与隐藏,初始值为false(不显示)。photoTitle和photoCamera分别存储弹窗中输入的作品标题和拍摄器材信息。
将弹窗的输入数据定义为组件的状态变量,是因为弹窗是组件的一部分,输入的数据需要在弹窗关闭后保留(或用于后续的提交操作)。虽然当前版本中点击上传后只是关闭弹窗,没有实际保存数据,但这种设计为后续功能扩展预留了空间。
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
modalOverlay构建函数创建了一个全屏的半透明黑色遮罩层。背景色使用rgba格式,黑色(0,0,0)+ 55%不透明度,这样既能让背景内容变暗以突出弹窗,又不会完全遮挡背景,保持了视觉上的层次感。
onClick事件绑定了传入的onClose回调函数,这意味着点击遮罩层可以关闭弹窗。这是弹窗交互的标准设计——除了点击关闭按钮外,点击弹窗外部区域也能关闭,提供了更便捷的操作方式。
遮罩层作为@Builder函数,并且接收一个回调函数作为参数,这种设计使得遮罩层可以被各种弹窗复用,而关闭逻辑由调用方决定。这是一种典型的"行为参数化"设计模式。
8.1.2 新增作品弹窗
@Builder addPhotoModal() {
Stack() {
this.modalOverlay(() => { this.showAddPhotoModal = false })
Column() {
Row() {
Text('📷 新增作品').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddPhotoModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(COLORS.border)
addPhotoModal构建函数创建了新增作品的弹窗内容。最外层使用Stack布局,第一层是半透明遮罩(modalOverlay),第二层是弹窗主体(Column)。Stack的堆叠特性让遮罩层铺满整个屏幕,弹窗居中浮在遮罩之上。
弹窗主体是一个Column,顶部是标题栏。标题栏使用Row布局,左侧是带相机emoji的标题文字,中间用Column().layoutWeight(1)撑开空间,右侧是关闭按钮(✕)。这种"左中右"的标题栏布局是移动端弹窗的标准模式。
标题栏下方是一条分割线(Divider),颜色为边框色,视觉上将标题区和内容区分隔开。
Column() {
Text('作品标题').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
TextInput({ placeholder: '如 城市天际线' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.photoTitle = v })
Text('拍摄器材').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 Sony A7R5' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.photoCamera = v })
表单内容区域包含多个输入项,每个输入项由标签文字(Text)和输入框(TextInput)组成。标签文字使用次要文字色,字号12号,提示用户这个输入框需要填写什么内容。
输入框的设计很有特色:背景色使用warm色(浅橙黄),而不是常见的白色或灰色。这种暖色调的输入框与应用的整体配色风格保持一致,同时也让输入区域更加柔和友好。圆角8dp,宽度90%,左右各有20dp的边距。
placeholder使用提示色(textHint),给出具体的填写示例(如"如 城市天际线"),而不是笼统的"请输入"。这种示例式的占位符比通用提示更有引导性,用户一看就知道该填什么样的内容。
onChange事件将输入的值同步到对应的@State变量中。这是声明式UI中处理用户输入的标准方式——输入框的值由状态驱动,用户输入改变状态,状态变化又会反映到UI上(虽然TextInput本身可能不显示状态值,但状态数据已经准备好供后续使用)。
Text('拍摄地点').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 上海外滩' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.warm).borderRadius(8)
.onClick(() => { this.showAddPhotoModal = false })
Column().width(12)
Text('上传').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.accent).borderRadius(8)
.onClick(() => { this.showAddPhotoModal = false })
}
.margin({ left: 20, right: 20, top: 20, bottom: 20 })
}
.constraintSize({ maxHeight: '70%' })
底部是操作按钮区域,使用Row水平排列"取消"和"上传"两个按钮。两个按钮各占一半宽度(layoutWeight(1)),中间用Column().width(12)留出12dp的间距。
"取消"按钮使用暖色调背景+次要文字色,视觉权重较低,作为次要操作。"上传"按钮使用强调色背景+白色文字,视觉权重高,作为主要操作。这种主次按钮的视觉差异化设计是表单交互的标准做法,引导用户的注意力到主要操作上。
.constraintSize({ maxHeight: ‘70%’ })限制了内容区域的最大高度为屏幕的70%。这是一个重要的细节——当表单内容很多时,弹窗不会撑满整个屏幕,而是在内部滚动,保持弹窗的整体比例和美感。
8.1.3 首页头卡——精选大图+曝光参数浮层
build() {
Stack() {
Scroll() {
Column() {
// 作品大图+曝光参数浮层头卡
Stack({ alignContent: Alignment.BottomStart }) {
Column()
.width('100%').height(220)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 28, bottomRight: 28 })
首页的build方法最外层是Stack,里面包含可滚动的内容区域和弹窗。内容区域最上方是一个头卡组件,使用Stack布局,对齐方式为底部起始(BottomStart)。
头卡的背景是一个220dp高的渐变色块,使用135度角的线性渐变,从主色(primary)渐变到深主色(primaryDark)。底部左右两个角有28dp的大圆角,形成一个"上平下圆"的形状,视觉上很有特色。
在真实应用中,这里应该是一张精选的大图,但在当前实现中用渐变色块替代。用渐变作为图片占位是一种常见的设计手法,既保持了视觉的美观,又避免了图片加载失败时的空白感。
Column() {
Row() {
Column() {
Text('📷 今日精选').fontSize(11).fontColor('#B0BEC5')
Text('城市天际线').fontSize(24).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
.margin({ top: 4 })
Text('by 光影捕手').fontSize(11).fontColor('#B0BEC5').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('3,280').fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
Text('❤️ 点赞').fontSize(9).fontColor('#B0BEC5').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
}
.padding({ left: 20, right: 20, top: 40 })
头卡的内容分为上下两部分。上部分是一个Row,左侧是作品信息,右侧是点赞数。左侧从上到下依次是:分类标签(今日精选)、作品标题(城市天际线)、作者信息(by 光影捕手)。文字颜色使用白色和浅灰色(#B0BEC5),在深色背景上保证了良好的可读性。
右侧是点赞数的突出展示,3,280的数字使用22号粗体强调色(橙色),在深色背景上非常醒目。下方是"❤️ 点赞"的小字说明。这种大数字+小标签的信息展示方式能有效吸引用户注意力,突出作品的受欢迎程度。
// 曝光参数浮层
Row() {
Column() {
Text('Sony A7R5').fontSize(10).fontColor(COLORS.white)
Text('24-70mm f/8').fontSize(9).fontColor('#B0BEC5').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Row() {
Text('ISO 100').fontSize(9).fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('rgba(255,255,255,0.15)').borderRadius(8)
Column().width(6)
Text('1/250s').fontSize(9).fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('rgba(255,255,255,0.15)').borderRadius(8)
}
}
.width('100%')
.padding({ left: 20, right: 20, top: 30, bottom: 16 })
}
}
.width('100%').height(220)
曝光参数浮层是头卡的下半部分,也是这款应用最具特色的设计之一。左侧显示相机型号和镜头光圈信息,右侧是两个胶囊状的参数标签——ISO和快门速度。
参数标签使用半透明白色背景(rgba(255,255,255,0.15)),白色文字,8dp圆角。这种设计在深色背景上既保持了良好的可读性,又有一种通透感和科技感。15%的不透明度让背景渐变隐约可见,视觉层次非常丰富。
将曝光参数(相机、镜头、光圈、ISO、快门速度)以如此醒目的方式展示在首页头卡中,充分体现了这款应用的专业性定位——它不是普通的图片分享应用,而是面向摄影爱好者的专业社区,EXIF信息是作品不可或缺的组成部分。
8.1.4 周拍摄柱状图
// 周拍摄柱状图
Column() {
Text('📊 本周拍摄(张)').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16 })
Row() {
ForEach(WEEK_SHOT, (w: WeekShotMeta) => {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Column().width(22).height(90)
.backgroundColor(COLORS.border)
.borderRadius(4)
Column().width(22).height(Number(w.value) * 6)
.linearGradient({ angle: 0, colors: [[COLORS.accentLight, 0.0], [COLORS.accent, 1.0]] })
.borderRadius(4)
}
.width(22).height(90)
Text(w.label).fontSize(9).fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12 })
周拍摄柱状图是一个纯代码实现的数据可视化组件,没有依赖任何第三方图表库。整个图表放在一个卡片容器中,白色背景、16dp圆角、左右各12dp外边距。
图表的标题是"📊 本周拍摄(张)",左对齐,14号粗体字。下方是柱状图主体,使用Row水平排列七天的数据。每一列数据用Column垂直布局,上方是柱子,下方是日期标签。
柱子的实现非常巧妙:使用Stack堆叠两个Column,底层是高度90dp的灰色背景柱(border色),上层是高度随数据变化的渐变色柱(从accentLight到accent的垂直渐变)。Stack的对齐方式设为Bottom,让数据柱从底部向上生长,这正是柱状图的标准呈现方式。
柱子的高度计算使用Number(w.value) * 6,即将拍摄张数乘以6倍作为柱子高度(单位dp)。这种简单的比例缩放虽然不是精确的百分比计算,但对于展示相对趋势来说已经足够。从数据来看,最大值是周六的14张,乘以6等于84dp,接近90dp的总高度,比例控制得很合理。
ForEach是ArkUI框架提供的列表渲染组件,用于遍历数组并为每个元素生成对应的UI。这里遍历WEEK_SHOT数组,为每一天的数据生成一根柱子和一个标签。.layoutWeight(1)让每一列平均分配Row的宽度,保证7根柱子均匀分布。
8.1.5 热门标签
// 热门标签
Column() {
Text('🔥 热门标签').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(TAG_LIST, (t: TagMeta) => {
Row() {
Text(t.name).fontSize(12).fontColor(getHotColor(t.isHot))
Text(' ' + t.count).fontSize(9).fontColor(COLORS.textHint)
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.warm).borderRadius(20)
.margin({ right: 8, bottom: 8 })
})
}
.width('100%').padding({ left: 16, right: 16 })
}
热门标签区域使用Flex布局,wrap属性设为FlexWrap.Wrap,实现了标签的自动换行效果。当一行放不下所有标签时,多余的标签会自动换到下一行,这是标签云/热门标签的标准布局方式。
每个标签是一个Row,包含标签名称和作品数量。标签名称使用12号字体,颜色根据是否热门动态变化(热门用橙色,非热门用灰蓝色)。作品数量使用9号小字体,提示色,作为补充信息。
标签的背景色使用warm色(浅橙黄),圆角20dp(接近胶囊形状),左右各12dp、上下各6dp的内边距。这种圆润的标签样式在视觉上非常友好,点击热区也足够大。标签之间右边距8dp、下边距8dp,保持适当的间距。
标签右下方的间距(margin)而不是整个Flex容器的padding,是因为FlexWrap换行后,每一行的第一个标签左边不需要间距,最后一个右边也不需要间距,而每行之间需要垂直间距。用标签自身的margin来控制间距可以自动处理这些边界情况。
8.1.6 摄影大赛列表
// 比赛活动
Column() {
Text('🏆 摄影大赛').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
ForEach(CONTEST_LIST, (c: ContestMeta) => {
Row() {
Text(c.emoji).fontSize(28).margin({ right: 12 })
Column() {
Text(c.title).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text('截止 ' + c.deadline + ' · 奖金 ' + c.prize).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(c.isJoin ? '已参与' : '参与').fontSize(11)
.fontColor(c.isJoin ? COLORS.textHint : COLORS.white)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(c.isJoin ? COLORS.border : COLORS.accent).borderRadius(12)
}
.width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 })
})
}
摄影大赛列表也是一个卡片式设计,白色背景、圆角、外边距。列表中每一行是一个比赛项,使用水平布局:左侧是emoji图标(28号大字体),中间是比赛信息,右侧是参与按钮。
中间信息区包含两行:第一行是比赛标题(13号中等字重),第二行是截止日期和奖金信息(10号次要文字色)。第二行用"·"分隔两个信息点,简洁而信息密度高。
右侧的"参与/已参与"按钮根据isJoin状态呈现不同的视觉样式:未参与时是橙色填充按钮(强调色背景+白色文字),已参与时是灰色边框样式(边框色背景+提示色文字)。这种状态切换让用户一眼就能看出哪些比赛已经参加了,哪些还可以参加。
按钮的圆角是12dp,比标签的圆角略小,内边距左右各12dp、上下各6dp。这种尺寸设计让按钮既醒目又不显得笨重。
8.1.7 精选作品列表
// 精选作品列表
Column() {
Text('✨ 精选作品').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
ForEach(PHOTO_LIST, (p: PhotoMeta) => {
Row() {
Stack() {
Column().width(52).height(52).borderRadius(12)
.linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
Text(p.avatar).fontSize(28)
}
.width(52).height(52).margin({ right: 12 })
精选作品列表是首页最长的一个列表,展示了12条精选作品。每条作品项的左侧是一个52x52dp的头像区域。头像的实现方式是:一个渐变色块作为背景(从primaryLight到primaryDark的135度渐变),上面居中显示emoji头像。
使用渐变色块作为头像背景,而不是纯色背景,增加了视觉的丰富度和品质感。12dp的圆角让头像看起来圆润友好,28号的emoji大小适中。
Column() {
Text(p.title).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(p.author + ' · ' + p.camera).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
Text(p.lens + ' ' + p.aperture + ' ' + p.iso).fontSize(9).fontColor(COLORS.textHint).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
中间信息区有三行文字,形成清晰的信息层级。第一行是作品标题,最重要,字号最大(13号),主要文字色,中等字重。第二行是作者和相机信息,字号10号,次要文字色。第三行是镜头、光圈、ISO等专业参数,字号最小(9号),提示色文字。
这种三级信息结构是列表设计的经典模式——最重要的信息用最大最醒目的样式,次要信息逐级减弱。用户在快速浏览时能迅速抓住重点,需要详细信息时也能找到。
Column() {
Text('❤️ ' + String(p.likes)).fontSize(10).fontColor(getHotColor(p.isHot))
if (p.isHot) {
Text('🔥 热门').fontSize(8).fontColor(COLORS.white)
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
.backgroundColor(COLORS.accent).borderRadius(4)
.margin({ top: 4 })
}
}
.alignItems(HorizontalAlign.End)
右侧是点赞数和热门标签区域。点赞数的颜色根据是否热门动态变化——热门作品用橙色,非热门用灰蓝色。这样热门作品在视觉上更加突出,引导用户关注高质量内容。
如果作品是热门的(isHot为true),还会显示一个"🔥 热门"的小标签。这个标签使用橙色背景+白色文字,4dp圆角,非常小巧精致。8号字体虽然小,但配合橙色背景仍然很醒目。
条件渲染(if语句)是声明式UI的常用模式,根据状态决定是否渲染某个UI元素。这里用来控制热门标签的显示与隐藏。
每条作品项之间用底部分割线分隔(border底部1dp边框),这是列表项的标准分隔方式。
8.1.8 上传按钮与弹窗控制
// 新增按钮
Row() {
Text('+ 上传新作品').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.accent).borderRadius(12)
.onClick(() => { this.showAddPhotoModal = true })
}
.width('92%')
.margin({ bottom: 20 })
首页底部是一个"上传新作品"的大按钮,橙色填充,白色文字,12dp圆角,上下各12dp内边距。按钮宽度为92%,底部外边距20dp。
这个按钮是首页的主要行动号召(Call to Action),引导用户上传自己的作品。醒目的橙色和较大的尺寸确保用户不会错过这个入口。
点击按钮会将showAddPhotoModal设为true,从而显示新增作品弹窗。
if (this.showAddPhotoModal) {
this.addPhotoModal()
}
}
.width('100%').height('100%')
}
}
弹窗的显示控制通过条件渲染实现——当showAddPhotoModal为true时,渲染addPhotoModal的内容。由于最外层是Stack布局,弹窗会浮在内容区域之上。
这种弹窗实现方式虽然简单,但非常有效。核心原理就是利用Stack的堆叠特性+条件渲染,控制弹窗的显示与隐藏。遮罩层提供背景变暗的效果,同时点击遮罩可以关闭弹窗,形成完整的交互闭环。
8.2 作品页组件 PhotoGalleryContent
@Component
struct PhotoGalleryContent {
build() {
Scroll() {
Column() {
// 瀑布流双列
Text('🖼️ 全部作品').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(PHOTO_LIST, (p: PhotoMeta) => {
Column() {
Stack() {
Column().width('100%').height(120)
.linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ topLeft: 12, topRight: 12 })
Text(p.avatar).fontSize(40)
}
.width('100%').height(120)
作品页采用瀑布流双列布局展示全部作品。页面标题是"🖼️ 全部作品",16号粗体字。
作品网格使用Flex布局,wrap属性为Wrap(自动换行),justifyContent为SpaceBetween(两端对齐,项目之间间隔相等)。这种布局方式会自动计算每行能放几个卡片,然后均匀分布间距,实现整齐的网格效果。
每个作品卡片是一个Column,宽度为48%(两列布局,每列略小于50%,留出中间间距)。卡片上方是图片区域,下方是文字信息区域。
图片区域高度120dp,使用渐变背景+emoji图标的方式模拟作品图片。顶部左右两个角有12dp圆角,与卡片整体圆角一致。40号的emoji在120dp高的区域中居中显示,大小适中。
Column() {
Text(p.title).fontSize(12).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(p.author).fontSize(9).fontColor(COLORS.textSecondary).margin({ top: 2 })
Row() {
Text('❤️ ' + String(p.likes)).fontSize(9).fontColor(COLORS.accent)
Column().layoutWeight(1)
if (p.isHot) {
Text('🔥').fontSize(10)
}
}
.width('100%').margin({ top: 4 })
}
.width('100%').padding({ left: 8, right: 8, top: 6, bottom: 8 })
}
.width('48%')
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ bottom: 10 })
卡片下方的文字信息区域有三行:作品标题、作者、点赞数+热门标记。信息层级与首页列表类似,但字号更小一些,因为卡片空间有限。
底部的Row布局中,左边是点赞数(橙色),右边是热门火焰图标(如果有的话)。中间用Column().layoutWeight(1)撑开,让点赞数和热门图标分别靠左右两侧。
卡片整体有白色背景、12dp圆角、底部10dp外边距。卡片之间的间距由Flex的SpaceBetween对齐方式(水平方向)和卡片的margin-bottom(垂直方向)共同控制。
这种双列瀑布流布局非常适合图片类内容的展示,在有限的屏幕空间内展示更多的作品缩略图,提高浏览效率。
8.3 排行页组件 PhotoRankContent
8.3.1 前三名展示区
@Component
struct PhotoRankContent {
build() {
Scroll() {
Column() {
Text('🏆 摄影师排行').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })
// 前三名
Row() {
ForEach(PHOTOGRAPHER_LIST.slice(0, 3), (pg: PhotographerMeta, idx: number) => {
Column() {
Stack() {
Column().width(56).height(56).borderRadius(28)
.backgroundColor(idx === 0 ? '#FFD700' : idx === 1 ? '#C0C0C0' : '#CD7F32')
Text(pg.avatar).fontSize(32)
}
.width(56).height(56).margin({ bottom: 6 })
排行页的顶部是一个特殊设计的前三名展示区。使用PHOTOGRAPHER_LIST.slice(0, 3)获取前三位摄影师的数据。
三位摄影师水平排列在一个Row中,每位摄影师是一个Column,包含头像、名字、粉丝数、奖牌emoji。
头像的背景色根据排名不同而变化:第一名金色(#FFD700)、第二名银色(#C0C0C0)、第三名铜色(#CD7F32)。这种金/银/铜的配色方案是排行榜的经典设计,用户一眼就能识别出前三名的名次。
头像大小为56x56dp,圆形(borderRadius: 28),比普通列表的头像更大,突出前三名的特殊地位。
Text(pg.name).fontSize(11).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Bold)
Text(pg.followers + '粉丝').fontSize(9).fontColor(COLORS.textSecondary).margin({ top: 2 })
Text(idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉').fontSize(14).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
.margin({ top: idx === 0 ? 0 : 20 })
})
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12 })
名字使用粗体字,粉丝数使用次要文字色。底部显示奖牌emoji:🥇金牌、🥈银牌、🥉铜牌,进一步强化名次的视觉识别。
一个巧妙的设计是margin的top值:第一名的top是0,第二、三名的top是20。这意味着第一名的头像位置比第二、三名高20dp,形成一个"冠军居中更高"的领奖台效果。虽然只是一个小小的margin差异,但视觉效果却非常生动,让排行榜更有仪式感。
8.3.2 排行榜列表
// 排行榜
Column() {
ForEach(PHOTOGRAPHER_LIST, (pg: PhotographerMeta, idx: number) => {
Row() {
Text(String(idx + 1)).fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.accent : COLORS.textHint)
.width(32).textAlign(TextAlign.Center)
Text(pg.avatar).fontSize(28).margin({ right: 10 })
排行榜列表展示全部8位摄影师,每位一行。每行的左侧是排名数字,18号粗体字,宽度32dp,居中对齐。排名数字的颜色根据名次变化:前三名用强调色(橙色),其余用提示色(浅灰)。这种设计让前三名在列表中仍然保持视觉上的突出地位。
排名数字右边是头像emoji,28号字体。再往右是摄影师的详细信息。
Column() {
Text(pg.name).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(pg.specialty + ' · ' + pg.works + '作品').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
中间信息区有两行:摄影师姓名和专长+作品数。专长字段用"·"与作品数分隔,信息密度高但不杂乱。
Column() {
Text(pg.followers).fontSize(12).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Bold)
Text('粉丝').fontSize(8).fontColor(COLORS.textHint).margin({ top: 1 })
}
.alignItems(HorizontalAlign.End).margin({ right: 10 })
粉丝数区域也是两行结构:上面是粉丝数(粗体),下面是"粉丝"标签(小字提示色)。这种大数字+小标签的设计让粉丝数这个关键指标更加突出。
Text(pg.isFollow ? '已关注' : '关注').fontSize(11)
.fontColor(pg.isFollow ? COLORS.textHint : COLORS.white)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(getFollowColor(pg.isFollow)).borderRadius(12)
}
.width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
})
}
最右侧是关注按钮。按钮的文字和背景色根据关注状态动态变化:已关注时是灰色背景+灰色文字(视觉上较弱),未关注时是橙色背景+白色文字(视觉上较强)。
这种设计的交互意图很明显:对于未关注的人,用醒目的橙色按钮引导用户点击关注;对于已关注的人,用弱化的样式表示"已完成"状态,减少视觉干扰。
每行之间用底部分割线分隔,保持列表的清晰结构。
8.4 器材页组件 PhotoGearContent
8.4.1 编辑相机弹窗
@Component
struct PhotoGearContent {
@State showEditCameraModal: boolean = false
@State editModel: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
器材页的弹窗是编辑相机信息弹窗。与首页的新增作品弹窗类似,也有一个modalOverlay遮罩层构建器。实际上,三个页面(首页、器材页、我的页)都有各自的modalOverlay构建器,代码几乎完全一样。这是一个可以进一步优化的点——可以将modalOverlay抽离成一个公共组件,减少重复代码。
@Builder editCameraModal() {
Stack() {
this.modalOverlay(() => { this.showEditCameraModal = false })
Column() {
Row() {
Text('✏️ 编辑相机信息').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showEditCameraModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(COLORS.border)
编辑相机弹窗的标题是"✏️ 编辑相机信息",与新增作品弹窗的结构基本一致:标题栏+分割线+表单内容+操作按钮。
Column() {
Text('型号').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
TextInput({ placeholder: '如 A7R5' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.editModel = v })
Text('传感器').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 全画幅' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('像素').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 6100万' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
表单包含三个输入项:型号、传感器、像素。每个输入项的样式与新增作品弹窗保持一致——暖色调背景、圆角、占位符示例提示。
只有型号输入框绑定了onChange事件(同步到editModel状态),传感器和像素输入框没有绑定。这可能是因为当前版本只是演示用途,没有完整实现编辑功能。在真实产品中,所有输入项都应该绑定对应的状态变量。
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.warm).borderRadius(8)
.onClick(() => { this.showEditCameraModal = false })
Column().width(12)
Text('保存').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.primary).borderRadius(8)
.onClick(() => { this.showEditCameraModal = false })
}
.margin({ left: 20, right: 20, top: 20, bottom: 20 })
}
.constraintSize({ maxHeight: '70%' })
底部的操作按钮也是"取消"+"保存"的组合。但注意这里的主按钮(保存)使用的是primary色(深灰蓝),而不是accent色(橙色)。这与新增作品弹窗的主按钮颜色不同——新增用橙色,编辑用主色。
这种颜色区分可能是有意为之的设计:新增操作是"创建",用充满活力的橙色;编辑操作是"修改",用稳重的主色调。虽然只是颜色的细微差别,但体现了对不同操作类型语义的思考。
8.4.2 器材列表与统计
build() {
Stack() {
Scroll() {
Column() {
Text('🎥 我的器材库').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })
ForEach(CAMERA_LIST, (c: CameraMeta) => {
Row() {
Stack() {
Column().width(48).height(48).borderRadius(12)
.backgroundColor(c.isMain ? COLORS.warm : COLORS.border)
Text(c.emoji).fontSize(28)
}
.width(48).height(48).margin({ right: 12 })
器材列表的页面标题是"🎥 我的器材库"。列表项左侧是48x48dp的图标区域,背景色根据是否为主力相机而不同:主力相机用暖色调(warm)背景,非主力用边框色(border)背景。这种视觉区分让用户快速识别出常用的主力相机。
Column() {
Text(c.brand + ' ' + c.model).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(c.sensor + ' · ' + c.pixels).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
if (c.isMain) {
Text('主力').fontSize(9).fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 3, bottom: 3 })
.backgroundColor(COLORS.accent).borderRadius(4)
.margin({ right: 8 })
}
Text('✏️').fontSize(16).onClick(() => { this.showEditCameraModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })
.border({ width: { bottom: 1 }, color: COLORS.border })
中间信息区显示品牌+型号(第一行)和传感器+像素(第二行)。品牌和型号用空格拼接,传感器和像素用"·"分隔。
如果是主力相机,会显示一个橙色的"主力"小标签,9号白色文字,4dp圆角,非常小巧醒目。
最右侧是编辑按钮(铅笔emoji),点击后弹出编辑相机信息弹窗。每一行都有独立的编辑入口,用户可以直接编辑对应的相机信息。
// 器材统计
Column() {
Text('📈 器材统计').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 12 })
Row() {
Column() {
Text('8').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
Text('相机').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center).layoutWeight(1)
Column() {
Text('12').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
Text('镜头').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center).layoutWeight(1)
Column() {
Text('5').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.success)
Text('配件').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center).layoutWeight(1)
}
.width('100%').padding({ top: 8, bottom: 16 })
}
器材统计区域用三列展示相机、镜头、配件的数量统计。每个统计项都是大数字+小标签的结构,28号粗体数字非常醒目。
三个数字使用不同的颜色:相机用橙色(accent)、镜头用主色(primary)、配件用绿色(success)。这种颜色区分增加了视觉的丰富度,也让三类器材更容易区分。
不过需要注意的是,这些统计数字是硬编码的(8、12、5),而不是根据CAMERA_LIST等数据动态计算的。在真实产品中,这些数字应该从数据中动态计算得出,确保数据一致性。
8.5 我的页组件 PhotoMineContent
8.5.1 删除相册弹窗
@Component
struct PhotoMineContent {
@State showDeleteAlbumModal: boolean = false
@State deleteName: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
我的页面的弹窗是删除相册的确认弹窗。与前两个弹窗不同,这是一个危险操作的确认弹窗,设计上需要特别注意防止误操作。
@Builder deleteAlbumModal() {
Stack() {
this.modalOverlay(() => { this.showDeleteAlbumModal = false })
Column() {
Row() {
Text('🗑️ 删除相册').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showDeleteAlbumModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(COLORS.border)
Column() {
Text('确定要删除该相册吗?').fontSize(14).fontColor(COLORS.textPrimary)
.margin({ top: 20, left: 20 })
Text('删除后无法恢复,相册中的照片将移至回收站。').fontSize(11).fontColor(COLORS.textSecondary)
.margin({ top: 8, left: 20, right: 20 })
删除确认弹窗的内容区域包含两段文字。第一段是确认问题:“确定要删除该相册吗?”,使用主要文字色,字号14号,让用户清晰地看到确认信息。
第二段是警告说明:“删除后无法恢复,相册中的照片将移至回收站。”,使用次要文字色,字号11号。这段文字告诉用户删除操作的后果——数据会移至回收站而不是立即永久删除,减轻用户的焦虑感,同时也明确了操作的不可逆性(在回收站清空之前还是可以恢复的)。
这种"确认问题+后果说明"的两段式文案是危险操作确认弹窗的标准做法,既保证了用户知情,又提供了足够的警示。
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.warm).borderRadius(8)
.onClick(() => { this.showDeleteAlbumModal = false })
Column().width(12)
Text('删除').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.danger).borderRadius(8)
.onClick(() => { this.showDeleteAlbumModal = false })
}
.margin({ left: 20, right: 20, top: 20, bottom: 20 })
}
.constraintSize({ maxHeight: '70%' })
底部按钮的设计也体现了危险操作的特点:"删除"按钮使用danger色(红色),而不是之前的橙色或主色。红色是危险/警告的标准语义色,用户看到红色按钮会本能地更加谨慎,有效防止误删。
"取消"按钮仍然是暖色调背景+次要文字色,作为安全的次要操作。将安全的"取消"放在左边、危险的"删除"放在右边,也是符合用户操作习惯的布局——右手持机时,右边更容易误触,但红色的警告色会起到提醒作用。
8.5.2 个人资料头卡
build() {
Stack() {
Scroll() {
Column() {
// 个人资料头卡
Row() {
Stack() {
Column().width(60).height(60).borderRadius(30)
.linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
Text('🧑🎨').fontSize(36)
}
.width(60).height(60).margin({ right: 14 })
个人中心页面的顶部是个人资料头卡,与首页头卡类似,使用渐变背景+底部大圆角的设计。头卡内容使用Row水平布局,左侧是头像,右侧是个人信息。
头像大小为60x60dp,圆形(30dp圆角),渐变背景+emoji的设计风格与其他页面保持一致。36号的emoji在60dp的圆中居中显示,大小适中。
Column() {
Text('光影捕手').fontSize(16).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('Lv.8 资深摄影师').fontSize(10).fontColor('#B0BEC5').margin({ top: 2 })
Row() {
Text('386 作品').fontSize(10).fontColor('#B0BEC5')
Text(' · ').fontSize(10).fontColor('#B0BEC5')
Text('12.8万 粉丝').fontSize(10).fontColor('#B0BEC5')
}
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%')
.padding({ left: 20, right: 20, top: 20, bottom: 20 })
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
个人信息区域有三行:用户名(16号白色粗体)、等级称号(10号浅灰色)、作品数+粉丝数(10号浅灰色,用"·"分隔)。
等级系统(Lv.8 资深摄影师)是社区类应用的常见功能,通过等级标识用户的活跃度和贡献度,激励用户持续创作和参与社区互动。
头卡的整体样式与首页头卡保持一致——135度渐变背景、底部大圆角(24dp)、深色背景上的白色/浅色文字。这种设计一致性让整个应用的视觉风格更加统一。
8.5.3 相册列表
// 我的相册
Column() {
Text('📁 我的相册').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
ForEach(ALBUM_LIST, (a: AlbumMeta) => {
Row() {
Stack() {
Column().width(44).height(44).borderRadius(10)
.backgroundColor(getLockBg(a.isLock))
Text(a.emoji).fontSize(26)
}
.width(44).height(44).margin({ right: 12 })
我的相册列表展示用户创建的所有相册。每个相册项左侧是44x44dp的图标区域,背景色根据是否加密动态变化:加密相册用暖色调(warm)背景,未加密用边框色(border)背景。
这种颜色区分让加密相册在列表中更容易被识别,也暗示了它们的特殊性(需要密码或指纹才能访问)。
Column() {
Text(a.name).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(a.count + ' · ' + a.date).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
if (a.isLock) {
Text('🔒').fontSize(14).margin({ right: 8 })
}
Text('🗑️').fontSize(16).onClick(() => { this.showDeleteAlbumModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
})
}
中间信息区显示相册名称和照片数量+创建日期。如果相册是加密的,还会显示一个锁图标(🔒),进一步强化加密的视觉标识。
最右侧是删除按钮(垃圾桶emoji),点击后弹出删除确认弹窗。每个相册都有独立的删除入口,方便用户管理相册。
删除按钮直接放在每行的最右侧,点击即可触发删除操作。不过从交互安全的角度来看,这种设计可能存在误触的风险——用户可能在滑动列表时不小心点击到删除按钮。更好的做法可能是将删除操作放在左滑菜单或详情页中,减少误触概率。
九、弹窗组件详解
9.1 modalOverlay的实现原理
三个页面的弹窗都使用了相同的modalOverlay模式。虽然每个页面都有自己的modalOverlay构建器(这是代码重复,可以优化),但实现原理是一致的。
modalOverlay的核心是一个全屏的半透明黑色遮罩层,它有两个作用:
- 视觉上降低背景内容的亮度,让弹窗更加突出
- 交互上捕获点击事件,点击遮罩层可以关闭弹窗
遮罩层使用Column组件,设置宽高为100%铺满全屏,背景色为rgba(0,0,0,0.55)——55%不透明度的黑色。这个透明度经过精心设计:足够暗以突出弹窗,但又不会完全遮挡背景内容,保持了界面的上下文感。
从实现架构来看,弹窗使用Stack布局,包含两层:
- 底层:modalOverlay遮罩层(全屏)
- 上层:弹窗内容容器(居中显示)
Stack的堆叠特性自然地实现了弹窗浮在内容之上的效果。遮罩层的onClick事件绑定关闭逻辑,弹窗内容本身不绑定关闭事件,这样点击弹窗内部不会关闭,只有点击外部遮罩才会关闭——这是弹窗交互的标准行为。
9.2 三种弹窗的设计模式对比
这款应用包含三种类型的弹窗:新增作品、编辑相机、删除相册。它们虽然内容不同,但遵循相同的设计模式:
| 弹窗类型 | 标题图标 | 主按钮颜色 | 主按钮文字 | 表单字段数 |
|---|---|---|---|---|
| 新增作品 | 📷 | 橙色(accent) | 上传 | 3 |
| 编辑相机 | ✏️ | 深灰蓝(primary) | 保存 | 3 |
| 删除相册 | 🗑️ | 红色(danger) | 删除 | 0(确认文案) |
三种弹窗对应三种操作类型:创建、修改、删除。主按钮的颜色分别使用了强调色(创建)、主色(修改)、危险色(删除)。这种用颜色区分操作类型的设计非常专业,用户通过按钮颜色就能直觉地感知操作的性质和风险等级。
新增类操作使用充满活力的橙色,鼓励用户创建新内容;编辑类操作使用稳重的主色调,表示这是一个常规的修改操作;删除类操作使用警示性的红色,提醒用户这是危险操作,需要谨慎确认。
从表单复杂度来看,新增和编辑弹窗都包含表单输入,删除弹窗只有确认文字。这也符合各自的功能定位——创建和修改需要用户输入信息,删除只需要确认意图。
9.3 弹窗交互流程图
十、核心UI组件分析
10.1 头卡组件
头卡组件是这款应用中最具视觉特色的组件之一,在首页和我的页都有使用。它的设计特点包括:
渐变背景:使用135度角的线性渐变,从主色渐变到深主色,营造出深邃而专业的视觉效果。渐变比纯色背景更有层次感和品质感。
底部大圆角:底部左右两个角使用28dp(首页)或24dp(我的页)的大圆角,顶部是直角。这种"上平下圆"的形状像一个倒置的圆角矩形,视觉上很有辨识度。
文字层级:深色背景上使用白色主文字+浅灰辅助文字的配色方案,确保良好的可读性。文字大小和字重的差异建立了清晰的信息层级。
功能差异:首页头卡主要展示精选作品信息,包含作品标题、作者、点赞数、曝光参数等;我的页头卡主要展示个人信息,包含用户名、等级、作品数、粉丝数等。相同的视觉框架承载不同的内容,体现了组件的复用性。
首页头卡的曝光参数浮层是一大亮点——将ISO、快门速度等专业参数以半透明胶囊标签的形式展示,既专业又美观,是摄影类应用的差异化设计。
10.2 柱状图组件
周拍摄柱状图是一个纯代码实现的轻量级图表组件,没有依赖任何第三方库。它的实现原理是:
底层背景柱:一个固定高度(90dp)的灰色Column,作为柱状图的背景槽,代表100%的高度。
上层数据柱:一个高度由数据决定的渐变Column,从底部向上生长。高度 = 数据值 × 比例系数(6)。
Stack堆叠 + Bottom对齐:使用Stack将背景柱和数据柱堆叠在一起,对齐方式设为Bottom,让数据柱从底部开始向上延伸,形成柱状图的视觉效果。
渐变填充:数据柱使用垂直渐变(angle: 0),从浅色(accentLight)渐变到深色(accent),增加了柱子的立体感和视觉品质。
这种纯基础组件实现的图表虽然功能简单(不支持交互、动画、多数据系列等),但对于展示简单的趋势数据来说已经足够。它的优势在于:零依赖、体积小、完全可控、样式可深度定制。
10.3 卡片组件
卡片是这款应用中最常用的UI组件之一,几乎每个模块的内容都放在卡片中。卡片的统一设计风格是:
- 白色背景(cardBg)
- 16dp圆角(大卡片)或12dp圆角(小卡片)
- 左右各12dp外边距
- 顶部12dp外边距
卡片设计的好处是显而易见的:
- 视觉分层:卡片将内容组织成独立的区块,页面结构清晰,用户容易理解
- 信息聚焦:每张卡片承载一个主题的内容,用户的注意力自然地被卡片框住
- 触摸友好:卡片之间有间距,手指点击时不容易误触相邻卡片
- 美观统一:统一的卡片样式让整个应用看起来整洁、专业
从布局角度看,卡片式设计配合Scroll滚动视图,形成了"一屏多卡,滚动浏览"的页面结构。用户通过垂直滚动浏览一张张卡片,每张卡片内的内容相对独立和完整。
10.4 列表项组件
列表项是另一种大量使用的UI模式,在作品列表、摄影师排行、器材列表、相册列表中都有应用。
标准的列表项结构是:左侧图标 + 中间文字信息 + 右侧操作/辅助信息。这种"左中右"的三栏布局是移动端列表的经典设计模式。
各部分的详细设计:
- 左侧图标:通常是渐变色块+emoji的组合,大小从40dp到52dp不等,圆角8-12dp
- 中间信息:通常是2-3行文字,字号逐级减小,颜色逐级变浅,形成清晰的信息层级
- 右侧区域:可能是数字、标签、按钮、状态指示等,根据业务需求灵活变化
列表项之间通常用底部分割线分隔(border底部1dp),或者用卡片背景+间距的方式分隔。分割线的颜色是border色(非常浅的灰),既起到了分隔作用,又不会太突兀。
十一、技术亮点总结表格
| 功能模块 | 技术实现方式 | 设计模式 | 代码量估计 | 复用性 |
|---|---|---|---|---|
| 颜色系统 | interface + const常量 | 配置驱动 | 约50行 | 高,可直接复用于其他应用 |
| 数据模型 | TypeScript interface | 类型安全 | 约60行 | 高,前后端联调的契约 |
| Tab导航 | @State + @Builder + enum | 状态驱动 | 约60行 | 高,标准Tab模式 |
| 弹窗系统 | Stack + 条件渲染 + modalOverlay | 组合模式 | 约150行/页 | 中,需抽离公共遮罩层 |
| 柱状图组件 | Stack堆叠 + 渐变Column | 纯代码绘制 | 约30行 | 中,需适配数据格式 |
| 头卡组件 | 渐变背景 + 底部大圆角 | 视觉容器 | 约60行 | 中,内容需自定义 |
| 列表组件 | ForEach + Row三栏布局 | 列表渲染 | 约40行/个 | 高,标准列表模式 |
| 卡片组件 | 白色背景 + 圆角 + 外边距 | 容器模式 | 约10行/个 | 高,通用容器 |
| 标签组件 | 圆角背景 + 文字 | 轻量组件 | 约5行/个 | 高,通用标签 |
| 状态管理 | @State装饰器 | 局部状态 | 每个组件2-5个 | 中,仅组件内使用 |
| Mock数据 | 顶层const数组 | 数据驱动 | 约150行 | 中,业务相关 |
| 工具函数 | 顶层function | 逻辑复用 | 约15行 | 高,通用颜色计算 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// ============================================================
// 534.ets 摄影社区 APP — PhotoApp
// 主色调: 镜头灰 #455A64 + 胶片橙 #FF6F00
// 布局: 作品大图+曝光参数浮层头卡 + 周拍摄柱状图
// + 热门作品瀑布流 + 摄影师排行 + 相机库 + 我的
// 弹窗: 新增作品 / 编辑相机 / 删除相册 (全部 modalOverlay)
// ============================================================
interface PhotoColorPalette {
primary: string
primaryLight: string
primaryDark: string
accent: string
accentLight: string
warm: string
bg: string
cardBg: string
textPrimary: string
textSecondary: string
textHint: string
border: string
success: string
warning: string
danger: string
white: string
}
interface TabMeta {
title: string
icon: string
}
interface PhotoMeta {
title: string
author: string
avatar: string
likes: number
camera: string
lens: string
aperture: string
iso: string
isHot: boolean
}
interface WeekShotMeta {
label: string
value: number
}
interface PhotographerMeta {
name: string
avatar: string
followers: string
works: string
specialty: string
isFollow: boolean
}
interface CameraMeta {
brand: string
model: string
emoji: string
sensor: string
pixels: string
isMain: boolean
}
interface AlbumMeta {
name: string
emoji: string
count: string
date: string
isLock: boolean
}
interface TagMeta {
name: string
count: string
isHot: boolean
}
interface ContestMeta {
title: string
emoji: string
deadline: string
prize: string
isJoin: boolean
}
const COLORS: PhotoColorPalette = {
primary: '#455A64',
primaryLight: '#78909C',
primaryDark: '#263238',
accent: '#FF6F00',
accentLight: '#FFB74D',
warm: '#FFF3E0',
bg: '#FAFAFA',
cardBg: '#FFFFFF',
textPrimary: '#263238',
textSecondary: '#607D8B',
textHint: '#B0BEC5',
border: '#ECEFF1',
success: '#43A047',
warning: '#FB8C00',
danger: '#E53935',
white: '#FFFFFF'
}
const TAB_CONFIG: Record<string, TabMeta> = {
'HOME': { title: '精选', icon: '📷' },
'GALLERY': { title: '作品', icon: '🖼️' },
'RANK': { title: '排行', icon: '🏆' },
'GEAR': { title: '器材', icon: '🎥' },
'MINE': { title: '我的', icon: '👤' }
}
const PHOTO_LIST: PhotoMeta[] = [
{ title: '城市天际线', author: '光影捕手', avatar: '🧑🎨', likes: 3280, camera: 'Sony A7R5', lens: '24-70mm', aperture: 'f/8', iso: 'ISO 100', isHot: true },
{ title: '雾中山林', author: '自然之眼', avatar: '🌲', likes: 2510, camera: 'Canon R5', lens: '70-200mm', aperture: 'f/4', iso: 'ISO 200', isHot: true },
{ title: '街角黄昏', author: '行走的镜头', avatar: '🚶', likes: 1860, camera: 'Fuji X-T5', lens: '35mm', aperture: 'f/2.8', iso: 'ISO 400', isHot: false },
{ title: '极光之夜', author: '星空猎人', avatar: '🌌', likes: 4120, camera: 'Nikon Z9', lens: '14-24mm', aperture: 'f/2.8', iso: 'ISO 3200', isHot: true },
{ title: '微观花蕊', author: '微距世界', avatar: '🌸', likes: 980, camera: 'Olympus OM-1', lens: '90mm Macro', aperture: 'f/5.6', iso: 'ISO 200', isHot: false },
{ title: '逆光人像', author: '光影捕手', avatar: '🧑🎨', likes: 2150, camera: 'Sony A7R5', lens: '85mm', aperture: 'f/1.4', iso: 'ISO 100', isHot: false },
{ title: '延时云海', author: '云端漫步', avatar: '☁️', likes: 3680, camera: 'Canon R5', lens: '16-35mm', aperture: 'f/11', iso: 'ISO 100', isHot: true },
{ title: '黑白街头', author: '行走的镜头', avatar: '🚶', likes: 1420, camera: 'Leica Q3', lens: '28mm', aperture: 'f/2', iso: 'ISO 800', isHot: false },
{ title: '日落剪影', author: '光影捕手', avatar: '🧑🎨', likes: 2890, camera: 'Sony A7R5', lens: '70-200mm', aperture: 'f/4', iso: 'ISO 100', isHot: true },
{ title: '雨后霓虹', author: '夜色猎人', avatar: '🌃', likes: 1670, camera: 'Fuji X-T5', lens: '23mm', aperture: 'f/1.4', iso: 'ISO 1600', isHot: false },
{ title: '雪山星轨', author: '星空猎人', avatar: '🌌', likes: 5230, camera: 'Nikon Z9', lens: '14-24mm', aperture: 'f/2.8', iso: 'ISO 6400', isHot: true },
{ title: '复古胶片', author: '怀旧派', avatar: '📼', likes: 1230, camera: 'Pentax K-1', lens: '50mm', aperture: 'f/1.8', iso: 'ISO 400', isHot: false }
]
const WEEK_SHOT: WeekShotMeta[] = [
{ label: '周一', value: 4 },
{ label: '周二', value: 7 },
{ label: '周三', value: 5 },
{ label: '周四', value: 8 },
{ label: '周五', value: 11 },
{ label: '周六', value: 14 },
{ label: '周日', value: 9 }
]
const PHOTOGRAPHER_LIST: PhotographerMeta[] = [
{ name: '光影捕手', avatar: '🧑🎨', followers: '12.8万', works: '386', specialty: '风光·城市', isFollow: true },
{ name: '星空猎人', avatar: '🌌', followers: '9.6万', works: '215', specialty: '星空·延时', isFollow: true },
{ name: '自然之眼', avatar: '🌲', followers: '8.2万', works: '428', specialty: '自然·生态', isFollow: false },
{ name: '行走的镜头', avatar: '🚶', followers: '6.5万', works: '512', specialty: '街头·人文', isFollow: false },
{ name: '微距世界', avatar: '🌸', followers: '4.1万', works: '189', specialty: '微距·花卉', isFollow: false },
{ name: '云端漫步', avatar: '☁️', followers: '3.8万', works: '156', specialty: '风光·延时', isFollow: false },
{ name: '夜色猎人', avatar: '🌃', followers: '3.2万', works: '98', specialty: '夜景·城市', isFollow: false },
{ name: '怀旧派', avatar: '📼', followers: '2.1万', works: '67', specialty: '胶片·复古', isFollow: false }
]
const CAMERA_LIST: CameraMeta[] = [
{ brand: 'Sony', model: 'A7R5', emoji: '📸', sensor: '全画幅', pixels: '6100万', isMain: true },
{ brand: 'Canon', model: 'EOS R5', emoji: '📸', sensor: '全画幅', pixels: '4500万', isMain: false },
{ brand: 'Nikon', model: 'Z9', emoji: '📸', sensor: '全画幅', pixels: '4570万', isMain: false },
{ brand: 'Fuji', model: 'X-T5', emoji: '📸', sensor: 'APS-C', pixels: '4020万', isMain: false },
{ brand: 'Olympus', model: 'OM-1', emoji: '📸', sensor: 'M4/3', pixels: '2000万', isMain: false },
{ brand: 'Leica', model: 'Q3', emoji: '📸', sensor: '全画幅', pixels: '6000万', isMain: false },
{ brand: 'Pentax', model: 'K-1', emoji: '📸', sensor: '全画幅', pixels: '3600万', isMain: false },
{ brand: 'Sigma', model: 'fp L', emoji: '📸', sensor: '全画幅', pixels: '6100万', isMain: false }
]
const ALBUM_LIST: AlbumMeta[] = [
{ name: '城市风光', emoji: '🏙️', count: '86张', date: '2024-08', isLock: false },
{ name: '自然风光', emoji: '🏔️', count: '124张', date: '2024-07', isLock: false },
{ name: '人像作品', emoji: '👤', count: '52张', date: '2024-06', isLock: true },
{ name: '街头摄影', emoji: '🛣️', count: '98张', date: '2024-06', isLock: false },
{ name: '星空银河', emoji: '🌌', count: '35张', date: '2024-05', isLock: false },
{ name: '微距世界', emoji: '🐝', count: '67张', date: '2024-05', isLock: false },
{ name: '旅行记录', emoji: '✈️', count: '215张', date: '2024-04', isLock: false },
{ name: '黑白系列', emoji: '⚫', count: '43张', date: '2024-03', isLock: true },
{ name: '胶片回忆', emoji: '📼', count: '28张', date: '2024-02', isLock: false },
{ name: '延时合集', emoji: '⏱️', count: '19张', date: '2024-01', isLock: false }
]
const TAG_LIST: TagMeta[] = [
{ name: '#风光', count: '2.3万', isHot: true },
{ name: '#人像', count: '1.8万', isHot: true },
{ name: '#街头', count: '1.2万', isHot: false },
{ name: '#星空', count: '8560', isHot: true },
{ name: '#微距', count: '6230', isHot: false },
{ name: '#黑白', count: '5810', isHot: false },
{ name: '#胶片', count: '4320', isHot: false },
{ name: '#延时', count: '3180', isHot: false }
]
const CONTEST_LIST: ContestMeta[] = [
{ title: '城市光影大赛', emoji: '🏙️', deadline: '08-30', prize: '¥10,000', isJoin: true },
{ title: '夏日风光征稿', emoji: '☀️', deadline: '09-15', prize: '¥5,000', isJoin: false },
{ title: '微距世界挑战', emoji: '🐝', deadline: '09-01', prize: '¥3,000', isJoin: false },
{ title: '星空摄影月赛', emoji: '🌌', deadline: '08-25', prize: '¥8,000', isJoin: true },
{ title: '人文纪实专题', emoji: '📖', deadline: '10-01', prize: '¥6,000', isJoin: false },
{ title: '黑白极简大赛', emoji: '⚫', deadline: '09-20', prize: '¥4,000', isJoin: false }
]
function getHotColor(isHot: boolean): string {
return isHot ? COLORS.accent : COLORS.primaryLight
}
function getFollowColor(isFollow: boolean): string {
return isFollow ? COLORS.border : COLORS.accent
}
function getLockBg(isLock: boolean): string {
return isLock ? COLORS.warm : COLORS.border
}
enum PhotoTab {
HOME,
GALLERY,
RANK,
GEAR,
MINE
}
@Entry
@Component
struct PhotoApp {
@State activeTab: PhotoTab = PhotoTab.HOME
@Builder contentArea() {
Column() {
if (this.activeTab === PhotoTab.HOME) {
PhotoHomeContent()
} else if (this.activeTab === PhotoTab.GALLERY) {
PhotoGalleryContent()
} else if (this.activeTab === PhotoTab.RANK) {
PhotoRankContent()
} else if (this.activeTab === PhotoTab.GEAR) {
PhotoGearContent()
} else if (this.activeTab === PhotoTab.MINE) {
PhotoMineContent()
}
}
.width('100%')
}
@Builder tabItem(icon: string, label: string, tab: PhotoTab) {
Column() {
Text(icon).fontSize(20)
Text(label).fontSize(10)
.fontColor(this.activeTab === tab ? COLORS.accent : COLORS.textHint)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => { this.activeTab = tab })
}
build() {
Column() {
this.contentArea()
Row() {
this.tabItem(TAB_CONFIG.HOME.icon, TAB_CONFIG.HOME.title, PhotoTab.HOME)
this.tabItem(TAB_CONFIG.GALLERY.icon, TAB_CONFIG.GALLERY.title, PhotoTab.GALLERY)
this.tabItem(TAB_CONFIG.RANK.icon, TAB_CONFIG.RANK.title, PhotoTab.RANK)
this.tabItem(TAB_CONFIG.GEAR.icon, TAB_CONFIG.GEAR.title, PhotoTab.GEAR)
this.tabItem(TAB_CONFIG.MINE.icon, TAB_CONFIG.MINE.title, PhotoTab.MINE)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.white)
.shadow({ radius: 10, color: '#455A6420', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor(COLORS.bg)
}
}
@Component
struct PhotoHomeContent {
@State showAddPhotoModal: boolean = false
@State photoTitle: string = ''
@State photoCamera: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
@Builder addPhotoModal() {
Stack() {
this.modalOverlay(() => { this.showAddPhotoModal = false })
Column() {
Row() {
Text('📷 新增作品').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddPhotoModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(COLORS.border)
Column() {
Text('作品标题').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
TextInput({ placeholder: '如 城市天际线' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.photoTitle = v })
Text('拍摄器材').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 Sony A7R5' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.photoCamera = v })
Text('拍摄地点').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 上海外滩' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.warm).borderRadius(8)
.onClick(() => { this.showAddPhotoModal = false })
Column().width(12)
Text('上传').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.accent).borderRadius(8)
.onClick(() => { this.showAddPhotoModal = false })
}
.margin({ left: 20, right: 20, top: 20, bottom: 20 })
}
.constraintSize({ maxHeight: '70%' })
}
.width('88%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
build() {
Stack() {
Scroll() {
Column() {
// 作品大图+曝光参数浮层头卡
Stack({ alignContent: Alignment.BottomStart }) {
Column()
.width('100%').height(220)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 28, bottomRight: 28 })
Column() {
Row() {
Column() {
Text('📷 今日精选').fontSize(11).fontColor('#B0BEC5')
Text('城市天际线').fontSize(24).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
.margin({ top: 4 })
Text('by 光影捕手').fontSize(11).fontColor('#B0BEC5').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('3,280').fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
Text('❤️ 点赞').fontSize(9).fontColor('#B0BEC5').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
}
.padding({ left: 20, right: 20, top: 40 })
// 曝光参数浮层
Row() {
Column() {
Text('Sony A7R5').fontSize(10).fontColor(COLORS.white)
Text('24-70mm f/8').fontSize(9).fontColor('#B0BEC5').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Row() {
Text('ISO 100').fontSize(9).fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('rgba(255,255,255,0.15)').borderRadius(8)
Column().width(6)
Text('1/250s').fontSize(9).fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('rgba(255,255,255,0.15)').borderRadius(8)
}
}
.width('100%')
.padding({ left: 20, right: 20, top: 30, bottom: 16 })
}
}
.width('100%').height(220)
// 周拍摄柱状图
Column() {
Text('📊 本周拍摄(张)').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16 })
Row() {
ForEach(WEEK_SHOT, (w: WeekShotMeta) => {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Column().width(22).height(90)
.backgroundColor(COLORS.border)
.borderRadius(4)
Column().width(22).height(Number(w.value) * 6)
.linearGradient({ angle: 0, colors: [[COLORS.accentLight, 0.0], [COLORS.accent, 1.0]] })
.borderRadius(4)
}
.width(22).height(90)
Text(w.label).fontSize(9).fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12 })
// 热门标签
Column() {
Text('🔥 热门标签').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(TAG_LIST, (t: TagMeta) => {
Row() {
Text(t.name).fontSize(12).fontColor(getHotColor(t.isHot))
Text(' ' + t.count).fontSize(9).fontColor(COLORS.textHint)
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.warm).borderRadius(20)
.margin({ right: 8, bottom: 8 })
})
}
.width('100%').padding({ left: 16, right: 16 })
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12 })
// 比赛活动
Column() {
Text('🏆 摄影大赛').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
ForEach(CONTEST_LIST, (c: ContestMeta) => {
Row() {
Text(c.emoji).fontSize(28).margin({ right: 12 })
Column() {
Text(c.title).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text('截止 ' + c.deadline + ' · 奖金 ' + c.prize).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(c.isJoin ? '已参与' : '参与').fontSize(11)
.fontColor(c.isJoin ? COLORS.textHint : COLORS.white)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(c.isJoin ? COLORS.border : COLORS.accent).borderRadius(12)
}
.width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 })
})
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12 })
// 精选作品列表
Column() {
Text('✨ 精选作品').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
ForEach(PHOTO_LIST, (p: PhotoMeta) => {
Row() {
Stack() {
Column().width(52).height(52).borderRadius(12)
.linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
Text(p.avatar).fontSize(28)
}
.width(52).height(52).margin({ right: 12 })
Column() {
Text(p.title).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(p.author + ' · ' + p.camera).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
Text(p.lens + ' ' + p.aperture + ' ' + p.iso).fontSize(9).fontColor(COLORS.textHint).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('❤️ ' + String(p.likes)).fontSize(10).fontColor(getHotColor(p.isHot))
if (p.isHot) {
Text('🔥 热门').fontSize(8).fontColor(COLORS.white)
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
.backgroundColor(COLORS.accent).borderRadius(4)
.margin({ top: 4 })
}
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
})
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12, bottom: 12 })
// 新增按钮
Row() {
Text('+ 上传新作品').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.accent).borderRadius(12)
.onClick(() => { this.showAddPhotoModal = true })
}
.width('92%')
.margin({ bottom: 20 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
if (this.showAddPhotoModal) {
this.addPhotoModal()
}
}
.width('100%').height('100%')
}
}
@Component
struct PhotoGalleryContent {
build() {
Scroll() {
Column() {
// 瀑布流双列
Text('🖼️ 全部作品').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(PHOTO_LIST, (p: PhotoMeta) => {
Column() {
Stack() {
Column().width('100%').height(120)
.linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ topLeft: 12, topRight: 12 })
Text(p.avatar).fontSize(40)
}
.width('100%').height(120)
Column() {
Text(p.title).fontSize(12).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(p.author).fontSize(9).fontColor(COLORS.textSecondary).margin({ top: 2 })
Row() {
Text('❤️ ' + String(p.likes)).fontSize(9).fontColor(COLORS.accent)
Column().layoutWeight(1)
if (p.isHot) {
Text('🔥').fontSize(10)
}
}
.width('100%').margin({ top: 4 })
}
.width('100%').padding({ left: 8, right: 8, top: 6, bottom: 8 })
}
.width('48%')
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ bottom: 10 })
})
}
.width('92%')
.padding({ top: 4 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
}
}
@Component
struct PhotoRankContent {
build() {
Scroll() {
Column() {
Text('🏆 摄影师排行').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })
// 前三名
Row() {
ForEach(PHOTOGRAPHER_LIST.slice(0, 3), (pg: PhotographerMeta, idx: number) => {
Column() {
Stack() {
Column().width(56).height(56).borderRadius(28)
.backgroundColor(idx === 0 ? '#FFD700' : idx === 1 ? '#C0C0C0' : '#CD7F32')
Text(pg.avatar).fontSize(32)
}
.width(56).height(56).margin({ bottom: 6 })
Text(pg.name).fontSize(11).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Bold)
Text(pg.followers + '粉丝').fontSize(9).fontColor(COLORS.textSecondary).margin({ top: 2 })
Text(idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉').fontSize(14).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
.margin({ top: idx === 0 ? 0 : 20 })
})
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12 })
// 排行榜
Column() {
ForEach(PHOTOGRAPHER_LIST, (pg: PhotographerMeta, idx: number) => {
Row() {
Text(String(idx + 1)).fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.accent : COLORS.textHint)
.width(32).textAlign(TextAlign.Center)
Text(pg.avatar).fontSize(28).margin({ right: 10 })
Column() {
Text(pg.name).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(pg.specialty + ' · ' + pg.works + '作品').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text(pg.followers).fontSize(12).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Bold)
Text('粉丝').fontSize(8).fontColor(COLORS.textHint).margin({ top: 1 })
}
.alignItems(HorizontalAlign.End).margin({ right: 10 })
Text(pg.isFollow ? '已关注' : '关注').fontSize(11)
.fontColor(pg.isFollow ? COLORS.textHint : COLORS.white)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(getFollowColor(pg.isFollow)).borderRadius(12)
}
.width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
})
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12, bottom: 12 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
}
}
@Component
struct PhotoGearContent {
@State showEditCameraModal: boolean = false
@State editModel: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
@Builder editCameraModal() {
Stack() {
this.modalOverlay(() => { this.showEditCameraModal = false })
Column() {
Row() {
Text('✏️ 编辑相机信息').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showEditCameraModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(COLORS.border)
Column() {
Text('型号').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
TextInput({ placeholder: '如 A7R5' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.editModel = v })
Text('传感器').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 全画幅' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('像素').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 6100万' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.warm).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.warm).borderRadius(8)
.onClick(() => { this.showEditCameraModal = false })
Column().width(12)
Text('保存').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.primary).borderRadius(8)
.onClick(() => { this.showEditCameraModal = false })
}
.margin({ left: 20, right: 20, top: 20, bottom: 20 })
}
.constraintSize({ maxHeight: '70%' })
}
.width('88%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
build() {
Stack() {
Scroll() {
Column() {
Text('🎥 我的器材库').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })
ForEach(CAMERA_LIST, (c: CameraMeta) => {
Row() {
Stack() {
Column().width(48).height(48).borderRadius(12)
.backgroundColor(c.isMain ? COLORS.warm : COLORS.border)
Text(c.emoji).fontSize(28)
}
.width(48).height(48).margin({ right: 12 })
Column() {
Text(c.brand + ' ' + c.model).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(c.sensor + ' · ' + c.pixels).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
if (c.isMain) {
Text('主力').fontSize(9).fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 3, bottom: 3 })
.backgroundColor(COLORS.accent).borderRadius(4)
.margin({ right: 8 })
}
Text('✏️').fontSize(16).onClick(() => { this.showEditCameraModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })
.border({ width: { bottom: 1 }, color: COLORS.border })
})
// 器材统计
Column() {
Text('📈 器材统计').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 12 })
Row() {
Column() {
Text('8').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
Text('相机').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center).layoutWeight(1)
Column() {
Text('12').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
Text('镜头').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center).layoutWeight(1)
Column() {
Text('5').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.success)
Text('配件').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center).layoutWeight(1)
}
.width('100%').padding({ top: 8, bottom: 16 })
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12, bottom: 12 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
if (this.showEditCameraModal) {
this.editCameraModal()
}
}
.width('100%').height('100%')
}
}
@Component
struct PhotoMineContent {
@State showDeleteAlbumModal: boolean = false
@State deleteName: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
@Builder deleteAlbumModal() {
Stack() {
this.modalOverlay(() => { this.showDeleteAlbumModal = false })
Column() {
Row() {
Text('🗑️ 删除相册').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showDeleteAlbumModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(COLORS.border)
Column() {
Text('确定要删除该相册吗?').fontSize(14).fontColor(COLORS.textPrimary)
.margin({ top: 20, left: 20 })
Text('删除后无法恢复,相册中的照片将移至回收站。').fontSize(11).fontColor(COLORS.textSecondary)
.margin({ top: 8, left: 20, right: 20 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.warm).borderRadius(8)
.onClick(() => { this.showDeleteAlbumModal = false })
Column().width(12)
Text('删除').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.danger).borderRadius(8)
.onClick(() => { this.showDeleteAlbumModal = false })
}
.margin({ left: 20, right: 20, top: 20, bottom: 20 })
}
.constraintSize({ maxHeight: '70%' })
}
.width('88%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
build() {
Stack() {
Scroll() {
Column() {
// 个人资料头卡
Row() {
Stack() {
Column().width(60).height(60).borderRadius(30)
.linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
Text('🧑🎨').fontSize(36)
}
.width(60).height(60).margin({ right: 14 })
Column() {
Text('光影捕手').fontSize(16).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('Lv.8 资深摄影师').fontSize(10).fontColor('#B0BEC5').margin({ top: 2 })
Row() {
Text('386 作品').fontSize(10).fontColor('#B0BEC5')
Text(' · ').fontSize(10).fontColor('#B0BEC5')
Text('12.8万 粉丝').fontSize(10).fontColor('#B0BEC5')
}
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%')
.padding({ left: 20, right: 20, top: 20, bottom: 20 })
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
// 我的相册
Column() {
Text('📁 我的相册').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
ForEach(ALBUM_LIST, (a: AlbumMeta) => {
Row() {
Stack() {
Column().width(44).height(44).borderRadius(10)
.backgroundColor(getLockBg(a.isLock))
Text(a.emoji).fontSize(26)
}
.width(44).height(44).margin({ right: 12 })
Column() {
Text(a.name).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(a.count + ' · ' + a.date).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
if (a.isLock) {
Text('🔒').fontSize(14).margin({ right: 8 })
}
Text('🗑️').fontSize(16).onClick(() => { this.showDeleteAlbumModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
})
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12, bottom: 12 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
if (this.showDeleteAlbumModal) {
this.deleteAlbumModal()
}
}
.width('100%').height('100%')
}
}
十二、结尾总结

这款摄影社区应用虽然只是一个前端原型,但其中蕴含的架构设计思想和UI组件设计经验值得深入学习。从整体架构来看,它采用了经典的Tab导航+多页面结构,每个页面职责清晰,页面之间通过底部导航切换,交互流程直观自然。
在架构设计方面,这款应用体现了几个值得称道的优点。第一是分层清晰——数据模型层、配置常量层、工具函数层、组件层,各层职责明确,依赖关系简单。第二是类型安全——大量使用interface定义数据结构,用enum表示枚举值,配合TypeScript的类型检查,有效减少了运行时错误。第三是配置驱动——TAB_CONFIG等配置常量将UI配置从组件代码中抽离,提高了代码的可维护性和可扩展性。
在组件设计方面,这款应用也有很多亮点。@Builder自定义构建器的使用,将可复用的UI片段封装成函数,减少了代码重复。虽然目前modalOverlay在每个页面中都有重复定义,但这已经体现了组件化复用的思路,只需再进一步抽离成独立组件即可。头卡、卡片、列表项、标签等基础UI元素的设计风格统一,形成了一套完整的设计语言。
在状态管理方面,目前使用的是最基础的@State装饰器,每个组件维护自己的局部状态。对于当前这个规模的应用来说,局部状态已经足够,不需要引入更复杂的状态管理方案。这是一个很好的实践——选择最简单的能满足需求的方案,而不是盲目追求复杂的架构。
从学习价值的角度来看,这款应用非常适合ArkUI/ArkTS的初学者学习。它涵盖了声明式UI开发的核心概念:@Component组件、@State状态、@Builder构建器、ForEach列表渲染、条件渲染、Stack/Column/Row布局、事件处理等。同时,它也展示了如何从零开始构建一个完整的移动应用界面,从颜色系统到数据模型,从页面布局到交互设计,都有可以借鉴的地方。
未来可以优化的方向也很明确:一是将重复的modalOverlay抽离为公共组件,减少代码冗余;二是引入更完善的状态管理方案,实现跨页面的状态共享;三是将统计数据改为动态计算,确保数据一致性;四是增加更多的交互动效,提升用户体验;五是接入真实的后端API,让应用从原型变成真正可用的产品。
更多推荐



所有评论(0)