SwiftUI 实战:构建一个复杂的图书首页长页面
一、引言
在日常开发中,我们经常会遇到需要构建多个内容模块纵向排列的首页,尤其是图书类、视频类、资讯类 App,这种页面结构尤为常见。相比于简洁的登录页或设置页,这类长页面涉及滚动嵌套、模块拆分、布局一致性、性能优化等多个方面,对开发者提出了更高的要求。
本篇文章将以 iPhone 自带的「图书」App 为参考,带你一步步用 SwiftUI 实现一个结构清晰、组件化良好、交互自然的首页布局。
我们的页面结构包括:
- 顶部导航栏 + 右上角的「我的」按钮
- 「继续阅读」模块(横向滑动)
- 「之前读过」模块(网格或列表)
- 「阅读目标」模块(包含进度统计)
虽然功能不复杂,但它很好地代表了一个典型的长页面结构。通过这个实战例子,我们将一起探讨 SwiftUI 在应对复杂页面时的布局技巧、组件划分策略以及实际编码方式。
二、页面整体结构:PHHomeView
在图书类 App 中,首页往往需要承载多个信息区域,并支持垂直滚动浏览。在 SwiftUI 中,这种长页面非常适合用 ScrollView 加上 VStack 的组合来搭建结构化内容。
我们将内容稍微进行一下简化,只保留 “继续阅读” 和 “之前读过” 还有 “阅读目标”。
下面是我们首页的主视图 PHHomeView 的代码结构节选:
struct PHHomeView: View {@ObservedObject var presenter = PHHomePresenter.sharedvar body: some View {ScrollView {VStack(alignment: .leading, spacing: 20) {Spacer(minLength: 20)// 继续阅读区域if let readingEpisode = presenter.readingEpisode {PHHomeContinueReadView(episode: readingEpisode)}// 之前读过区域if let recentlyViewedEpisodes = presenter.recentlyViewedEpisodes,!recentlyViewedEpisodes.isEmpty {PHHomeHistoryView(recentlyViewedEpisodes: recentlyViewedEpisodes)}// 阅读目标区域PHHomeGoalView().padding(.top, 24)Spacer(minLength: 40)}}.background(Color.gray.opacity(0.2)).toolbar {ToolbarItem(placement: .topBarTrailing) {Button {// 点击“我的”} label: {Image(systemName: "person.circle").foregroundColor(.black)}}}.navigationTitle("首页").onAppear {presenter.fetchReadingEpisode()presenter.fetchRecentlyViewedEpisodes()}}
}
📌 核心结构说明:
- 整体使用 ScrollView + VStack 实现长页面滚动:所有区域按顺序依次排列,形成一个流畅的滚动体验。
- 数据驱动视图内容是否显示:「继续阅读」与「之前读过」模块都基于 presenter 的数据条件渲染。
- 模块隔离良好,每个区域都是独立 View:比如 PHHomeContinueReadView、PHHomeHistoryView、PHHomeGoalView,便于复用与维护。
- 导航栏按钮与标题通过 .toolbar 和 .navigationTitle 设置:“我的”按钮使用系统图标,放在右上角。
- 页面数据在 .onAppear 时加载:保证首页每次展示时都能拿到最新内容。
三、继续阅读区域:PHHomeContinueReadView
这个模块是一个非常典型的 SwiftUI 区块式布局,由一个标题文本和一个自定义卡片组件组成。整体采用 VStack实现垂直排列,间距、边距控制都比较标准,适合初中级页面构建参考。
📄 代码如下:
struct PHHomeContinueReadView: View {var episode: PHEpisodeEntityvar body: some View {VStack(alignment: .leading, spacing: 10) {Text("继续阅读").font(.title2).fontWeight(.semibold).padding(.horizontal)PHHistoryCardView(episode: episode).padding(.horizontal, 24)}}
}
四、之前读过区域:PHHomeHistoryView
「之前读过」模块的结构与「继续阅读」类似,也是由标题加上一个滑动区域组成,区别在于这里使用了 ScrollView(.horizontal) 搭配 HStack,用于展示多个横向排列的卡片组件。
📄 对应代码如下:
struct PHHomeHistoryView: View {var recentlyViewedEpisodes: [PHEpisodeEntity]var body: some View {VStack(alignment: .leading, spacing: 10) {Text("之前读过").font(.title2).fontWeight(.semibold).padding(.horizontal)ScrollView(.horizontal, showsIndicators: false) {HStack(spacing: 12) {ForEach(recentlyViewedEpisodes, id: \.id) { episode inPHHistoryCardView(episode: episode)}}.padding(.horizontal, 24)}}}
}
🧱 布局解析:
- 最外层依然是 VStack,结构一致,方便模块化管理;
- 标题使用 .title2 + semibold 样式,与上一模块保持统一;
- ScrollView(.horizontal) 实现横向滑动区域,并关闭默认滚动指示器;
- 内容区域使用 HStack 横向排列所有卡片,每张卡片由 PHHistoryCardView 渲染;
- 外部 .padding(.horizontal, 24) 保证整体边距一致,与上一模块的卡片对齐。
五、复用组件:PHHistoryCardView 卡片结构解析
在前面两个模块「继续阅读」和「之前读过」中,我们都使用了同一个组件 PHHistoryCardView 来展示单个剧集的信息。这种组件复用的方式可以大大提升开发效率,并保持 UI 风格一致。
下面是 PHHistoryCardView 的完整实现:
struct PHHistoryCardView: View {var episode: PHEpisodeEntityvar body: some View {HStack(spacing: 12) {// 封面图if let packageId = episode.packageId,let fileName = episode.episodeCoverFileName {Image(uiImage: PHFileManger.loadImage(packageId: packageId, imageName: fileName) ?? UIImage()).resizable().aspectRatio(599.0 / 337.0, contentMode: .fill).frame(width: 100, height: 56).clipped().cornerRadius(6)}// 剧集信息文本VStack(alignment: .leading, spacing: 4) {Text(episode.drama?.title ?? "未知剧名").font(.subheadline).foregroundColor(.white).lineLimit(1)Text(episode.title ?? "第 X 集").font(.caption).foregroundColor(.white.opacity(0.8)).lineLimit(1)Text("共 \(episode.cardList?.count ?? 0) 张卡片").font(.caption2).foregroundColor(.white.opacity(0.6))}Spacer()}.padding(.horizontal, 12).frame(height: 80).background(randomColor).cornerRadius(8)}var randomColor: Color {let colors: [Color] = [.red, .green, .blue]return colors.randomElement()!.opacity(0.3)}
}
🧱 布局结构说明:
- 整体为 HStack:左侧封面图 + 中间文字信息 + 右侧空白占位 Spacer();
- 封面图尺寸为 100 × 56,保持 16:9 比例,使用 .resizable() 和 .aspectRatio() 控制缩放行为;
- 文字信息区使用 VStack 垂直排列三行文本,每行文字样式略有区分,强调主次;
- 背景颜色为红/绿/蓝三选一,带透明度 0.3,当前主要用于调试或展示状态区别,后续可替换为统一主题色;
- 外部加圆角 + 水平内边距,让卡片在页面中更加紧凑且不贴边。
这个组件设计追求简单、紧凑、信息清晰,适合在横向滑动视图中频繁出现。通过 PHHistoryCardView(episode:)这样的结构,我们可以在多个模块中共享样式逻辑,只需要传入数据即可灵活复用。
六、阅读目标区域:PHHomeGoalView
首页的尾部我们加入了一个带有进度环和统计信息的视图组件 —— PHHomeGoalView。这个模块相较前两个更为视觉化,包含一个弧形进度圈、文字信息和按钮,布局略复杂一些,但仍然可以通过 SwiftUI 的组合方式轻松实现。
📄 对应代码如下:
struct PHHomeGoalView: View {var body: some View {VStack(alignment: .center, spacing: 10) {Text("阅读目标").font(.title2).fontWeight(.semibold).padding(.horizontal)Text("坚持每天阅读,积累知识和灵感").font(.subheadline).foregroundColor(.gray).padding(.horizontal).padding(.bottom, 10)ZStack {let circleSize: CGFloat = 200// 背景圆Circle().stroke(Color.gray.opacity(0.2), lineWidth: 10).frame(width: circleSize, height: circleSize)// 弧形进度(目前写死 50%)Path { path inlet center = CGPoint(x: circleSize / 2, y: circleSize / 2)let radius = circleSize / 2let startAngle = Angle(degrees: 90)let endAngle = Angle(degrees: 90 + 360 * 0.5)path.addArc(center: center,radius: radius,startAngle: startAngle,endAngle: endAngle,clockwise: false)}.stroke(Color.blue, lineWidth: 10).frame(width: circleSize, height: circleSize)// 中间文字VStack {Text("今日阅读").font(.headline).foregroundColor(.blue)Text("0:00").font(.largeTitle).fontWeight(.bold).foregroundColor(.blue)Text("(阅读目标10分钟)").font(.subheadline).foregroundColor(.gray)}}.padding(.bottom, 20)// 开始阅读按钮Button(action: {print("开始阅读")}) {Text("开始阅读").font(.headline).padding().frame(maxWidth: .infinity).background(Color.blue).foregroundColor(.white).cornerRadius(8)}}.padding().background(Color.white)}
}
🧱 布局结构说明:
- 外层使用 VStack 垂直排列标题、副标题、图形区域和按钮;
- 视觉核心是 ZStack,用于绘制背景圆形 + 蓝色进度弧线 + 居中文字;
- 弧线绘制使用 Path 手动构造半圆(起始角度 90° 向后延展 180°),当前进度写死为 50%,可后续接入动态进度;
- 文字排布在弧形中央,大小层次清晰,强调阅读时长;
- 最下方的“开始阅读”按钮占满整行,使用 .frame(maxWidth: .infinity) 与 .cornerRadius 实现标准按钮样式;
- 整个组件嵌套在白色背景 + 内边距中,保持与其他模块视觉一致。
这个模块展示了 SwiftUI 中 Path 与 ZStack 的组合能力,也验证了即使不借助 Core Graphics 或第三方库,也能轻松绘制出可定制的图形视图。
七、结语
本文通过 SwiftUI 分模块构建了一个图书类 App 的首页页面,页面内容参考了 iPhone 自带的图书应用,包含以下几个区域:
- 顶部导航栏(带“我的”按钮)
- 继续阅读模块(单卡片展示)
- 之前读过模块(横向滑动卡片列表)
- 阅读目标模块(弧形进度 + 阅读时间)
整个首页采用了 ScrollView + VStack 作为主结构,配合多个独立组件完成页面构建。每个模块都遵循了清晰的层级布局与样式一致性,体现了 SwiftUI 在构建长页面时的简洁性与灵活性。
在这个过程中我们重点实践了:
- 如何将多个逻辑区域封装为可维护的独立视图
- 如何控制模块之间的间距与对齐
- 如何使用 ZStack 和 Path 绘制简单图形组件
SwiftUI 的组件组合理念,使得即使面对复杂的长页面,也能通过良好的结构拆分和样式统一,实现一套清晰、高可维护的实现方式。