普通视图
春晚、机器人、AI 与 LLM -- 肘子的 Swift 周报 #124
![]()
春晚、机器人、AI 与 LLM
作为一个观众数量超十亿的电视节目,央视春晚无疑是极佳的展示平台。今年春晚中,多家中国机器人厂商在不同节目中展示了其产品,其中讨论度最高的当属宇树(Unitree)的人形机器人。在表演环节,多款型号的人形机器人完成了大量较为复杂的武术与动态动作展示。与去年偏静态、偏站桩式的呈现相比,今年的动作复杂度与稳定性确实有明显提升,这一点也得到了全球媒体的关注与报道。
春晚之后,社交媒体上的讨论呈现出明显分化。除了对技术进步的惊叹之外,“预编程”、“没有 AI”、“缺乏实用性”等质疑声音同样不少。这在一定程度上反映了公众对机器人技术复杂度的低估——尤其是对运动控制、实时反馈系统和系统级整合难度的认知不足。
需要澄清的一点是:预训练并不等于“录制-回放”。当前人形机器人在此类表演中的确采用了高度规划的动作流程,这与人类舞者、运动员的训练逻辑有相通之处——大量的离线训练与调试构成了动作的基础,但在实际执行过程中,身体仍需依赖动态平衡与即时修正来应对真实环境的扰动。正是这种容错与实时修复能力,才让人形机器人这个天然不稳定的双足系统得以完成高动态的连续动作。
与此同时,近年来大语言模型(LLM)的爆发,让不少人将 LLM 与 AI 等同起来。事实上,AI 作为一个已有数十年发展的领域,远远不止语言理解这一分支。尤其是面对真实的物理世界时,视觉识别、路径规划、运动控制、强化学习等专用模型在工业与实体系统中的使用量,依然远高于 LLM。在机器人领域,真正决定能力上限的往往是感知系统、控制系统以及低延迟反馈算法,而不是语言推理能力。
即便未来为人形机器人引入更强的"认知能力",更适合的路径也未必是直接接入 LLM,而可能是构建能理解物理规律的世界模型(World Models)与具备低延迟响应能力的控制系统——这两点恰恰是 LLM 的固有短板。具身智能(Embodied AI)的挑战,与纯文本推理存在本质差异。
至于“实用性”的问题,功夫或舞蹈确实难以直接对应现实工作场景。但恰恰是这些对平衡性、协调性与动态响应要求极高的动作,为人形机器人这种高度复杂且不稳定的系统提供了极佳的验证场景。它们更像是工程能力的压力测试,展示的是机械设计、电子控制与算法系统整合的成熟度,而非短期商业落地能力。
我个人对于人形机器人未来的市场规模仍然持审慎态度。技术进步与商业普及之间往往存在不小的鸿沟。但从今年春晚所呈现的进步幅度来看,可以合理判断:在未来十年内,机器人或智能机器以某种形式融入日常工作与生活场景,已不再是科幻想象。无论你是否喜欢“机器人”,技术演进的趋势已经十分明确,我们终将需要与它们共存。
至于“机器人奴役人类”的情景,我暂时并不担心。我更现实的担忧是:如果它们在工作中出现 Bug,给我一拳,我真的挨不住。
🚀 《肘子的 Swift 周报》
每周为你精选最值得关注的 Swift、SwiftUI 技术动态
- 📮 立即订阅 | weekly.fatbobman.com 获取完整内容
- 👥 加入社区 | Discord 与 2000+ 开发者交流
- 📚 深度教程 | fatbobman.com 探索 200+ 原创文章
近期推荐
如何在不破坏 App 的前提下迁移到 @Observable (How to Migrate to @Observable Without Breaking Your App)
随着越来越多的应用将最低系统版本提升至 iOS 17,@Observable 正在取代 ObservableObject 成为新的状态管理基础设施,但当项目已经深度依赖 ObservableObject + @Published 时,迁移远非简单替换宏即可完成。Pawel Kozielecki 结合一次真实的迁移踩坑经历,从底层机制差异出发,系统梳理了新体系下属性包装器的正确使用方式——用 @State 管理生命周期、用 @Bindable 处理双向绑定、只读场景直接使用普通属性,并特别指出了 @ObservationIgnored、计算属性追踪盲点等容易被忽视的细节。迁移的难点从来不在语法层面,而在于真正厘清“谁拥有 view model 的生命周期”这一根本问题。
验证多个回调按顺序触发 (Testing with Event Streams)
尽管 Swift Testing 提供了丰富的断言 API,但在实际使用中你会发现,并没有一个工具能够完全对应 XCTest 中“验证多个回调按顺序触发”(fulfillment + enforceOrder)的能力。confirmation 既需要嵌套使用,也无法直接校验触发顺序。对此,Matt Massicotte 提出了一种更符合 Swift 并发模型的思路:使用 AsyncStream 收集事件,并封装为一个轻量级的 EventStream 类型——当回调触发时 yield 事件标识,测试结束后通过 collect 获取完整事件序列,再与预期数组进行对比。对于“为什么不直接使用数组”这一疑问,Matt 也给出了充分理由:在存在 @Sendable 约束或 actor 隔离不一致的场景下,直接写入数组会触发并发安全问题,而基于 AsyncStream 的方案则天然符合并发模型的约束。
务必为 SwiftData 模型显式声明 Schema 版本 (If You’re Not Versioning Your SwiftData Schema, You’re Gambling)
SwiftData 的声明式写法与自动迁移能力很容易让人产生“框架会替我处理一切”的错觉,但现实是,一旦模型结构发生变化(字段新增、重命名、关系调整),如果没有显式的 schema version 与 migration plan,就只能依赖隐式推断。一旦推断失败,结果往往不是优雅的迁移,而是崩溃、数据丢失,甚至导致应用无法启动。Mohammad Azam 的建议直接而务实:显式声明 Schema 版本;为未来的结构变化预留迁移路径;将“迁移设计”视为模型设计的一部分,而不是事后补救。
本文的观点同样适用于 Core Data。即便模型完全兼容轻量迁移,为每个发行版本创建对应的模型版本文件(只要发生结构修改),不仅有助于追踪模型演化轨迹,也能在出现问题时实现清晰而可控的回滚。用明确的版本机制约束模型演进,本质上是在为长期维护建立安全边界。
用 Swift 开发 CLI 工具 (How to build a simple CLI tool using Swift)
一个有趣的现象是,在 AI Coding 时代,CLI 正在重焕青春——越来越多的开发者通过构建 CLI 工具来承载自己的 MCP 与 Agent 工作流。Natascha Fadeeva 介绍了如何用 Swift Package Manager 和 Apple 官方的 ArgumentParser 库构建结构化的命令行工具:定义主命令与子命令、处理异步网络请求、最终编译为可独立分发的二进制文件。对于已经熟悉 Swift 的 iOS 开发者来说,这条路径比维护一套 bash/Python 脚本更自然,也更容易随项目一起演进。
在 AI 编程时保持方向感 (Navigation Notes – Agentic coding)
作为一个拥有丰富经验的开发者,Joseph Heck 认为当 AI 能够主动执行任务、生成代码甚至推动改动时,开发者的角色从“逐行实现者”转变为“路径规划者”。真正稀缺的能力不再是写代码的速度,而在“导航”——也就是开发者在复杂代码与多代理环境中如何保持方向感。Joseph 给出了几条建议,例如:在提示词中始终加入"对任何模糊之处向我提问";先让 Agent 制定计划并获得确认,再开始实施;提供确定性的反馈回路(单元测试、编译器错误),让 Agent 能够自我修正;以及将反复使用的指令集沉淀为 Skill 文件等。
Heck 并没有过度渲染“AI 颠覆开发者”的叙事,而是强调一种更冷静的现实:agentic coding 会放大已有的工程能力。如果你本来就善于模块划分与抽象设计,AI 会加速你;如果边界感模糊,AI 只会更快制造混乱。
为 Agent 驱动的 iOS 项目构建可靠交付管线 (Setting up a delivery pipeline for your agentic iOS projects)
当代码的生成、修改与重构开始由 Agent 驱动时,传统的 CI/CD 流程是否仍然足够?Donny Wals 以一次真实经历展开:健身时应用崩溃,他将 Crash Report 交给 Agent 分析,训练结束后 PR 已经准备就绪,合并后 TestFlight 构建随即落地。围绕这一实践,他系统梳理了如何为“agentic iOS 项目”构建一条可靠的交付管线(delivery pipeline),确保自动化改动依然可控、可验证、可发布。
文章的重点并不在某个具体工具,而在流程设计本身。Donny 强调,Agent 生成的代码本质上仍属于“未经人工逐行审查的改动”,因此更需要明确的边界与质量闸口:自动化测试、持续集成与发布流程必须承担最终的交付责任。Agent 可以显著提升实现速度,但工程纪律不能随之放松——速度提升之后,控制机制反而更为关键。
实时掌握 Foundation Models 的上下文消耗 (Tracking Token Usage in Foundation Models)
Apple 的 Foundation Models 运行在设备端,上下文窗口仅 4096 个 token,一旦超出便无法继续对话。iOS 26.4 新增了 token 用量追踪 API,帮助开发者实时掌握上下文消耗情况。Artem Novichkov 系统介绍了四个关键指标:模型上下文总容量(contextSize)、Instructions 的 token 消耗、单条 Prompt 的消耗,以及完整对话记录(Transcript)的累计用量。文章还揭示了一个容易被忽视的细节:当引入 Tool 时,其名称、描述与参数 Schema 会被序列化并计入 token,同一段 Instructions 在附加 Tool 后 token 数从 16 跃升至 79。对于设备端模型而言,token 的可观测性将成为优化体验的基础设施。
工具
App Store Connect CLI
App Store Connect CLI 是由 Rudrank Riyam 开发的非官方 App Store Connect 命令行工具,功能覆盖 TestFlight 管理、构建上传、代码签名、截图自动化、本地化同步、应用审核提交、notarization,以及财务报告下载等完整发布链路。它从设计阶段就强调 Agent 场景,并提供了面向 Agent 的实践文档。若你的发布流程重心在 TestFlight、元数据、提审、签名与 CI 自动化,ASC 可以作为 fastlane 的轻量替代方案之一。
GRDB 7.10.0: 新增 Android、Linux、Windows 支持
GRDB 7.10.0 是一个具有里程碑意义的版本更新:本次正式引入对 Android、Linux、Windows 的支持,并新增通过 Swift Package Manager 使用 SQLCipher(加密数据库)的能力——这两项功能都长期受到社区期待。这意味着这个 Swift 生态中最成熟的 SQLite 封装库,正在从 Apple 平台工具演进为真正的跨平台数据层解决方案。
Gwendal Roué 在 版本公告 中也特别说明,由于 Xcode 尚未支持 package traits,SwiftPM 目前仍会下载未实际使用的依赖;在相关问题解决之前,SQLCipher 支持将以 fork 形式长期维护。
Swift System Metrics
Swift System Metrics 为 Swift 应用(尤其是服务端项目)提供了统一的系统级指标采集能力,例如 CPU 使用率、内存占用、文件描述符数量等,并通过标准化的 Metrics 接口对外暴露,便于接入 Prometheus 等现有监控体系。它并非一个独立的监控系统,而是由 Swift Server Work Group 推动的基础设施组件,旨在与 Swift Metrics 生态对齐,使系统资源指标与应用级指标纳入同一可观测体系。1.0 的发布意味着 API 已趋于稳定,具备生产环境使用条件。对正在构建 Swift 后端服务、或持续完善 Swift 可观测性能力的团队来说,这是一个基础设施层面的关键拼图。
往期内容
💝 支持与反馈
如果本期周报对你有帮助,请:
- 👍 点赞 - 让更多开发者看到
- 💬 评论 - 分享你的看法或问题
- 🔄 转发 - 帮助同行共同成长
🚀 拓展 Swift 视野
- 📮 邮件订阅 | weekly.fatbobman.com 获取独家技术洞察
- 👥 开发者社区 | Discord 实时交流开发经验
- 📚 原创教程 | fatbobman.com 学习 Swift/SwiftUI 最佳实践
春晚、机器人、AI 与 LLM - 肘子的 Swift 周报 #124
作为一个观众数量超十亿的电视节目,央视春晚无疑是极佳的展示平台。今年春晚中,多家中国机器人厂商在不同节目中展示了其产品,其中讨论度最高的当属宇树(Unitree)的人形机器人。在表演环节,多款型号的人形机器人完成了大量较为复杂的武术与动态动作展示。
第三十二章 接下来我们开始做`灭菌整板`页面
![]()
新建 SterilizeWholeBoardPage 空页面
class SterilizeWholeBoardPageViewModel: BaseViewModel {
}
struct SterilizeWholeBoardPage: View {
@StateObject private var viewModel = SterilizeWholeBoardPageViewModel()
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
EmptyView()
}
.makeToDetailPage()
}
}
添加 【灭菌批号】【栈版号】【箱号】
![]()
class SterilizeWholeBoardPageViewModel: BaseViewModel {
/// 灭菌批号
@Published var sterilizationLotNumber:String = ""
/// 栈版号
@Published var stackVersionNumber:String = ""
/// 箱号
@Published var caseNumber:String = ""
}
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
VStack(spacing: 0) {
Spacer()
.frame(height: 5)
VStack(spacing: 0) {
ScanTextView(title: "灭菌批号",
prompt: "请输入灭菌批号",
text: $viewModel.sterilizationLotNumber)
Divider()
.padding(.leading, 10)
ScanTextView(title: "栈版号",
prompt: "请输入栈版号",
text: $viewModel.stackVersionNumber)
Divider()
.padding(.leading, 10)
ScanTextView(title: "箱号",
prompt: "请输入箱号",
text: $viewModel.caseNumber)
}
.background(.white)
Spacer()
}
}
...
}
}
![]()
添加 【栈板序号】【物料总体积】【箱数】
![]()
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
VStack(spacing: 0) {
...
...
VStack {
HStack(spacing: 0) {
Text("栈板序号")
.frame(width: 100, alignment: .leading)
Text("1")
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(EdgeInsets(top: 15, leading: 22, bottom: 15, trailing: 22))
Divider()
.padding(.leading, 10)
VStack(spacing: 10) {
HStack(spacing: 0) {
Text("物料总体积")
.frame(width: 100, alignment: .leading)
Text("120.86 m³")
}
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 0) {
Text("箱数")
.frame(width: 100, alignment: .leading)
Text("12")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(EdgeInsets(top: 15, leading: 22, bottom: 15, trailing: 22))
}
...
}
}
...
}
}
![]()
使用 environment 规范 Title 文本的宽度
.frame(width: 100, alignment: .leading)
大量这种代码我们实在受够了,一个页面如果很多元素,或者其他界面一样的这种对齐呢?不过我们可以通过 environment进行设置。
新增 TitleWidthEnvironmentKey
struct TitleWidthEnvironmentKey: EnvironmentKey {
/// 设置默认为100
static var defaultValue: CGFloat = 100
}
给 EnvironemtValues 扩展属性 titleWidth
extension EnvironmentValues {
var titleWidth: CGFloat {
get { self[TitleWidthEnvironmentKey.self] }
set { self[TitleWidthEnvironmentKey.self] = newValue }
}
}
将 ScanTextView 中的宽度限制 修改为 titleWidth
struct ScanTextView: View {
...
/// 默认为 100
@Environment(\.titleWidth) private var titleWidth:CGFloat
init(title:String, prompt:String, text:Binding<String>) {
...
}
...
}
封装组件 LimitLeadingWidthView
struct LimitLeadingWidthView<Leading:View, Treading:View>: View {
@Environment(\.titleWidth) private var leadingLimitWidth:CGFloat
private let leading:Leading
private let treading:Treading
init(@ViewBuilder leading:() -> Leading, @ViewBuilder treading:() -> Treading) {
self.leading = leading()
self.treading = treading()
}
var body: some View {
HStack(spacing: 0) {
leading
.frame(width: leadingLimitWidth, alignment: .leading)
treading
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct LimitLeadingWidthView_Previews: PreviewProvider {
static var previews: some View {
LimitLeadingWidthView(leading: {
Text("我是左侧文本")
}, treading: {
Text("我是右侧文本")
})
.previewLayout(.sizeThatFits)
}
}
![]()
将【栈板序号】【物料总体积】【箱数】更换为 LimitLeadingWidthView 组件
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
VStack(spacing: 0) {
...
VStack {
LimitLeadingWidthView(leading: {
Text("栈板序号")
}, treading: {
Text("1")
})
...
VStack(spacing: 10) {
LimitLeadingWidthView {
Text("物料总体积")
} treading: {
Text("120.86 m³")
}
LimitLeadingWidthView {
Text("箱数")
} treading: {
Text("12")
}
}
...
}
...
}
}
...
}
}
将 ScanTextView 内部使用 LimitLeadingWidthView 组件
struct ScanTextView: View {
...
var body: some View {
LimitLeadingWidthView {
HStack {
Text("*")
.foregroundColor(Color(uiColor: appColor.c_e68181))
Text(title)
Spacer()
}
} treading: {
HStack {
TextField(prompt, text: $text)
.frame(height:33)
Image("scan_icon", bundle: .main)
}
}
...
}
}
扩展 View 新增 limitLeadingWidth 方法
虽然默认值100已经在当前页面足够的展示左侧的内容,我们想要在当前页面根本修改全部LimitLeadingWidthView左侧宽度为110。
z
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
...
}
.environment(\.titleWidth, 110)
}
}
对于.environment(\.titleWidth, 110)这样的方式不是很优雅,使用者还要关心对应Key是什么?我们可以给View做一下扩展。
extension View {
func limitLeadingWidth(_ width:CGFloat) -> some View {
self.environment(\.titleWidth, width)
}
}
此时我们上面的代码就可以变成下面
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
...
}
.limitLeadingWidth(110)
}
}
这样的写法可以明确用意。
获取栈板序号
![]()
栈板序号的值来源于通过灭菌批号查询。
新增 @Published 栈板序号用于更新页面
class SterilizeWholeBoardPageViewModel: BaseViewModel {
...
/// 栈板序号
@Published var palletSerialNumber:String = ""
}
根据【灭菌批号】获取【栈板序号】
class SterilizeWholeBoardPageViewModel: BaseViewModel {
...
/// 请求栈板序号
func requestPalletNumber() async {
guard !sterilizationLotNumber.isEmpty else {
showHUDMessage(message: "灭菌批号为空!")
return
}
let api = GetSterilizationSequenceApi(sterilizeBatch: sterilizationLotNumber)
let model:BaseModel<Int> = await request(api: api)
guard model._isSuccess, let data = model.data else {
return
}
palletNumber = "\(data)"
}
}
输入完毕【灭菌批号】获取【栈板序号】
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
VStack(spacing: 0) {
...
VStack(spacing: 0) {
ScanTextView(title: "灭菌批号",
prompt: "请输入灭菌批号",
text: $viewModel.sterilizationLotNumber)
.onSubmit {
Task {
await viewModel.requestPalletNumber()
}
}
...
}
...
}
}
...
}
}
设置 TabBar 的背景颜色
![]()
突然发现,我们的TabrBar变成了这个样子,应该是我们修改SafeArea导致的。
struct TabPage: View {
...
init() {
...
UITabBar.appearance().backgroundColor = .white
}
...
}
获取【物料总体积】【箱数】
继承 PalletBindBoxNumberPageViewModel
物料总体积和箱数来源于根据栈版号获取的箱子列表拿到的数据。这个页面输入栈版号和箱号是一样的逻辑,我们不如将SterilizeWholeBoardPageViewModel继承于PalletBindBoxNumberPageViewModel。
class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
/// 灭菌批号
@Published var sterilizationLotNumber:String = ""
/// 栈板序号
@Published var palletSerialNumber:String = ""
....
}
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
VStack(spacing: 0) {
...
VStack(spacing: 0) {
...
ScanTextView(title: "栈版号",
prompt: "请输入栈版号",
text: $viewModel.palletNumber)
...
ScanTextView(title: "箱号",
prompt: "请输入箱号",
text: $viewModel.boxNumber)
}
...
VStack {
LimitLeadingWidthView(leading: {
Text("栈板序号")
}, treading: {
Text(viewModel.palletSerialNumber)
})
...
}
}
...
}
}
新增 @Published 变量显示【箱号总体积】【箱数】
class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
...
/// 总体积
@Published var totalCapacity:String = ""
/// 箱数
@Published var totalBox:String = ""
...
}
因为箱子总体积和箱数的数据来源于单条的BoxDetailModel里面的数据,我们监听boxDetailModels的变化,获取第一条元素进行获取。
class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
...
/// 存储 Publisher 取消
private var cancellabels:Set<AnyCancellable> = []
override init() {
super.init()
$boxDetailModels.sink {[weak self] models in
guard let self = self else {return}
self.totalCapacity = models.first.flatMap({$0.volume}).map({"\($0)"}) ?? ""
self.totalBox = models.first.flatMap({$0.total}).map({"\($0)"}) ?? ""
}
.store(in: &cancellabels)
}
...
}
查询栈板箱子列表和新增删除箱号
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
VStack(spacing: 0) {
...
VStack(spacing: 0) {
...
ScanTextView(title: "栈版号",
prompt: "请输入栈版号",
text: $viewModel.palletNumber)
.onSubmit {
Task {
await viewModel.requestBoxDetailList()
}
}
...
ScanTextView(title: "箱号",
prompt: "请输入箱号",
text: $viewModel.boxNumber)
.onSubmit {
Task {
await viewModel.addOrRemoveBox()
}
}
}
...
}
}
...
}
}
提炼箱号列表
灭菌整板箱子列表和托盘绑定箱号的箱子列表是一样的,所以,我们可以将灭菌整板的箱子列表进行提炼。
struct BoxListView: View {
private let models:[BoxDetailModel]
init(models:[BoxDetailModel]) {
self.models = models
}
var body: some View {
List {
ForEach(models) { model in
BoxDetailView(model: model)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 5, trailing: 0))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
}
}
修改托盘绑定箱号页面
struct PalletBindBoxNumberPage: View {
...
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
VStack(spacing:0) {
...
BoxListView(models: viewModel.boxDetailModels)
}
}
...
}
}
灭菌整板页面新增BoxListView
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
VStack(spacing: 0) {
...
Spacer()
.frame(height: 10)
BoxListView(models: viewModel.boxDetailModels)
}
}
...
}
}
![]()
添加 【重置】 【提交】按钮
![]()
打印需要调用蓝牙和硬件交互,我们就把打印替换成重置
封装 TextButton
在登录页面,我们有一个类似的登录按钮,决定按照登录按钮的样式封装按钮,方便后面的使用。
struct TextButton: View {
@StateObject private var appColor = AppColor.share
private let title:String
private let action:() -> Void
init(title:String, action:@escaping () -> Void) {
self.title = title
self.action = action
}
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 16))
.frame(maxWidth:.infinity)
.frame(height: 45)
.background(Color(uiColor: appColor.c_209090))
.foregroundColor(.white)
.cornerRadius(5)
}
}
}
struct TextButton_Previews: PreviewProvider {
static var previews: some View {
TextButton(title: "登录", action: {})
.previewLayout(.sizeThatFits)
}
}
通过 TextButton 添加 【重置】【提交】
通过 Stack 进行叠加布局
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
ZStack {
...
VStack {
Spacer()
HStack {
TextButton(title: "重置") {
}
TextButton(title: "提交") {
}
}
.padding()
}
}
}
...
}
}
重置界面数据
点击重置按钮需要将界面所有的数据清空,界面恢复到刚打开的状态。
class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
...
func reset() {
sterilizationLotNumber = ""
palletSerialNumber = ""
palletNumber = ""
boxNumber = ""
totalCapacity = ""
totalBox = ""
boxDetailModels = []
}
}
struct SterilizeWholeBoardPage: View {
...
var body: some View {
PageContentView(title: "灭菌整板", viewModel: viewModel) {
ZStack {
...
VStack {
...
HStack {
TextButton(title: "重置") {
viewModel.reset()
}
...
}
...
}
}
}
...
}
}
【提交】灭菌整板
![]()
第三十一章 完善箱号列表
我们已经通过栈版号获取到了箱子列表数据,那么我们用List将数据展示出来。
BoxDetailModel 实现 Identifiable 协议
extension BoxDetailModel: Identifiable {
var id: String { boxCode ?? "" }
}
List + ForEach 实现列表
struct PalletBindBoxNumberPage: View {
...
var body: some View {
... {
... {
...
List {
ForEach(viewModel.boxDetailModels) { model in
BoxDetailView()
}
}
}
}
...
}
}
![]()
List 构建的是否存在性能问题?
![]()
看了视图,核心还是利用UITableView重用的机制,所以使用List展示很多数据,是会走重用机制的。
设置 List 的 Style
struct PalletBindBoxNumberPage: View {
...
var body: some View {
... {
... {
... {
List {
...
}
.listStyle(.plain)
}
}
...
}
}
![]()
通过 listRowInsets 设置 Row 的间隙
显示出来的间隙,明显和我们BoxDetailView的间隙大很多,为了看一下差距,我们给BoxDetailView设置一个红色背景色。
![]()
看起来左右留白多一些,上下留白很少。
struct PalletBindBoxNumberPage: View {
...
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
VStack(spacing:0) {
...
List {
ForEach(0 ..< 10) { model in
BoxDetailView()
...
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
}
...
}
}
...
}
}
![]()
通过 listRowInsets 增加 Cell 之间的间隙
struct PalletBindBoxNumberPage: View {
...
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
VStack(spacing:0) {
...
List {
ForEach(0 ..< 10) { model in
BoxDetailView()
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 5, trailing: 0))
}
}
...
}
}
...
}
}
![]()
通过 listRowBackground 设置背景颜色
上图完全看不到Cell之间的坚决,我们可以通过listRowBackground进行设置颜色,来区分Cell。
struct PalletBindBoxNumberPage: View {
...
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
VStack(spacing:0) {
...
List {
ForEach(0 ..< 10) { model in
BoxDetailView()
...
.listRowBackground(Color.clear)
}
}
...
}
}
...
}
}
![]()
通过 listRowSeparator 隐藏 Cell 的 Separator
struct PalletBindBoxNumberPage: View {
...
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
VStack(spacing:0) {
...
List {
ForEach(0 ..< 10) { model in
BoxDetailView()
...
.listRowSeparator(.hidden)
}
}
...
}
}
...
}
}
![]()
传递 Model 到 BoxDetailView 赋值
struct BoxDetailView: View {
private let model:BoxDetailModel
init(model:BoxDetailModel) {
self.model = model
}
var body: some View {
HStack {
VStack {
TitleValueView(...,
value: model.skuCode ?? "")
...
TitleValueView(...,
value: model.skuBatch ?? "")
}
VStack {
TitleValueView(...,
value: model.paperCode ?? "",
...)
...
TitleValueView(...,
value: model.boxCode ?? "",
...)
}
}
...
}
}
struct PalletBindBoxNumberPage: View {
...
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
VStack(spacing:0) {
...
List {
ForEach(viewModel.boxDetailModels) { model in
BoxDetailView(model: model)
...
}
}
...
}
}
...
}
}
![]()
第三十章 接下来我们写首页的功能,首先是我们的`托盘绑定箱号`。
![]()
创建托盘绑定箱号界面
新建 ViewModel
class PalletBindBoxNumberPageViewModel: BaseViewModel {
}
新建 Page
struct PalletBindBoxNumberPage: View {
@StateObject private var viewModel = PalletBindBoxNumberPageViewModel()
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
EmptyView()
}
}
}
新增首页跳转 PalletBindBoxNumberPage
NavigationLink
对于导航的跳转,我们需要用到NavigationLink.
struct HomePage: View {
...
var body: some View {
... {
... {
ActionCardView(
title: "生产执行",
actions: [
/// ActionItem
...
])
...
}
}
...
}
struct ActionItem: Hashable {
...
}
ActionItem不是一个View,因此不能够使用NavigationLink。
在 Function方法体内部执行 NavigationLink跳转。
HomePageViewModel 新增一个控制 NavigationLink 激活的变量
class HomePageViewModel: BaseViewModel {
...
/// 是否允许跳转界面
@Published var isAllowPushPage:Bool = false
...
}
在 HomePage 新增一个不可见的 NavigationLink
struct HomePage: View {
...
var body: some View {
PageContentView(title: "首页",
viewModel: viewModel) {
VStack {
...
VStack(spacing:0) {
NavigationLink(isActive: $viewModel.isAllowPushPage) {
} label: {
EmptyView()
}
Spacer()
}
}
} leadingBuilder: {
...
} trailingBuildeder: {
...
}
...
}
}
获取点击首页按钮 ActionItem
HomePageViewModel 新增记录选中 ActionItem的变量。
class HomePageViewModel: BaseViewModel {
...
/// 当前点击按钮的 `ActionItem`
@Published var currentClickActionItem:ActionItem?
...
}
ActionCardView
struct ActionCardView: View {
...
@Binding var currentClickActionItem:ActionItem?
var body: some View {
VStack {
...
HStack(alignment:.top) {
HStack {
ActionView(actionItems: actions(index: .left),
currentClickActionItem: $currentClickActionItem)
...
}
...
HStack {
ActionView(actionItems: actions(index: .center),
currentClickActionItem: $currentClickActionItem)
}
...
HStack {
...
ActionView(actionItems: actions(index: .right),
currentClickActionItem: $currentClickActionItem)
}
...
}
...
}
...
}
...
}
ActionView
struct ActionView: View {
...
@Binding var currentClickActionItem:ActionItem?
var body: some View {
VStack {
ForEach(actionItems, id: \.self) { item in
...
.onTapGesture {
currentClickActionItem = item
}
}
}
}
}
HomePage
struct HomePage: View {
...
var body: some View {
PageContentView(title: "首页",
viewModel: viewModel) {
VStack {
ActionCardView(
title: "生产执行",
actions: [
....
], currentClickActionItem: $viewModel.currentClickActionItem)
...
}
}
} leadingBuilder: {
...
} trailingBuildeder: {
...
}
.onAppear {
...
}
}
}
监听 currentClickActionItem 值的改变,执行跳转。
struct HomePage: View {
...
var body: some View {
PageContentView(title: "首页",
viewModel: viewModel) {
...
} leadingBuilder: {
...
} trailingBuildeder: {
...
}
...
.onChange(of: viewModel.currentClickActionItem) { newValue in
viewModel.isAllowPushPage = true
}
}
}
根据 ActionItem 返回对应的 Page
extension HomePageViewModel {
var actionPage: some View {
return currentClickActionItem.map { item in
Group {
if item.title == "托盘绑定箱号" {
PalletBindBoxNumberPage()
} else {
EmptyView()
}
}
}
}
}
HomePage
struct HomePage: View {
...
var body: some View {
PageContentView(title: "首页",
viewModel: viewModel) {
VStack {
...
VStack(spacing:0) {
NavigationLink(isActive: $viewModel.isAllowPushPage,
destination: {viewModel.actionPage}) {
EmptyView()
}
...
}
}
} leadingBuilder: {
...
} trailingBuildeder: {
...
}
...
}
}
![]()
修复返回按钮样式不对
![]()
隐藏返回按钮文本
let backButtonAppearance = UIBarButtonItemAppearance()
backButtonAppearance.normal.titleTextAttributes = [
.font : UIFont.systemFont(ofSize: 0),
]
appearance.backButtonAppearance = backButtonAppearance
修改 SwiftUI 返回按钮的颜色
NavigationView {
...
}
.accentColor(.black)
需要注意的是官方说
accentColor已经要废弃了,Use the asset catalog's accent color or View.tint(_:) instead."但是替换为
tint不起作用。
没有隐藏底部的 Tab
![]()
目前在 SwiftUI中暂时没有任何方便的方法可以在 NavigationView 进行 Push 跳转隐藏底部的 Tabbar。我们只能在需要隐藏的界面的 onAppear和 onDisappear去隐藏。
/// ❌ 这样设置是不起作用的
UITabBar.appearance().isHidden = true
我们在运行时候,看一下布局。
![]()
我们按照结构找出 UITabbar
if let appBar = App.keyWindow?.rootViewController
.flatMap({$0.view})
.flatMap({$0.subviews.first})
.flatMap({$0.subviews.first})
.map({$0.subviews})
.map({$0.compactMap({$0 as? UITabBar})})
.flatMap({$0.first}) {
print(appBar)
}
在 App 获取当前 Tabbar 的方法
struct App {
...
static var tabBar:UITabBar? {
return keyWindow?.rootViewController
.flatMap({$0.view})
.flatMap({$0.subviews.first})
.flatMap({$0.subviews.first})
.map({$0.subviews})
.map({$0.compactMap({$0 as? UITabBar})})
.flatMap({$0.first})
}
}
隐藏和显示当前 UITabbar
struct PalletBindBoxNumberPage: View {
...
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
...
}
.onAppear {
App.tabBar?.isHidden = false
}
.onDisappear {
App.tabBar?.isHidden = true
}
}
}
![]()
隐藏UITabar之后多出了很多空白的区域,我们设置忽略安全距离。
/// 页面的基础试图
struct PageContentView<Content:View,
Leading:View,
Trailing:View,
ViewModel:BaseViewModel>: View {
...
var body: some View {
navigationBar {
ZStack {
content
.background {
Color(uiColor: appColor.c_efefef)
.ignoresSafeArea()
}
}
...
}
}
...
}
封装 Detail 页面
为了让后面的界面一样拥有 隐藏UITabBar我们需要进行封装成DetailView,方便后续的使用。
新建一个 DetailPageViewModify
struct DetailPageViewModify: ViewModifier {
func body(content: Content) -> some View {
content
.onAppear {
App.tabBar?.isHidden = true
}
.onDisappear {
App.tabBar?.isHidden = false
}
}
}
extension View {
func makeToDetailPage() -> some View {
self.modifier(DetailPageViewModify())
}
}
将 PalletBindBoxNumberPage 页面使用 DetailPageViewModify
struct PalletBindBoxNumberPage: View {
@StateObject private var viewModel = PalletBindBoxNumberPageViewModel()
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
EmptyView()
}
.makeToDetailPage()
}
}
修复第二次相同页面无法 Push 问题
![]()
从上面的掩饰发现,第一次是可以正常的进入,点击返回,第二次无法Push进入。只有点击其他页面返回之后,才能正常的返回。
打印点击 Push 对应的 ActionItem
newValue = Optional(Win_.ActionItem(icon: "托盘绑定箱号", iconColor: UIExtendedSRGBColorSpace 0.945098 0.564706 0.215686 1, title: "托盘绑定箱号"))
newValue = Optional(Win_.ActionItem(icon: "灭菌整板(有箱号)", iconColor: UIExtendedSRGBColorSpace 0.945098 0.564706 0.215686 1, title: "灭菌整板(有箱号)"))
newValue = Optional(Win_.ActionItem(icon: "托盘绑定箱号", iconColor: UIExtendedSRGBColorSpace 0.945098 0.564706 0.215686 1, title: "托盘绑定箱号"))
发现在整个过程中,点击第二次是没有走 onChange,是因为检测到值相同,是因为ActionItem实现了Hashable协议。
在 HomePage 的 onAppear 方法重置 currentClickActionItem
struct HomePage: View {
...
var body: some View {
...
.onAppear {
...
viewModel.currentClickActionItem = nil
}
...
}
}
![]()
经过重置,第二次Push无法跳转问题解决了。
封装扫描输入组件
![]()
接下来我们封装上面的组件,大致的界面构造如下。
![]()
新建一个 ScanTextView
struct ScanTextView: View {
@StateObject private var appColor = AppColor.share
/// 前面的标题
private let title:String
/// 输入框的提示文本
private let prompt:String
/// 输入框输入的内容
@Binding private var text:String
init(title:String, prompt:String, text:Binding<String>) {
self.title = title
self.prompt = prompt
self._text = text
}
var body: some View {
HStack {
HStack {
Text("*")
.foregroundColor(Color(uiColor: appColor.c_e68181))
Text(title)
Spacer()
}
TextField(prompt, text: $text)
.frame(height:33)
Image("scan_icon", bundle: .main)
}
.font(.system(size: 14))
.padding()
}
}
![]()
添加栈版号和箱号
struct PalletBindBoxNumberPage: View {
...
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
VStack {
VStack {
ScanTextView(title: "栈版号",
prompt: "请输入栈版号",
text: $viewModel.palletNumber)
ScanTextView(title: "箱号",
prompt: "请输入箱号",
text: $viewModel.boxNumber)
}
.background(.white)
Spacer()
}
}
...
}
}
class PalletBindBoxNumberPageViewModel: BaseViewModel {
/// 输入的栈版号
@Published var palletNumber:String = ""
/// 箱号
@Published var boxNumber:String = ""
}
![]()
固定 ScanTextView 的 Title 的宽度
![]()
提示语是没有对齐的,因为是自动布局,很难会让自动的对齐,我们需要设置左侧标题固定长度。
struct ScanTextView: View {
...
/// 默认为 100
private let titleWidth:CGFloat
init(title:String, prompt:String, text:Binding<String>, titleWidth:CGFloat = 100) {
...
self.titleWidth = titleWidth
}
var body: some View {
HStack {
HStack {
...
}
.frame(width: titleWidth)
...
}
...
}
}
栈版号和箱号中间添加分割线
struct PalletBindBoxNumberPage: View {
@StateObject private var viewModel = PalletBindBoxNumberPageViewModel()
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
VStack(spacing:0) {
VStack(spacing:0) {
...
Divider()
.padding(.leading)
...
}
...
}
}
...
}
}
箱号详情组件
![]()
分析布局如下。
![]()
struct BoxDetailView: View {
var body: some View {
HStack {
VStack {
HStack {
Text("物料编号:")
Text("A")
}
HStack {
Text("物料批号:")
Text("120211217A")
}
}
VStack {
HStack {
Text("工单号:")
Text("WO-201425")
}
HStack {
Text("箱号:")
Text("BOX-01")
}
}
}
.padding(15)
.frame(maxWidth: .infinity)
.background(.white)
.cornerRadius(10)
}
}
![]()
制作标题信息组件
我们需要标题和信息上对齐,类似下面的排版方案。
![]()
struct TitleValueView: View {
@StateObject private var appColor = AppColor.share
private let title:String
private let value:String
init(title:String, value:String) {
self.title = title
self.value = value
}
var body: some View {
HStack(alignment:.firstTextBaseline) {
Text(title)
.foregroundColor(Color(uiColor: appColor.c_999999))
Text(value)
.foregroundColor(Color(uiColor: appColor.c_333333))
}
.font(.system(size: 14))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
![]()
将箱号详情标题和描述替换为 TitleValueView 组件
struct BoxDetailView: View {
var body: some View {
HStack {
VStack {
TitleValueView(title: "物料编号:",
value: "A")
TitleValueView(title: "物料批号:",
value: "120211217A")
}
VStack {
TitleValueView(title: "工单号:",
value: "WO-201425")
TitleValueView(title: "箱号:",
value: "BOX-01")
}
}
...
}
}
![]()
调整上下组件的间距
struct BoxDetailView: View {
var body: some View {
HStack {
VStack {
...
Spacer()
.frame(height: 7.5)
...
}
VStack {
...
Spacer()
.frame(height: 7.5)
...
}
}
...
}
}
![]()
可手动控制 Title 的宽度
我们给TitleValueView新增一个可以手动控制Title宽度的参数,如果不为0则手动控制高度。
struct TitleValueView: View {
...
private let titleWidth:CGFloat
init(title:String, value:String, titleWidth:CGFloat = 0) {
...
self.titleWidth = titleWidth
}
var body: some View {
HStack(alignment:.firstTextBaseline) {
if titleWidth == 0 {
titleText
} else {
titleText
.frame(width: titleWidth, alignment: .leading)
}
...
}
...
}
private var titleText: some View {
Text(title)
.foregroundColor(Color(uiColor: appColor.c_999999))
}
}
我们将工单号和箱号宽度保持一致
struct BoxDetailView: View {
var body: some View {
HStack {
VStack {
...
}
VStack {
TitleValueView(title: "工单号:",
value: "WO-201425",
titleWidth: 50)
...
TitleValueView(title: "箱号:",
value: "BOX-01",
titleWidth: 50)
}
}
...
}
}
![]()
固定 ScanTextView的高度
![]()
经过自动布局之后的ScanTextView的高度达到了65的高度,超出了设计图50的高度,主要是输入框固定了高度,我们将去掉Padding,给ScanTextView设置固定高度为50。
struct ScanTextView: View {
...
var body: some View {
HStack {
...
}
...
.frame(height:50)
}
}
![]()
只增加左右间距
高度50设置完毕,但是左右靠边,我们只设置边距左右为10。
struct ScanTextView: View {
...
var body: some View {
HStack {
...
}
...
.padding(.leading, 10)
.padding(.trailing, 10)
/// 或者
/// .padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
}
}
![]()
获取箱号列表
新增 @Published 参数箱号列表 用于更新列表
class PalletBindBoxNumberPageViewModel: BaseViewModel {
...
/// 箱子列表
@Published var boxDetailModels:[BoxDetailModel] = []
}
新增根据栈版号获取箱号列表方法
class PalletBindBoxNumberPageViewModel: BaseViewModel {
...
/// 请求获取箱子列表
func requestBoxDetailList() async {
let api = PalletQueryApi(palletCode: palletNumber)
let model:BaseModel<[BoxDetailModel]> = await request(api: api)
guard model._isSuccess else { return }
boxDetailModels = model.data ?? []
}
}
当输入栈版号结束之后请求箱号列表
怎么才能监听到输入完毕呢?我们可以使用onSubmit这个扩展获取。
struct PalletBindBoxNumberPage: View {
...
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
VStack(spacing:0) {
VStack(spacing:0) {
ScanTextView(title: "栈版号",
prompt: "请输入栈版号",
text: $viewModel.palletNumber)
.onSubmit {
Task {
await viewModel.requestBoxDetailList()
}
}
...
}
...
}
}
...
}
}
添加或者删除箱号
此时我们的栈板上是没有数据的,需要我们输入箱号进行新增和删除操作。
![]()
上图的逻辑都封装在接口里面,所以我们只需要关心输入箱号之后,调用接口即可。
添加新增或者删除箱号逻辑方法
class PalletBindBoxNumberPageViewModel: BaseViewModel {
...
/// 添加或者移除箱号
func addOrRemoveBox() async {
let api = BoxAddApi(palletCode: palletNumber, boxCode: boxNumber)
let model:BaseModel<String> = await request(api: api)
guard model._isSuccess else {return}
/// 重新获取列表 刷新界面
await requestBoxDetailList()
}
}
给箱号输入框添加onSubmit方法
struct PalletBindBoxNumberPage: View {
...
var body: some View {
PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
VStack(spacing:0) {
VStack(spacing:0) {
...
ScanTextView(title: "箱号",
prompt: "请输入箱号",
text: $viewModel.boxNumber)
.onSubmit {
Task {
await viewModel.addOrRemoveBox()
}
}
}
...
}
}
...
}
}
给请求添加HUD
此时添加箱号成功了
{"code":200,"data":"ç®±å·ç»å®æ æ¿æå!!!","message":"success","objectType":null,"success":true}
在日志也看不出来乱码显示,我们希望提示给用户。
给获取箱子列表添加HUD
class PalletBindBoxNumberPageViewModel: BaseViewModel {
...
/// 请求获取箱子列表
func requestBoxDetailList() async {
...
let model:BaseModel<[BoxDetailModel]> = await request(api: api, showHUD: true)
...
}
...
}
![]()
此时我们已经获取到列表了,但是HUD没有消失,主要是逻辑中没有调用隐藏HUD。
给BaseViewModel新增Hidden HUD方法
@MainActor
class BaseViewModel: ObservableObject {
...
func hiddenHUD() {
self.isLoadingHUD = false
}
...
}
给查询箱号和新增和删除箱号添加HUD和移除HUD
class PalletBindBoxNumberPageViewModel: BaseViewModel {
...
/// 请求获取箱子列表
func requestBoxDetailList() async {
...
hiddenHUD()
...
}
/// 添加或者移除箱号
func addOrRemoveBox() async {
...
let model:BaseModel<String> = await request(api: api, showHUD: true)
...
hiddenHUD()
...
}
}
添加或者删除成功提示
上面的代码我们还是无法成功显示提示语,到底是添加成功还是删除成功。当我们请求完毕,展示获取的Data字符串。
![]()
但是展示和隐藏十分的快,在显示没有结束之前,被后面获取箱子列表接口在请求完毕之后隐藏了。
![]()
class PalletBindBoxNumberPageViewModel: BaseViewModel {
...
/// 添加或者移除箱号
func addOrRemoveBox() async {
...
if let message = model.data {
showHUDMessage(message: message)
}
}
}
![]()
修复HUD开始显示之前内容的问题
HUD展示逻辑
![]()
HUD Message展示逻辑
![]()
我们看到在展示文本延时两秒之后,文本没有清空,导致下次请求进行Loading时候因为文本不为空,展示不是一个Loading HUD而是上一个提示的文本。
清空上一个展示的文本
修复这个问题,大概有两种方案
方案1 在延时两秒隐藏时候 清空文本
@MainActor
class BaseViewModel: ObservableObject {
...
/// 展示 HUD 文本
/// - Parameter message: 提示的信息
func showHUDMessage(message:String) {
...
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
...
self.hudMessage = ""
}
}
...
}
方案2 在展示HUD的时候 清空之前的文本
@MainActor
class BaseViewModel: ObservableObject {
...
func request<T:Codable, API:APIConfig>(api:API, showHUD:Bool = false) async -> BaseModel<T> {
if (showHUD) {
hudMessage = ""
...
}
...
}
}
展示HUD Message的文本内容只是一个临时的展示内容,应该在展示完毕重置,所以第一种方案比较好。
![]()
第二十九章 修复首页 PopMenuView 显示问题
在首页切换工厂的时候,我们发现了一处严重的UI问题。
![]()
本来我们做的PopMenuButton竟然被导航栏遮挡在最下面。出现的原因在于,我们无法确保我们的PopMenuView一定在最外面,因此可能被其他外层遮挡。为了确保PopMenuView一定会在最外层弹出,我们只能弹出一个 UIViewController,这样保证一定出现在最外层。
![]()
我们只需要获取到offset Y的高度即可,这个值也是PopMenuButton对应的在Golbal对应的offset y。
对于获取视图在对应视图的位置,我们可以使用GeometryReader。今天在测试通过PreferenceKey传递获取的偏移量时候,意外试验出一个BUG。
通过 PreferenceKey 获取指定视图的偏移量
1 创建 PreferenceKey
struct TextPointKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
value = nextValue()
}
}
2 组件获取 Point
Text("Hello World!")
/// 一般使用 `background`其实也可以使用 `overlay`
.background {
/// 使用 `GeometryReader` 获取父试图的大小
GeometryReader { geometry in
/// 使用 透明颜色 是为了不污染界面
Color.clear
/// 通过`GrometryProxy`的`frame`方法可以获取对应的位置
/// 保存在 `Preference`中
.preference(key: TextPointKey.self,
value: geometry.frame(in: .global).origin)
}
}
3 通过 onPreferenceChange 获取刚才设置的值
Text("Hello World!")
/// 一般使用 `background`其实也可以使用 `overlay`
.background {
...
}
.onPreferenceChange(TextPointKey.self) { point in
print(point.debugDescription)
}
此时我们运行可以看到有下面打印信息。
(148.5, 408.1666666666667)
上述的方法进行使用会可能引起获取不到的Bug,关于这个Bug的研究可以看下面的文章。
[]: xiaozhuanlan.com/topic/74531…"关于 SwiftUI 通过 Preference 获取视图 Frame 的隐藏 BUG 探索"
获取PopMenuButton对应global的point
1 新增PreferenceKey
struct PopMenuPointKey: PreferenceKey {
static var defaultValue: [CGPoint] = []
static func reduce(value: inout [CGPoint], nextValue: () -> [CGPoint]) {
value.append(contentsOf: nextValue())
}
}
2 获取选择工厂组件的Point
struct HomePage: View {
...
var body: some View {
PageContentView(title: "首页",
viewModel: viewModel) {
...
} leadingBuilder: {
HStack(spacing:6) {
...
}
.background(content: {
GeometryReader { geometry in
Color.clear
.preference(key: PopMenuPointKey.self, value: [geometry.frame(in: .global).origin])
}
})
.onPreferenceChange(PopMenuPointKey.self, perform: { points in
print(points.debugDescription)
})
...
} trailingBuildeder: {
...
}
...
}
}
[(16.0, 60.0)]
[(16.000000000000007, 60.0)]
[(16.0, 60.0)]
打印了三次,打印多次,这就是使用数组的弊端吧。
保存获取到的Point
为了能够让我们弹出一个UIViewController可以定位到,我们需要将这个Point保存下来,我们需要新增一个@State变量存起来。
struct HomePage: View {
...
@State private var popMenuButtonOffset:CGPoint = .zero
var body: some View {
PageContentView(title: "首页",
viewModel: viewModel) {
...
} leadingBuilder: {
HStack(spacing:6) {
...
}
...
.onPreferenceChange(PopMenuPointKey.self, perform: { points in
guard let point = points.first else { return }
popMenuButtonOffset = point
})
...
} trailingBuildeder: {
EmptyView()
}
...
}
}
新增 View 展示 PopMenuView
struct PopMenuContentView<T:PopMenuItem>: View {
/// 数据源
private let items:[T]
/// `PopMenuButton`的`Offset`
private let offset:CGPoint
/// 当前选中的数据源
@Binding private var currentItem:T
init(items:[T],
offset:CGPoint,
currentItem:Binding<T>) {
self.items = items
self.offset = offset
self._currentItem = currentItem
}
var body: some View {
GeometryReader { geometry in
popMenuButton
.offset(x: 0, y: offset.y)
}
}
private var popMenuButton: some View {
PopMenuButton(items: items, currentItem: $currentItem) {item in
currentItem = item
}
}
}
使用UIHostingController展示工厂列表
struct HomePage: View {
...
@State private var popMenuButtonOffset:CGPoint = .zero
var body: some View {
PageContentView(title: "首页",
viewModel: viewModel) {
...
} leadingBuilder: {
HStack(spacing:6) {
...
}
...
.onTapGesture {
let rootView = PopMenuContentView(items: viewModel.factoryList,
offset: popMenuButtonOffset,
currentItem: $viewModel.currentFactory)
let controller = UIHostingController(rootView: rootView)
controller.modalPresentationStyle = .overFullScreen
controller.view.backgroundColor = .clear
let rootWindow:UIWindow?
if #available(iOS 13.0, *) {
rootWindow = UIApplication.shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.compactMap({$0 as? UIWindowScene})
.first?.windows
.filter({$0.isKeyWindow})
.first
} else {
rootWindow = UIApplication.shared.windows.filter({$0.isKeyWindow}).first
}
rootWindow?.rootViewController?.present(controller, animated: false, completion: nil)
}
} trailingBuildeder: {
...
}
...
}
}
封装获取Key Window的获取方法
我们在弹出了UIHostingController代码的时候,我们再次写了获取Key Window的代码,这是我们第二次用到,我们可以将获取Key Window进行封装,方便我们后续的使用。
struct App {
static var keyWindow:UIWindow? {
if #available(iOS 13.0, *) {
return UIApplication.shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.compactMap({$0 as? UIWindowScene})
.first?.windows
.filter({$0.isKeyWindow})
.first
} else {
return UIApplication.shared.windows
.filter({$0.isKeyWindow})
.first
}
}
}
替换掉工程现有获取Key Window的方法
DataPickerManager
class DataPickerManager {
...
/// show 方法采用 @ViewBuilder 获取自定义的视图
func show<Content:View>(@ViewBuilder _ content:() -> Content) {
/...
guard let rootViewController = App.keyWindow?.rootViewController else {return}
...
}
...
}
struct HomePage: View {
...
var body: some View {
PageContentView(title: "首页",
viewModel: viewModel) {
...
} leadingBuilder: {
HStack(spacing:6) {
...
}
...
.onTapGesture {
...
App.keyWindow?.rootViewController?.present(controller, animated: false, completion: nil)
}
} trailingBuildeder: {
...
}
...
}
}
修复偏移问题
![]()
修改完毕,我们运行之后发现,这偏移的位置和也太远了。为了探明原因,我们修改一下PopMenuContentView背景颜色,看一下问题所在。
struct PopMenuContentView<T:PopMenuItem>: View {
...
var body: some View {
GeometryReader { geometry in
...
}
.background(.blue)
}
...
}
![]()
发现PopMenuContentView是完全铺满的,不是因为安全距离造成的。那是不是偏移量导致的吗?我们去掉offset.
struct PopMenuContentView<T:PopMenuItem>: View {
...
var body: some View {
GeometryReader { geometry in
popMenuButton
}
.background(.blue)
}
...
}
![]()
我们去掉offset之后,竟然布局好像从安全距离开始的。我们就忽略掉安全距离,再次试一下。
struct PopMenuContentView<T:PopMenuItem>: View {
...
var body: some View {
GeometryReader { geometry in
popMenuButton
}
.ignoresSafeArea()
.background(.blue)
}
...
}
![]()
这个就符合我们的预期了。我们将代码恢复,运行,我们的组件已经布局正常了。
![]()
此时凭空出现的PopMenuView显得十分的突兀,我们不妨让PopMenuView显示在PopMenuButton的下来会好的多。
修改PopMenuPointKey值为[CGRect]
PopMenuPointKey
struct PopMenuPointKey: PreferenceKey {
static var defaultValue: [CGRect] = []
static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
value.append(contentsOf: nextValue())
}
}
HomePage
struct HomePage: View {
...
var body: some View {
PageContentView(title: "首页",
viewModel: viewModel) {
...
} leadingBuilder: {
...
.background(content: {
GeometryReader { geometry in
Color.clear
.preference(key: PopMenuPointKey.self, value: [geometry.frame(in: .global)])
}
})
.onPreferenceChange(PopMenuPointKey.self, perform: { rects in
guard let rect = rects.first else { return }
popMenuButtonOffset = CGPoint(x: rect.minX, y: rect.maxY)
})
...
} trailingBuildeder: {
EmptyView()
}
...
}
}
![]()
此时我们的界面看起来好一些,但是还是很丑。
封装PopMenu
刚才经过我们一阵的修改,功能实现了,但是需要实现这个功能,需要改很多的东西。最好体验就是封装一个组件,可以自定义PopMenuButton和自定义PopMenuView。通过一个变量控制UIHostingController显示和隐藏。
类似这样伪代码
Button("show Menu")
.popMenu(isShow:$isShow) {
PopMenuView
}
改造PopMenuContentView
我们要做的让PopMenuContentView现实的内容可以用户自定义,参数offset保持不变。
struct PopMenuContentView<Content:View>: View {
...
/// 内容视图
private let content:Content
init(offset:CGPoint, @ViewBuilder content:() -> Content) {
...
self.content = content()
}
var body: some View {
GeometryReader { geometry in
content
...
}
...
}
}
封装.popMenu方法
struct PopMenuViewModify: ViewModifier {
@Binding private var isShow:Bool
init(isShow:Binding<Bool>) {
_isShow = isShow
}
func body(content: Content) -> some View {
content
.background(content: {
GeometryReader { geometry in
Color.clear
.preference(key: PopMenuPointKey.self, value: [geometry.frame(in: .global)])
}
})
}
}
保存获取到的Frame
struct PopMenuViewModify: ViewModifier {
...
/// `PopMenuButton`的`Frame`
@State private var contentFrame:CGRect = .zero
...
func body(content: Content) -> some View {
content
...
.onPreferenceChange(PopMenuPointKey.self) { rects in
guard let rect = rects.first else {return}
contentFrame = rect
}
}
}
通过onChange监听isShow值的变动
struct PopMenuViewModify: ViewModifier {
...
func body(content: Content) -> some View {
content
...
.onChange(of: isShow) { newValue in
if newValue {
/// 展示 `UIHostingController`
} else {
/// 隐藏 `UIHostingController`
}
}
}
}
新增一个 @ViewBuilder设置 PopMenuView
struct PopMenuViewModify<PopMenuView:View>: ViewModifier {
...
/// 自定义 `PopMenuView`的闭包
private let contentBlock:() -> PopMenuView
init(isShow:Binding<Bool>,
@ViewBuilder content:@escaping () -> PopMenuView) {
...
contentBlock = content
}
func body(content: Content) -> some View {
...
}
}
展示 UIHostingController
struct PopMenuViewModify<PopMenuView:View>: ViewModifier {
...
func body(content: Content) -> some View {
content
...
.onChange(of: isShow) { newValue in
if newValue {
/// 展示 `UIHostingController`
show()
} else {
/// 隐藏 `UIHostingController`
}
}
}
private func show() {
let offset = CGPoint(x: contentFrame.minX, y: contentFrame.maxY)
let rootView = PopMenuContentView(offset: offset, content: {
contentBlock()
})
let controller = UIHostingController(rootView: rootView)
controller.modalPresentationStyle = .overFullScreen
controller.view.backgroundColor = .clear
App.keyWindow?.rootViewController?.present(controller,
animated: false,
completion: nil)
}
}
隐藏 UIHostingController
当我们进行隐藏时候发现,我们此时已经拿不到当前弹出的视图。
通过 presentedViewController获取当前弹出的 UIHostingController
var presentedViewController: UIViewController? { get }
当您使用 present(_:animated:completion:) 方法以模态方式(显式或隐式)呈现视图控制器时,调用该方法的视图控制器将此属性设置为它呈现的视图控制器。 如果当前视图控制器没有以模态方式呈现另一个视图控制器,则此属性中的值为 nil。
struct PopMenuViewModify<PopMenuView:View>: ViewModifier {
...
private func dismiss() {
let controller = App.keyWindow?.rootViewController?.presentedViewController
controller?.dismiss(animated: false, completion: nil)
}
}
封装 View 的扩展
extension View {
func popMenu<PopMenuView:View>(isShow:Binding<Bool>,
@ViewBuilder content:@escaping () -> PopMenuView) -> some View {
let modify = PopMenuViewModify(isShow: isShow, content: content)
return self.modifier(modify)
}
}
将封装好的PopMenu组件替换首页工厂功能
struct HomePage: View {
...
@State private var isShowFactoryMenu:Bool = false
...
var body: some View {
PageContentView(title: "首页",
viewModel: viewModel) {
...
} leadingBuilder: {
HStack(spacing:6) {
...
}
.popMenu(isShow: $isShowFactoryMenu, content: {
PopMenuButton(items: viewModel.factoryList,
currentItem: $viewModel.currentFactory) { item in
viewModel.currentFactory = item
isShowFactoryMenu = false
}
})
.onTapGesture {
isShowFactoryMenu = true
}
} trailingBuildeder: {
...)
}
...
}
}
发现我们使用起来更加的简单方便。
![]()
修改登录页面选择服务器组件
struct LoginPage: View {
...
@StateObject private var appConfig:AppConfig = AppConfig.share
var body: some View {
... {
... {
...
... {
ServerSelectMenuView()
...
.popMenu(isShow: $viewModel.isShowServerMenu) {
PopMenuButton(items: viewModel.supportServerUrls,
currentItem: $appConfig.currentAppServer) { item in
appConfig.currentAppServer = item
viewModel.isShowServerMenu = false
}
}
.onTapGesture {
viewModel.isShowServerMenu = true
}
...
}
...
}
...
}
...
}
}
![]()
第二十八章 重置 ObservableObject 模型数据
经过通过Demo工程不停的测试,终于尝试出来两种版本可以解决问题,一种通过@ObservedObject的方式可以解决问题,另外通过@StateObject解决问题。但是不管通过@ObservedObject还是@StateObject方式,都需要将需要修改的对象用@Published声明。
class RootModel: ObservableObject {
static let root = RootModel()
@Published var model:Model = Model()
}
class Model: ObservableObject {
@Published var text:String = ""
@Published var isOpen:Bool = true {
didSet {
text = "你能发现我了,恭喜你!"
}
}
}
下面讲述一下通过@ObservedObject的方式来实现。
@main
struct ExampleApp: App {
@StateObject var model = RootModel()
var body: some Scene {
WindowGroup {
VStack {
ContentView(model: model.model)
Button("tap") {
model.model = Model()
}
}
}
}
}
struct ContentView: View {
@ObservedObject var model:Model
var body: some View {
VStack {
Text(model.text)
Toggle("是否显示", isOn: $model.isOpen)
}
}
}
![]()
当是我运行看到效果达到的时候,我并没有满足当前的解决方案,我觉得通过传递参数这一种有点复杂,并不是我们想要的,后来经过尝试了很多次,终于发现了另外的一种。
通过@StateObject达成效果
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
VStack {
ContentView()
Button("tap") {
RootModel.root.model = Model()
}
}
}
}
}
struct ContentView: View {
@StateObject var model = RootModel.root
var body: some View {
VStack {
Text(model.model.text)
Toggle("是否显示", isOn: $model.model.isOpen)
}
}
}
通过上述的代码,我们一样完成了功能。不过的是我们的RootModel需要做成单例模式,不过这个没关系,正好符合我们的需求。
不过我觉得第二种实现起来更加的方便,不需要将参数传来传去的。那么我们就用第二种方法改造上一章节的问题。
改造 UserConfig 的生成规则
第一步 修改 AppConfig 中的 userConfig 从 Optional 修改为 No Optional
var userConfig:UserConfig
这样我们在使用UserConfig中的参数@Published时候不会因为语法报错,当用户没有登录使用默认的值,也是符合正常的业务逻辑。
第二步 修改 getUserConfig 方法的逻辑
![]()
private func getUserConfig() -> UserConfig {
/// 流程图地址 
/// 如果服务器地址为空 或者 当前登录用户不存在 则返回 [server = "" user = 0]的默认配置
let defaultUserConfig = UserConfig(server: "", user: "0")
guard !currentAppServer.isEmpty else { return defaultUserConfig }
guard let currentUserId = try? UserManager.EmployeeNo(currentUserId).value else { return defaultUserConfig }
return UserConfig(server: currentAppServer, user: currentUserId)
}
但是这个方式还是存在一些问题,可能还存在下面的情况
![]()
既然是用户配置,自然和用户强相关的,没有服务器地址用户就不能登录,没有登录就不存在用户ID,所以获取不到统一用一套新的用户配置是可以的,当在已经登录情况下,不存在服务器地址和用户ID是错误的,是不允许存在的。
为了保障我们获取UserConfig的逻辑的严谨性,我们按照最新的逻辑图进行修改代码。
class AppConfig: ObservableObject {
...
/// 流程图地址 https://gitee.com/joser_zhang/upic/raw/master/uPic/202112151044218.png
private func getUserConfig() -> UserConfig {
guard !currentAppServer.isEmpty else {
/// 如果服务器为空 则创建 [server = ""] [user = 0]的用户配置
return UserConfig(server: "", user: "0")
}
guard let currentUserId = try? UserManager.EmployeeNo(currentUserId).value else {
/// 如果服务器不为空 当前不存在登录用户的ID 则创建 [server = "xxx"] [user = "0"]的用户配置
return UserConfig(server: currentAppServer, user: "0")
}
/// 如果服务器存在 存在登录用户的ID 就返回[server = "xxx"][user = "xxx"]的用户配置
/// 里面是否重新创建配置还是读取本地已经存在配置 交给 `UserConfig`处理
return UserConfig(server: currentAppServer, user: currentUserId)
}
}
第三步 修改 AppConfig 初始化
fileprivate extension Notification.Name {
...
static let currentServerChanged = Notification.Name("currentServerChanged")
}
class AppConfig: ObservableObject {
...
/// 当前 App 的服务器地址
@AppStorage("currentAppServer")
var currentAppServer:String = "" {
didSet {
NotificationCenter.default.post(name: .currentServerChanged, object: nil)
}
}
...
private var cancellabelSet:Set<AnyCancellable> = []
init() {
...
/// 监听 `currentAppServer` 的变化重新生成 `UserConfig`
NotificationCenter.default.publisher(for: .currentServerChanged, object: nil)
.sink { [weak self] no in
/// 监听到`currentAppServer`改变,重新生成`UserConfig`
guard let self = self else {return}
self.userConfig = self.getUserConfig()
}
.store(in: &cancellabelSet)
}
...
}
修改是否登录逻辑
为了可以确保可以在用户登录之后拿到UserConfig没有问题,我们需要修改一下isLogin的逻辑。我们假设一下我们我们不修改会造成什么的危害?
![]()
红色箭头的逻辑是有问题的,因为覆盖安装,旧版本已经登录情况下,用户操作的所有配置都保存在默认配置下面,是存在问题的。
为了解决旧版本已经登录的情况,我们就修改isLogin的逻辑,让旧版本已经登录的用户保持未登录的状态。
![]()
我们将之前通过判断gatewayUserName换成了employeeNO,不但兼容了老版本,而且新版本后续登录也不会出问题。
1 新增 employeeNo 是否来源于缓存字段替换 isGatewayUserNameFromCache
class AppConfig: ObservableObject {
...
/// `employeeNo` 是否来源于缓存 默认来源于缓存
var isEmplyeeNoFromCache:Bool = true
...
}
2 用户主动登录之后 修改 isEmplyeeNoFromCache = false
struct UserManager {
...
/// 进行登录
func login() {
AppConfig.share.isEmplyeeNoFromCache = false
...
}
}
3 修改入口 isNeedLogin 代码
struct Win_App: App {
@StateObject private var appConfig:AppConfig = AppConfig.share
...
private var isNeedLogin:Bool {
/// 如果 `employeeNo` 不存在 则需要进行登录
guard isExitUserId else { return true}
/// 如果 `employeeNo` 存在 并且 `isEmplyeeNoFromCache = false` 代表是刚刚登录的 则不需要登录
guard appConfig.isEmplyeeNoFromCache else { return false }
/// 此时 `employeeNo`已经存在 假设是全新创建`UserConfig`默认`isAutoLogin = false`也是需要进行登录操作的
return !appConfig.userConfig.isAutoLogin
}
private var isExitUserId:Bool {
guard let _ = try? UserManager.EmployeeNo(appConfig.currentUserId).value else {
return false
}
return true
}
}
修复工程报错
因为我们将所有存放在AppConfig的信息转移到UserConfig中,很多地方出现了报错,经过上面修改逻辑,我们拿着AppConfig.UserConfig修复工程中出现的错误。
修复 Api
class Api: API {
...
static var defaultHeadersConfig: ((inout HTTPHeaders) -> Void)? {
return { headers in
if let gatewayUserName = AppConfig.share.userConfig.gatewayUserName {
...
}
if let currentFactoryCode = AppConfig.share.userConfig.currentFactoryCode {
...
}
}
}
}
修复 HomePageViewModel
class HomePageViewModel: BaseViewModel {
...
@Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
factoryName: nil) {
didSet {
AppConfig.share.userConfig.currentFactoryCode = currentFactory.factoryCode
}
}
...
/// 查找保存的工厂代码对应最新工厂列表的模型
private func findFactory() -> FactoryListResponseModel? {
return factoryList.first { model in
guard let currentFactoryCode = AppConfig.share.userConfig.currentFactoryCode else {return false}
...
}
}
}
修复 MyPage
struct MyPage: View {
...
@StateObject private var appConfig = AppConfig.share
...
private func userNameCell() -> some View {
MyDetailStyle1CellContentView(title: "姓名",
detail: AppConfig.share.userConfig.userInfoModel?.userName ?? "")
}
...
private func autoLoginCell() -> some View {
MyCellContentView(title: "自动登录") {
Toggle("", isOn: $appConfig.userConfig.isAutoLogin)
}
}
...
private func logoutButton() -> some View {
Button {
appConfig.userConfig.gatewayUserName = nil
...
} label: {
...
}
}
/// 点击了产线
private func didClickProductLine() {
guard let _ = appConfig.userConfig.workShopCode else {
...
return
}
...
}
...
}
修复 AppConfig 报错
class AppConfig: ObservableObject {
...
/// 当前 App 的服务器地址
@AppStorage("currentAppServer")
var currentAppServer:String = "" {
...
}
...
var userConfig:UserConfig
...
init() {
/// ❌ 'self' used in property access 'currentAppServer' before all stored properties are initialized
if currentAppServer.isEmpty {
...
}
/// ❌ 'self' used in method call 'getUserConfig' before all stored properties are initialized
/// 初始化 UserConfig
self.userConfig = getUserConfig()
...
}
/// 流程图地址 https://gitee.com/joser_zhang/upic/raw/master/uPic/202112151044218.png
private func getUserConfig() -> UserConfig {
...
}
}
分别存在两个错误
-
currentAppServer在userConfig之前使用,因为调用方法和变量默认省去了self.,所以我们需要在AppConfig初始化完毕才能使用currentAppServer - 我们在
AppConfig初始化之前调用了方法getUserConfig
1 将 getUserConfig 方法修改为类方法
/// 流程图地址 https://gitee.com/joser_zhang/upic/raw/master/uPic/202112151044218.png
/// 根据提供的服务器地址 和当前登录用户的唯一ID 查询本地已经存在的用户配置 或者重新生成新的用户配置
/// - Parameters:
/// - server: 服务器地址
/// - userId: 当前登录用户的唯一ID
/// - Returns: 当前用户的配置
private static func getUserConfig(from server:String, userId:String?) -> UserConfig {
guard !server.isEmpty else {
/// 如果服务器为空 则创建 [server = ""] [user = 0]的用户配置
return UserConfig(server: "", user: "0")
}
guard let currentUserId = try? UserManager.EmployeeNo(userId).value else {
/// 如果服务器不为空 当前不存在登录用户的ID 则创建 [server = "xxx"] [user = "0"]的用户配置
return UserConfig(server: server, user: "0")
}
/// 如果服务器存在 存在登录用户的ID 就返回[server = "xxx"][user = "xxx"]的用户配置
/// 里面是否重新创建配置还是读取本地已经存在配置 交给 `UserConfig`处理
return UserConfig(server: server, user: currentUserId)
}
2 在 AppConfig 初始化之前获取 server 和 userId 的值
let server = _currentAppServer.wrappedValue
let userId = _currentUserId.wrappedValue
3 调整 currentAppServer 赋值和 userConfig 初始化的位置
class AppConfig: ObservableObject {
...
init() {
...
/// 初始化 UserConfig
self.userConfig = AppConfig.getUserConfig(from: server, userId: userId)
if currentAppServer.isEmpty {
...
}
...
}
...
}
4 修复 AppConfig 其他报错
class AppConfig: ObservableObject {
...
init() {
...
/// 监听 currentUserId 的变化
/// `@AppStorage` 是无法进行监听的 因此这里采用 `Notification`
NotificationCenter.default.publisher(for: .currentUserIdChanged, object: nil)
.sink {[weak self] no in
...
self.userConfig = AppConfig.getUserConfig(from: self.currentAppServer, userId: self.currentUserId)
}
...
/// 监听 `currentAppServer` 的变化重新生成 `UserConfig`
NotificationCenter.default.publisher(for: .currentServerChanged, object: nil)
.sink { [weak self] no in
...
self.userConfig = AppConfig.getUserConfig(from: self.currentAppServer, userId: self.currentUserId)
}
...
}
...
}
修复 MyPageViewModel
class MyPageViewModel: BaseViewModel {
...
/// 当前选中的车间
@Published var currentWorkshop:GetAllWorkshopResponse? {
didSet {
AppConfig.share.userConfig.workShopCode = currentWorkshop?.workshopCode
}
}
...
/// 当前选中产线的模型
@Published var currentProductLine:GetAllProductLineApiResponse? {
didSet {
AppConfig.share.userConfig.productLineCode = currentProductLine?.code
}
}
...
/// 当前选中的仓库
@Published var currentStoreHouse:GetAllStoreHouseApiResponse? {
didSet {
AppConfig.share.userConfig.storeHouseCode = currentStoreHouse?.code
}
}
override init() {
...
workshopCancellabel = AppConfig.share.userConfig.$workShopCode.sink {[weak self] value in
...
}
}
...
private func getAllWorkShop() async {
...
if let workShopCode = AppConfig.share.userConfig.workShopCode {
...
guard let _ = index else {
/// 如果查询不到 意味着之前选中的数据已经不存在 则默认第一个
AppConfig.share.userConfig.workShopCode = workShops.first?.workshopCode
return
}
AppConfig.share.userConfig.workShopCode = workShopCode
} else {
/// 如果之前没有选中的车间 则默认第一个
AppConfig.share.userConfig.workShopCode = workShops.first?.workshopCode
}
currentWorkshop = data.first(where: { response in
guard let configCode = AppConfig.share.userConfig.workShopCode else {return false}
...
})
}
/// 获取车间下面的所有产线
private func getAllProductLine() async {
guard let workShopCode = AppConfig.share.userConfig.workShopCode else {
return
}
...
/// 是否存在之前选中保存的产线code
if let productLineCode = AppConfig.share.userConfig.productLineCode {
...
} else {
...
}
}
func getAllStoreHouse() async {
...
/// 是否存在之前保存过的仓库
if let storeHouseCode = AppConfig.share.userConfig.storeHouseCode {
...
} else {
...
}
}
...
/// 当前选中车间的名称
func currentWorkShopName() -> String? {
return workShops.first { response in
guard let workShopCode = AppConfig.share.userConfig.workShopCode else {return false}
...
}?.name
}
...
}
修复 UserManager
1 修复报错
struct UserManager {
...
/// 进行登录
func login() {
...
AppConfig.share.userConfig.gatewayUserName = gatewayUserName
AppConfig.share.userConfig.userInfoModel = user
...
}
}
在修复上述代码的时候,我们发现了一个问题。
/// 进行登录
func login() {
AppConfig.share.isEmplyeeNoFromCache = false
/// 设置`gatewayUserName`和`userInfoModel`值
...
/// 设置`currentUserId`重新创建一个新的`UserConfig`
AppConfig.share.currentUserId = employeeNo
}
系统监听到currentUserId变动,重新创建新的UserConfig。
2 调整 设置 gatewayUserName 和 userInfoModel 值位置
/// 进行登录
func login() {
...
AppConfig.share.currentUserId = employeeNo
AppConfig.share.userConfig.gatewayUserName = gatewayUserName
AppConfig.share.userConfig.userInfoModel = user
}
![]()
运行登录,看起来十分的正常。但是我们操作退出的时候发现了竟然无法退出。
![]()
修复退出登录异常
struct MyPage: View {
...
private func logoutButton() -> some View {
Button {
appConfig.userConfig.gatewayUserName = nil
appConfig.currentTabIndex = 0
} label: {
...
}
}
...
}
无法退出的原因在于我们判断登录调整为employeeNo,但是退出登录清空的是gatewayUserName。
1 修复退出失败
struct MyPage: View {
...
private func logoutButton() -> some View {
Button {
appConfig.currentUserId = nil
...
} label: {
...
}
}
...
}
2 UserManager 新增退出方法
struct UserManager {
...
/// 退出登录
static func logout() {
AppConfig.share.currentUserId = nil
AppConfig.share.currentTabIndex = 0
}
}
struct MyPage: View {
...
private func logoutButton() -> some View {
Button {
UserManager.logout()
} label: {
...
}
}
...
}
![]()
第二十七章 UINavigationBarAppearance|Divider
在我的界面,导航栏和内容视图已经融合在一起了,我们没有办法分清楚。
![]()
我们准备让导航条和内容分开,不然这样看起来的UI太丑了。
/// 页面的基础试图
struct PageContentView<Content:View,
Leading:View,
Trailing:View,
ViewModel:BaseViewModel>: View {
...
/// 初始化页面试图
/// - Parameters:
/// - title: 导航标题
/// - contentBuilder: 内容
/// - leadingBuilder: 导航左侧按钮
/// - trailingBuildeder: 导航右侧按钮
init(title:String,
viewModel:ViewModel,
@ViewBuilder contentBuilder:() -> Content,
@ViewBuilder leadingBuilder:() -> Leading,
@ViewBuilder trailingBuildeder:() -> Trailing) {
...
let appearance = UINavigationBarAppearance()
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().compactAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance
}
...
}
![]()
此时我们创建一个默认导航条的配置,可以轻松和内容是如区分。我们设置一下导航条的背景颜色为白色,和我们底部的颜色保持一致。
let appearance = UINavigationBarAppearance()
appearance.backgroundColor = .white
![]()
如图所示,我们在最后一行也显示了线,导致界面上十分的丑,我们将界面可以进行配置这条线。
struct MyCellContentView<Right:View>: View {
...
private let isShowBottomLine:Bool
...
init(title:String,
isShowBottomLine:Bool = true,
@ViewBuilder rightBuilder:() -> Right) {
...
self.isShowBottomLine = isShowBottomLine
...
}
var body: some View {
VStack(spacing: 0) {
...
if isShowBottomLine {
...
} else {
/// 是为了填充让控件一样的高度
Color.clear
.frame(height: 0.5)
}
}
...
}
}
struct MyDetailStyle1CellContentView: View {
...
private let isShowBottomLine:Bool
init(title:String,
detail:String,
isShowBottomLine:Bool = true) {
...
self.isShowBottomLine = isShowBottomLine
}
var body: some View {
MyCellContentView(title: title,
isShowBottomLine: isShowBottomLine) {
...
}
}
}
struct MyDetailCellContentView: View {
...
private let isShowBottomLine:Bool
init(title:String,
detail:String,
isShowBottomLine:Bool = true) {
...
self.isShowBottomLine = isShowBottomLine
}
var body: some View {
MyCellContentView(title: title,
isShowBottomLine: isShowBottomLine) {
...
}
}
}
突然我们发现有 Divider 这个组件,就是分割 UI元素用的,我们可以替换我们之前自定义的线。
struct MyCellContentView<Right:View>: View {
...
var body: some View {
VStack(spacing: 0) {
...
if isShowBottomLine {
Divider()
.padding(.leading, 15)
} else {
...
}
}
...
}
}
![]()
我们自动登录的高度明显要高于其他,主要原因我们设置自动布局,并且设置外边距是 15。这就导致 Switch组件默认高度比较高,加上15的Padding之后,整体放入高度会比较高。
我们将组件限制为50 高度,其余的元素全部居中对齐。
struct MyCellContentView<Right:View>: View {
...
var body: some View {
ZStack {
VStack(spacing: 0) {
HStack {
...
}
...
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
}
VStack {
Spacer()
if isShowBottomLine {
...
} else {
...
}
}
}
...
.frame(height:50)
}
}
![]()
此时我们的界面已经优化的和设计图差不多了,接下来我们开始优化我们功能。
class AppConfig: ObservableObject {
...
@AppStorage("gatewayUserName")
var gatewayUserName:String?
/// 当前选中的工厂代码
@AppStorage("currentFactoryCode")
var currentFactoryCode:String?
@AppStorage("userInfo")
private var userInfo:String?
...
/// 是否自动登录
@AppStorage("isAutoLogin")
var isAutoLogin = false
/// 选中的车间代码
@AppStorage("workShopCode")
/// 因为 _workShopCode 已经被系统使用 我们用workShopCode_
private var workShopCode_:String?
...
/// 选中的产线 code
@AppStorage("productLineCode")
var productLineCode:String?
/// 选中仓库 code
@AppStorage("storeHouseCode")
var storeHouseCode:String?
...
}
观察上述的代码,我们的本地存储是有问题的。因为服务器地址,用户发生了改变,这些值就要跟着发生改变。那就意味着,我们不能用这些不变的作为Key,需要加入服务器地址和用户的唯一ID作为条件。
但是我们在 AppConfig 初始化Key又拿不到当前保存的服务器地址和用户的唯一ID,我们不妨把用户相关的配置分离出来。
/// 用户配置
class UserConfig: ObservableObject {
private let server:String
private let user:String
@AppStorage(gatewayUserNameKey)
var gatewayUserName:String?
private var gatewayUserNameKey:String {
return "gatewayUserName_\(server)_\(user)"
}
init(server:String, user:String) {
self.server = server
self.user = user
}
}
但是上面代码报了错误
Cannot use instance member 'gatewayUserNameKey' within property initializer; property initializers run before 'self' is available
这样我们无法进行初始化,我们就按照之前我们做的,自己自定义进行初始化。
/// 用户配置
class UserConfig: ObservableObject {
...
@AppStorage
var gatewayUserName:String?
init(server:String, user:String) {
...
self._gatewayUserName = AppStorage("gatewayUserName_\(server)_\(user)")
}
}
我们将所有和用户有关的配置都转移到 UserConfig 里面。
/// 用户配置
class UserConfig: ObservableObject {
...
init(server:String, user:String) {
self.server = server
self.user = user
let userKey = "\(server)_\(user)"
self._gatewayUserName = AppStorage("gatewayUserName_\(userKey)")
self._currentFactoryCode = AppStorage("currentFactoryCode_\(userKey)")
self._userInfo = AppStorage("userInfo_\(userKey)")
self._isAutoLogin = AppStorage(wrappedValue: false, "isAutoLogin_\(userKey)")
self._workShopCode_ = AppStorage("workShopCode_\(userKey)")
self._productLineCode = AppStorage("productLineCode_\(userKey)")
self._storeHouseCode = AppStorage("storeHouseCode_\(userKey)")
self.workShopCode = workShopCode_
}
}
![]()
我们要获取用户的配置的时候必须要拿到用户ID,获取用户ID的时候必须拿到用户配置。这个似乎陷入了死循环中,我们看下面的流程。
![]()
我们在整个流程中发现,只有当用户没有登录,重新登录可以拿到用户ID获取到用户配置,才能打破这个死循环。但是在已经登录的流程,想要获取到用户配置就是一个死循环。
想要打破这个循环,就要改变上面的逻辑。
![]()
我们将判断是否登录换成了判断本地是否有用户ID,有了用户ID就可以获取到用户配置,从而打破循环。
class AppConfig: ObservableObject {
...
/// 当前登录的用户ID
@AppStorage("currentUserId")
var currentUserId:String?
...
}
字段 currentUserId 来源于我们用户信息中的 employeeNo 字段,我们在用户登录的时候进行保存employeeNo字段到本地。
class LoginPageViewModel: BaseViewModel {
...
func login() async {
...
AppConfig.share.currentUserId = model.data?.user?.employeeNo
}
}
此时我们看一下我们当前用户登录之后的设置代码。
if let gatewayUserName = model.data?.gatewayUserName {
/// 放在 [AppConfig.share.gatewayUserName] 赋值的前面 这样 gatewayUserName 通知时候才能获取 isGatewayUserNameFromCache 最新值
AppConfig.share.isGatewayUserNameFromCache = false
AppConfig.share.userConfig?.gatewayUserName = gatewayUserName
}
AppConfig.share.userConfig?.userInfoModel = model.data?.user
AppConfig.share.currentUserId = model.data?.user?.employeeNo
我们此时只有一处地方可以登录,我们后续可能还有手机号/微信/微博/苹果等等登录方式,可能登录地方就要写很多这种逻辑。我们不如将登录之后的逻辑放在一个统一的方法里面,以后在其他登录方法或者页面登录之后进行调用。
struct UserManager {
/// 登录时候 类似于JWT的值
private let gatewayUserName:String
/// 用户唯一的 ID 当前值代表员工的工号
private let employeeNo:String
/// 用户的信息
private let user:UserInfoModel
/// 初始化用户管理中心 如果初始化失败 则返回异常
/// - Parameter response: 用户登录的返回内容
init(userLogin response:UserLoginResponse) throws {
guard let gatewayUserName = response.gatewayUserName, !gatewayUserName.isEmpty else {
throw "[gatewayUserName]返回为空"
}
self.gatewayUserName = gatewayUserName
guard let user = response.user else {
throw "[user]返回为空"
}
self.user = user
guard let employeeNo = response.user?.employeeNo, !employeeNo.isEmpty else {
throw "[employeeNo]返回为空"
}
guard let _ = Int(employeeNo) else { throw "[employeeNo]必须是纯数字" }
self.employeeNo = employeeNo
}
/// 进行登录
func login() {
AppConfig.share.isGatewayUserNameFromCache = false
AppConfig.share.userConfig?.gatewayUserName = gatewayUserName
AppConfig.share.userConfig?.userInfoModel = user
AppConfig.share.currentUserId = employeeNo
}
}
我们在 UserManager 初始化的时候做了验证并可能抛出异常,我们初始化这么多验证,如果后续的字段更多,岂不是初始化逻辑就很复杂了。我们修改一下上面初始化方法,将验证进行一次简化。
struct UserManager {
...
init(userLogin response:UserLoginResponse) throws {
self.gatewayUserName = try UserManager.verify(gatewayUserName: response.gatewayUserName)
self.user = try UserManager.verify(user: response.user)
self.employeeNo = try UserManager.verify(employeeNo: self.user.employeeNo)
}
/// 验证 gatewayUserName 的值
/// - Parameter name: gatewayUserName 值
/// - Returns: 验证通过的 gatewayUserName 值
private static func verify(gatewayUserName name:String?) throws -> String {
guard let gatewayUserName = name, !gatewayUserName.isEmpty else {
throw "[gatewayUserName]返回为空"
}
return gatewayUserName
}
/// 验证用户信息
/// - Parameter user: 用户信息
/// - Returns: 验证通过的用户信息
private static func verify(user model:UserInfoModel?) throws -> UserInfoModel {
guard let user = model else {
throw "[user]返回为空"
}
return user
}
/// 验证 employeeNo 的值
/// - Parameter no: employeeNo 值
/// - Returns: 验证通过的 employeeNo 值
private static func verify(employeeNo no:String?) throws -> String {
guard let employeeNo = no, !employeeNo.isEmpty else {
throw "[employeeNo]返回为空"
}
guard let _ = Int(employeeNo) else { throw "[employeeNo]必须是纯数字" }
return employeeNo
}
...
}
此时我们将验证提炼出来,可以给 UserManager的其他的初始化方法进行调用。我们还可以对于代码进行提炼进行修改,我们修改成下面的样子。
struct UserManager {
...
init(userLogin response:UserLoginResponse) throws {
self.gatewayUserName = try GatewayUserName(response.gatewayUserName).value
self.user = try User(response.user).value
self.employeeNo = try EmployeeNo(response.user?.employeeNo).value
}
...
}
fileprivate protocol UserResponseVerify {
associatedtype T
var value:T { get }
init(_ value:T?) throws
}
extension UserManager {
struct GatewayUserName: UserResponseVerify {
let value: String
init(_ value: String?) throws {
... 验证过程
self.value = gatewayUserName
}
}
struct User: UserResponseVerify {
let value: UserInfoModel
init(_ value: UserInfoModel?) throws {
... 验证过程
self.value = user
}
}
struct EmployeeNo: UserResponseVerify {
let value: String
init(_ value: String?) throws {
... 验证过程
self.value = employeeNo
}
}
}
我们修改成这个样子之后,已经渐渐的和 DDD(领域驱动)沾点边了。
class LoginPageViewModel: BaseViewModel {
...
func login() async {
...
if let response = model.data, let userManager = try? UserManager(userLogin: response) {
userManager.login()
}
}
...
}
我们修改了逻辑,已经在登录完毕完成了保存 employeeNo的值,此时我们就要写一下UserConfig的逻辑。
![]()
class AppConfig: ObservableObject {
...
var userConfig:UserConfig?
/// 当前登录的用户ID
@AppStorage("currentUserId")
var currentUserId:String?
init() {
...
/// 初始化 UserConfig
self.userConfig = getUserConfig()
/// 监听 currentUserId 的变化
/// @AppStorage是无法进行监听的
}
private func getUserConfig() -> UserConfig? {
guard !currentAppServer.isEmpty else { return nil }
guard let currentUserId = try? UserManager.EmployeeNo(currentUserId).value else { return nil }
return UserConfig(server: currentAppServer, user: currentUserId)
}
}
我们使用 @AppStorage 是无法通过 sink监听值更新的。我们可以在 currentUserId的didSet中去操作设置新的UserConfig,但是我们上面的逻辑就显得有点中断。
我们可以通过Notification进行实现,让流程连贯起来,方便阅读和维护。
class AppConfig: ObservableObject {
...
private var cancellabelSet:Set<AnyCancellable> = []
init() {
...
/// 初始化 UserConfig
self.userConfig = getUserConfig()
/// 监听 currentUserId 的变化
/// `@AppStorage` 是无法进行监听的 因此这里采用 `Notification`
NotificationCenter.default.publisher(for: .currentUserIdChanged, object: nil)
.sink {[weak self] no in
/// 监听到 `currentUserId` 改变的时候 更新 `UserConfig`
guard let self = self else { return }
self.userConfig = self.getUserConfig()
}
.store(in: &cancellabelSet)
}
...
}
fileprivate extension Notification.Name {
static let currentUserIdChanged = Notification.Name("currentUserIdChanged")
}
写到这里我们发现了userConfig是一个Optional可选值,是无法通过@StateObject初始化的。但是UserConfig如果用户没有登录则无法进行初始化。
/// ❌ Cannot convert value of type 'UserConfig?' to specified type 'UserConfig'
@StateObject private var useConfig:UserConfig = AppConfig.share.userConfig
我想通过用户没有登录就创建一个空的UserConfig,当登录或者重新登录就对当前的UserConfig进行重新的赋值,但是这样的操作十分的麻烦。
就当我绝望,觉得只能通过通过一个个更新才能实现的时候,我想到了在Flutter中可以监听整个对象,如果对象变动,则会更新使用此对象属性所有的Widget。
那么这个思路是否可以通过SwiftUI中实现吗,我们下一章接下来说。
第二十六章 Focused
新增 Profile 环境
到此我们已经做完了登录页面 首页 我的页面,但是还是存在一些问题需要进行优化,比如登录页面在第一次安装App的时候,默认没有服务器地址,需要用户手动的选择一个,这样就让用户可能多一次操作,体验不是很好。
为了可以优化体验,我们觉得第一次安装App的时候给服务器地址增加一项默认值,但是默认值设置为那一个?
![]()
我们目前可以获取是否是 DEBUG编译还是 RELEASE编译,但是无法区分是PROFILE编译。那么我们基于DEBUG环境新增一套环境作为我们PROFILE环境。
![]()
这样我们已经可以区分是否是Profile环境,我们给服务器一个默认的值。
class AppConfig: ObservableObject {
...
init() {
...
if currentAppServer.isEmpty {
#if DEBUG
currentAppServer = AppServer.debug.rawValue
#elseif PROFILE
currentAppServer = AppServer.profile.rawValue
#else
currentAppServer = AppServer.release.rawValue
#endif
}
}
}
登陆页面,记住密码的选项默认也是关闭的,我们修改默认为打开,可以方便用户下次登陆页面不需要重复输入账号和密码。
class LoginPageViewModel: BaseViewModel {
...
///是否记住密码
@AppStorage("isRememberPassword")
var isRememberPassword:Bool = true
...
}
focused 获取输入框是否获取焦点
我们下次启动,用户名和密码已经自动填写,但是我们更换用户名的时候需要一个个的进行删除,如果我们编辑的时候展示删除按钮岂不是可以一键的进行删除。
但是在 SwiftUI 中给 TextField 中添加 ClearMode十分的困难。不过我们可以通过 focused的Modify获取到输入框获取到焦点。
func focused(_ condition: FocusState<Bool>.Binding) -> some View
那么我们就封装一下,当获取焦点的时候并且内容不为空时候,显示Clear按钮。
struct ClearTextField: View {
private let title:String
@Binding private var text:String
/// 获取当前输入框是否获取焦点
@FocusState private var isFocus:Bool
/// 保持和 TextField 一致 好替换
init(_ title:String, text:Binding<String>) {
self.title = title
self._text = text
}
var body: some View {
HStack {
TextField(title, text: $text)
.focused($isFocus)
if isShowClearButton {
/// `X`按钮
Image(systemName: "xmark")
.padding()
.onTapGesture {
/// 点击清空文本
text = ""
}
}
}
}
private var isShowClearButton:Bool {
/// 当获取焦点并且文本不为空才显示清空的按钮
isFocus && !text.isEmpty
}
}
在测试过程中 ClearTextField 组件预览输入文本,无法正常显示 Clear 按钮。但是用在登陆页面,就可以正常显示,这一点很奇怪。
我们的密码不能直接使用上述组件,因为密码需要用到 SecureField 组件。我们给 ClearTextField 新增一个属性,控制使用 TextFiled 还是 ClearTextField。
struct ClearTextField: View {
...
private var isSecure:Bool
/// 保持和 TextField 一致 好替换
init(_ title:String, text:Binding<String>, isSecure:Bool = false) {
...
self.isSecure = isSecure
}
var body: some View {
HStack {
if !isSecure {
TextField(title, text: $text)
.focused($isFocus)
} else {
SecureField(title, text: $text)
.focused($isFocus)
}
...
}
}
...
}
我们登陆页面的用户名和密码输入框换成我们封装的输入框。
struct UserNameValueContentView: View {
...
private var userNameField:some View {
ClearTextField("请输入用户名", text: $viewModel.userName)
}
}
struct PasswordValueContentView: View {
...
private var passwordField:some View {
ClearTextField("请输入密码",
text: $viewModel.password,
isSecure: true)
}
}
![]()
我们运行看了一下效果,发现输入框的高度在获取焦点和失去焦点相互跳跃。原来在于我们给最后 Clear 按钮添加 Padding的时候,上下也添加了 Padding,导致 Clear出现的时候比 输入框的高度还搞 就自动拉伸了整个控件的高度。
为了解决高度跳动的问题,我们将输入框的高度固定在33,因为在UIKit中 UITextField的默认高度就是33。之后设置Clear按钮的高度也是33。
struct ClearTextField: View {
...
var body: some View {
HStack {
...
if isShowClearButton {
/// `X`按钮
Image(systemName: "xmark")
.frame(maxHeight: .infinity)
.padding(.leading, 10)
.padding(.trailing, 10)
...
}
}
.frame(height:33)
}
...
}
![]()
此时我们的输入框的高度不会来回的跳跃了。
虽然我们用户名和密码可以一键的清空,但是我们我们修改用户名的时候,密码按道理说应该被清空。我们不考虑不同账号同一个密码的情况,这种极少存在。
那么我们监听到用户名输入框内容修改的时候,我们就清空密码输入框。
class LoginPageViewModel: BaseViewModel {
...
/// 监听用户名输入
private var userNameCancellabel:AnyCancellable?
override init() {
...
userNameCancellabel = $userName.sink(receiveValue: {[weak self] _ in
guard let self = self else {return}
self.password = ""
})
}
...
}
![]()
我们运行发现,我们本来记住密码的功能失效了,密码被清空了。不明白为什么sink之后立马得到回掉,这个是导致密码被清空的原因所在。
不过我在研究的过程中,发现了一个规律可以解决这个问题。
sink value = admin username = admin /// 相等不需要进行操作
sink value = admin1 username = admin /// 不想等需要进行操作
sink value = admin1 username = admin1 /// 相等不需要进行操作
虽然我们初始化和执行一次用户名更改调用了三次,但是需要执行的只有一次。我们只需要在判断 sink value != userName的情况下去操作我们的密码。
class LoginPageViewModel: BaseViewModel {
...
/// 监听用户名输入
private var userNameCancellabel:AnyCancellable?
override init() {
...
userNameCancellabel = self.$userName.sink(receiveValue: {[weak self] name in
guard let self = self else {return}
/// 如果更新的用户名发生了变动,则清空密码输入框
guard name != self.userName else {return}
self.password = ""
})
}
...
}
![]()
我们输入框在被用户变更之后已经可以正常的删除密码,但是我们的登录按钮在用户名和密码都为空的情况下,竟然依然可以点击操作,这是不合理的。
我们希望在用户名和密码没有输入的情况下,按钮的背景颜色灰色看起来不可点击,当用户输入用户名和密码之后,登录按钮变亮可以点击。
![]()
在我们封装登录按钮的时候我们无法感知外界对于按钮的因素变化,现在只需要判断用户名和密码是否存在,后面可能会需要判断用户名的组成格式是否正确。
我们修改一下流程图。
![]()
这样我们登录按钮只需要关心外面传入的是否可以点击控制自己的状态。
struct LoginButton: View {
@StateObject private var appColor = AppColor.share
/// 是否激活按钮
@Binding private var isActive:Bool
private let action:() -> Void
init(isActive:Binding<Bool>, action:@escaping () -> Void) {
self._isActive = isActive
self.action = action
}
var body: some View {
Button(action:action) {
Text("登录")
.frame(maxWidth:.infinity)
.frame(height: 45)
.background(background)
.foregroundColor(.white)
.cornerRadius(5)
}
.disabled(!isActive)
}
@ViewBuilder private var background:some View {
/// 按钮激活 背景色 #209090 按钮禁用 背景色 #cccccc
if isActive {
Color(uiColor: appColor.c_209090)
} else {
Color(uiColor: appColor.c_cccccc)
}
}
}
为了可以在登录页面根据输入的用户名和密码变化,更改登录按钮的激活状态。
struct LoginPage: View {
@StateObject private var viewModel:LoginPageViewModel = LoginPageViewModel()
...
var body: some View {
PageContentView(title: "登陆", viewModel: viewModel) {
VStack {
...
VStack(spacing:30) {
...
LoginButton(isActive: $viewModel.isLoginButtonActive) {
Task {
await viewModel.login()
}
}
}
...
}
...
}
...
}
}
class LoginPageViewModel: BaseViewModel {
...
private var cancellabel:Set<AnyCancellable> = []
/// 登录按钮是否激活
@Published var isLoginButtonActive:Bool = false
override init() {
...
self.$userName.sink(receiveValue: {[weak self] value in
guard let self = self else {return}
self.updateLoginButtonActive()
...
}).store(in: &cancellabel)
self.$password.sink {[weak self] value in
guard let self = self else {return}
self.updateLoginButtonActive()
}.store(in: &cancellabel)
}
...
/// 更新登录按钮的状态
private func updateLoginButtonActive() {
/// 只有用户名和密码通知不为空的时候才可以激活登录按钮
isLoginButtonActive = !userName.isEmpty && !password.isEmpty
}
}
![]()
第二十五章 完善登录逻辑
实现自动登录
接下来我们需要做 `自动登陆功能,自动登陆就是登陆之后,下次启动开启状态下,直接进入首页。关闭情况下,则进入登陆页面。
![]()
@main
struct Win_App: App {
@StateObject private var appConfig:AppConfig = AppConfig.share
var body: some Scene {
WindowGroup {
if isLogin {
if appConfig.isAutoLogin {
TabPage()
} else {
LoginPage()
}
} else {
LoginPage()
}
}
}
...
}
我们需要两处需要初始化LoginPage的地方,这个玩意需要参数,或者其他设置,就比较麻烦了,虽然我们可以提炼代码。
@main
struct Win_App: App {
@StateObject private var appConfig:AppConfig = AppConfig.share
var body: some Scene {
WindowGroup {
if isLogin {
if appConfig.isAutoLogin {
TabPage()
} else {
loginPage()
}
} else {
loginPage()
}
}
}
...
private func loginPage() -> some View {
LoginPage()
}
}
但是我们的判断逻辑依然十分的复杂,我们可以变更一下流程图。
![]()
我们将判断的逻辑封装成一个方法,这样虽然看起来没啥变化,但是对于页面处理逻辑清晰。
@main
struct Win_App: App {
@StateObject private var appConfig:AppConfig = AppConfig.share
var body: some Scene {
WindowGroup {
if isNeedLogin {
LoginPage()
} else {
TabPage()
}
}
}
...
private var isNeedLogin:Bool {
return !isLogin || !appConfig.isAutoLogin
}
}
但是,经过测试,我们登陆成功也是无法进入首页的,因为 isAutoLogin 默认关闭的。经过思考,我们上面的逻辑是有问题的,需要修改一些逻辑。
![]()
1 当App全新未安装的时候(红线代表逻辑走向)
![]()
2 当执行登陆完毕之后
![]()
3 第二次启动 App已经登录过 但是没有开启自动登录
![]()
4 App启动 App已经登录过,开启了自动登录
![]()
我们按照流程图写一下代码
@main
struct Win_App: App {
@StateObject private var appConfig:AppConfig = AppConfig.share
...
private var isNeedLogin:Bool {
/// 如果 gatewayUserName 不存在 则需要进行登录
guard isExitGatewayUserName else { return true}
/// 如果 gatewayUserName 存在 并且 isGatewayUserNameFromCache = false 代表是刚刚登录的 则不需要登录
guard appConfig.isGatewayUserNameFromCache else { return false }
/// 如果 gatewayUserName 存在 并且 isGatewayUserNameFromCache = true 代表登录是之前运行操作的 如果没开启自动登录就需要前往重新登录
return !appConfig.isAutoLogin
}
/// 是否存在 gatewayUserName
private var isExitGatewayUserName:Bool {
guard let gatewayUserName = appConfig.gatewayUserName else { return false }
return !gatewayUserName.isEmpty
}
}
class AppConfig: ObservableObject {
...
/// gatewayUserName 是否来源于缓存 默认来源于缓存
var isGatewayUserNameFromCache:Bool = true
...
}
class LoginPageViewModel: BaseViewModel {
...
func login() async {
...
if let gatewayUserName = model.data?.gatewayUserName {
/// 放在 [AppConfig.share.gatewayUserName] 赋值的前面 这样 gatewayUserName 通知时候才能获取 isGatewayUserNameFromCache 最新值
AppConfig.share.isGatewayUserNameFromCache = false
AppConfig.share.gatewayUserName = gatewayUserName
}
...
}
}
![]()
接下来我们需要获取版本号和 build号显示出来,这个简单一些。
struct MyPage: View {
...
private func appVersionCell() -> some View {
MyDetailStyle1CellContentView(title: "版本",
detail: viewModel.versionValue)
}
...
}
class MyPageViewModel: BaseViewModel {
...
var versionValue:String {
guard let infoDictionary = Bundle.main.infoDictionary else { return "" }
guard let version = infoDictionary["CFBundleShortVersionString"] else { return "" }
guard let buildNumber = infoDictionary["CFBundleVersion"] else { return "" }
return "\(version)(\(buildNumber))"
}
}
我的页面接下来就只剩下退出登录功能了,我们按照我们上方登录流程图来看,只需要将 gatewayUserName 设置为 nil即可实现退出登录,回到登录界面。
struct MyPage: View {
...
@StateObject private var appConfig = AppConfig.share
...
private func logoutButton() -> some View {
Button {
appConfig.gatewayUserName = nil
} label: {
...
}
}
...
}
![]()
研究界面的初始化和重建
但是我们重新进来还是在我的界面,既然重新登录,我认为就应该回到首页。我们在研究生命周期时候发现下面的打印。
struct TabPage: View {
...
init() {
print("-> TabPage init")
...
}
var body: some View {
TabView(selection:$currentTabIndex) {
...
}
...
.onAppear {
print("-> currentTabIndex = \(currentTabIndex)")
}
}
}
-> LoginPage init // 未登录展示登录界面
-> TabPage init // 登陆 初始化TabPage
-> HomePage init // 初始化HomePage
-> MyPage init // 初始化 MyPage
-> currentTabIndex = 0 // TabPage onAppear
-> TabPage init // ??? 为啥再次初始化一次
-> HomePage init /// 点击currentTabIndex = 1 重新初始化HomePage
-> MyPage init // 重新初始化 MyPage
-> TabPage init // ??? 不理解为啥再次初始化
-> TabPage init // ??? 不理解为啥再次初始化
-> TabPage init // ??? 不理解为啥再次初始化
-> TabPage init // ??? 不理解为啥再次初始化
TabPage init打印了很多次,但是 currentTabIndex 只打印了一次,那就是 onAppear只执行了一次。我们简单绘制一下渲染树结构,按照Page为单位。
![]()
我们通过 @State 将数结构细化一点
![]()
从首页切换到我的页面,为啥切换到我的页面会打印这么多次 TabPage init?我的页面和首页的不同就是,首页初始化了工厂列表,我的页面在onAppear方法里面执行了初始化车间和产线还有仓库数据的操作。
难道和这个有关系,我们屏蔽一下初始化的代码。
struct MyPage: View {
...
var body: some View {
...
return PageContentView(title: "我的", viewModel: viewModel) {
...
}
}
.onAppear {
// Task {
// await viewModel.initData()
// }
}
}
...
}
struct HomePage: View {
...
var body: some View {
return NavigationView {
...
}
...
.onAppear {
// Task {
// await viewModel.requestFactoryList()
// }
}
}
}
我们再次看一下日志输出。
-> LoginPage init // 未登录展示登录界面
-> TabPage init // 登陆 初始化TabPage
-> HomePage init // 初始化HomePage
-> MyPage init // 初始化 MyPage
-> currentTabIndex = 0 // TabPage onAppear
-> HomePage init /// 点击currentTabIndex = 1 重新初始化HomePage
-> MyPage init // 重新初始化 MyPage
-> TabPage init // ??? 不理解为啥再次初始化
少了四次 TabPage init,这四次应该就是刷新我的界面的 车间/产线/仓库显示和刷新首页工厂操作引起的。但是这样依然 View 初始化的很多次,按照我们的操作。
/// 下面是理想状态下的输出
-> LoginPage init // 未登录展示登录界面
-> TabPage init // 登陆 初始化TabPage
-> HomePage init // 初始化HomePage
-> MyPage init // 初始化 MyPage
-> currentTabIndex = 0 // TabPage onAppear
上面应该就是在 UIKit系统下面正常的数据,但是SwiftUI不同于UIKit的生命周期,但是和Flutter有类似的作用。我们打印一下Body执行的过程,这个才是真正设计到调用绘制。
-> LoginPage init /// 未登录 初始化 LoginPage
-> LoginPage Body /// 绘制 LoginPage
-> LoginPage Body /// 展示 Loading 绘制 LoginPage
-> LoginPage Body /// 展示登陆成功提示 绘制 LoginPage
-> TabPage init /// 登陆成功 初始化 TabPage
->Tab Page Body /// 绘制 TabPage
->HomePage init /// 初始化 HomePage
->MyPage init /// 初始化 MyPage 因为都没展示我的页面 所以后续不需要绘制
-> currentTabIndex = 0 /// TabPage onAppear
-> HomePage Body /// 绘制 HomePage
->Tab Page Body /// 点击 tab = 1 重新绘制 TabPage
->HomePage init /// 重新初始化 HomePage 因为首页已经绘制 所以不需要重新绘制
->MyPage init /// 重新初始化 MyPage
->MyPage Body /// 绘制 MyPage 页面
->MyPage Body /// 重新绘制 MyPage 页面
-> TabPage init /// 初始化 TabPage
从输出上面看绘制首页一次是正常的,虽然多次初始化,多次初始化对于性能影响不大。但是我的页面绘制了两次?经过不停的调试,发现我的页面比首页多执行一次的原因在于 在 HomePage中添加了 NavigationView, 而 MyPage的 NavigationView 是加在 TabPage里面的。
我们都将 NavigationView 转移到 TabPage,再次看一下输出。
-> LoginPage init
-> LoginPage Body
-> LoginPage Body
-> LoginPage Body
-> TabPage init
->Tab Page Body
->HomePage init
->MyPage init
-> currentTabIndex = 0
-> HomePage Body
->Tab Page Body
->HomePage init
->MyPage init
->MyPage Body
->MyPage Body
-> TabPage init
都转移出来之后,发现刚开始进入的时候就开始初始化了 我的页面了。
我们能够通过树形结构局部刷新数来优化呢?答案是肯定的,但是目前来说也没必要研究那么深入,并且现在的页面就算优化,也没有大的意义。
从上面的输入看,当页面重新初始化和绘制的时候,@State不会随着初始化的,导致我们重新登陆完毕,展示给我们的是我的界面的问题。
因为 @State是 TabPage私有的,所以我们在我的页面退出登录也无法操作 TabPage的 currentTabIndex。目前想到了两种方案,第一种采用通知的形式,第二种采用@Binding。对于Struct,我猜测通知的方式可能不生效,或者麻烦,没有@Binding 方便。
class AppConfig: ObservableObject {
...
/// 当前 Tab 的索引
@Published var currentTabIndex:Int = 0
...
}
struct TabPage: View {
...
@StateObject private var appConfig = AppConfig.share
...
var body: some View {
TabView(selection:$appConfig.currentTabIndex) {
...
}
...
}
}
我们采用在AppConfig中新增一个@Published标识当前选中的Tab,因为AppConfig对象随时可以访问。为了修复重新登录无法重新定位到首页,我们在退出登录重置一下 currentTabIndex。
struct MyPage: View {
...
@StateObject private var appConfig = AppConfig.share
...
private func logoutButton() -> some View {
Button {
...
appConfig.currentTabIndex = 0
} label: {
...
}
}
}
第二十四章 init 方法初始化 State
选择车间功能做完之后,我们接下来开始做产线的功能。
![]()
但是产线的功能来源于车间,意思当车间更换之后,我们的产线就要发生变更。那么我们就要监听AppConfig 中 workShopCode 值发生改变,我们请求产线的数据。
但是不幸的是,我们的 workShopCode 不是通过 @Published修饰的,所以我们无法通过sink方式监听值的变更。既然我们只能通过 @Published 进行监听,我们将之前值设置为中间代码。
class AppConfig: ObservableObject {
...
/// 选中的车间代码
@AppStorage("workShopCode")
/// 因为 _workShopCode 已经被系统使用 我们用workShopCode_
private var workShopCode_:String?
/// 用于外部监听 workShopCode 值的变更
@Published var workShopCode:String? {
didSet {
/// 车间切换 更新本地缓存
workShopCode_ = workShopCode
}
}
...
init() {
workShopCode = workShopCode_
}
}
class MyPageViewModel: BaseViewModel {
...
/// 保存 AnyCancellable 不然会导致后续的车间变更获取不到通知
private var workshopCancellabel:AnyCancellable?
override init() {
super.init()
workshopCancellabel = AppConfig.share.$workShopCode.sink {[weak self] value in
/// 监听到 车间变更
print(value ?? "")
}
}
...
}
但是在运行测试过程中,发现了一个问题。就是我们来回切换选择器值的时候,我们也收到了值更新的打印。那么我们确定的按钮根本没有起到作用。
我们功能是只有当用户点击了确定按钮的时候,我们才需要更改外部值。
struct PickerSheet<Item:DataPickerItem>: View {
...
/// 只存在 PickerSheet 运行期间缓存的值 解决只有点击确定按钮才更新外部的值的问题
@State private var cacheSelectItem:Item?
...
var body: some View {
VStack {
...
DataPickerView(items: items, selectItem: $cacheSelectItem)
...
}
...
}
private func confirmClick() {
selectItem = cacheSelectItem
confirmHandle()
}
}
但是我们测试的过程中发现,每次点击弹出 PickerSheet 组件的时候,我们默认选择的第一项都是第一个。看来之前写的代码不生效?这个是什么原因导致的呢?
通过断点查看,原来 selectItem 传进来为空,想起来了,这个值默认为空,之后我们就没管了。我们需要获取到 workShops 数据之后,将之前选中的车间简码转换成对应的模型。
class MyPageViewModel: BaseViewModel {
...
private func getAllWorkShop() async {
...
currentWorkshop = data.first(where: { response in
guard let configCode = AppConfig.share.workShopCode else {return false}
guard let code = response.workshopCode else {return false}
return configCode == code
})
}
...
}
发现上述代码改完之后,我们在测试过程中,发现还是空值,后来一想,我们使用了 cacheSelectItem 作为中间值,是因为中间值没有初始化。
struct PickerSheet<Item:DataPickerItem>: View {
...
init(...) {
...
self.cacheSelectItem = selectItem.wrappedValue
}
...
}
在 Init 方法初始化 State
这样改后依然没有任何的效果,难道是因为在init中还没有初始化State,我们在init 自己初始化试一下。
struct PickerSheet<Item:DataPickerItem>: View {
...
init(...) {
...
self._cacheSelectItem = State(initialValue: selectItem.wrappedValue)
}
...
}
通过上述的代码,我们 PickerSheet 弹出之后选中对应不会选中对应行的问题解决了。对于属性包装器在 init 方法完毕才初始化,这个确实是不注意就坑的地方。
接下来,我们来写获取车间下面所有产线的方法。
class MyPageViewModel: BaseViewModel {
...
override init() {
...
workshopCancellabel = AppConfig.share.$workShopCode.sink {[weak self] value in
/// 监听到 车间变更
Task {[weak self] in
guard let self = self else {return}
await self.getAllProductLine()
}
}
}
...
/// 获取车间下面的所有产线
private func getAllProductLine() async {
guard let workShopCode = AppConfig.share.workShopCode else {
showHUDMessage(message: "请选选择车间!");
return
}
let api = GetAllProductLineApi(workshopCode: workShopCode)
let model:BaseModel<[GetAllProductLineApiResponse]> = await request(api: api, showHUD: false)
guard model._isSuccess else {return}
guard let data = model.data else {return}
productLines = data
}
...
}
我们根据切换车间,就重新获取新的产线,设置新的产线。
![]()
为了可以获取到之前保存的产线code,那么我们需要在 AppConfig新增一个变量。
class AppConfig: ObservableObject {
...
/// 选中的产线 code
@AppStorage("productLineCode")
var productLineCode:String?
...
}
我们在获取产线列表方法,实现上面的流程图。
class MyPageViewModel: BaseViewModel {
...
/// 车间下面所有的产线列表
var productLines:[GetAllProductLineApiResponse] = []
...
/// 获取车间下面的所有产线
private func getAllProductLine() async {
...
/// 是否存在之前选中保存的产线code
if let productLineCode = AppConfig.share.productLineCode {
/// 存在 判断在最新产线列表是否存在 就将之前的 code 进行更新
if isExit(productLine: productLineCode, in: productLines) {
AppConfig.share.productLineCode = productLineCode
} else {
/// 否则就默认第一个产线
AppConfig.share.productLineCode = productLines.first?.code
}
} else {
/// 如果不存在之前选中保存的产线 code 则直接默认第一个产线
AppConfig.share.productLineCode = productLines.first?.code
}
}
/// 是否指定产线的 code 在列表存在
/// - Parameters:
/// - code: 产线的 code
/// - list: 产线列表
private func isExit(productLine code:String, in list:[GetAllProductLineApiResponse]) -> Bool {
/// 查找出指定产线 code 在列表位置
let index = list.firstIndex(where: { response in
guard let _code = response.code else {return false}
return code == _code
})
/// 如果查找出来索引不为空,则代表存在于列表中
return index != nil
}
...
}
为了可以拿到当前选中产线的模型,用于显示当先选中产线的名字,我们修改一下代码。
class MyPageViewModel: BaseViewModel {
...
/// 当前选中产线的模型
@Published var currentProductLine:GetAllProductLineApiResponse? {
didSet {
AppConfig.share.productLineCode = currentProductLine?.code
}
}
...
/// 获取车间下面的所有产线
private func getAllProductLine() async {
...
/// 获取列表之后 找到当前选中的模型
currentProductLine = productLines.first(where: { response in
guard let code = AppConfig.share.productLineCode, let _code = response.code else {return false}
return code == _code
})
}
...
}
我们这样写逻辑也没有问题,但是自信的一想,最后一个获取当前选中模型赋值操作之后再次更新了 AppConfig 中 productLineCode的值。
![]()
此时我们有四条线可以更新产线的code,灰色区域三种只存在一种可能,加上外面可以更新产线code。整个流程下面会因为设置当前产线模型多了一次更新。
虽然性能方面不会产生大的影响,但是后续如果其他地方监听产线变更做一些逻辑,就会导致问题出现。我们修改一下逻辑图如下所示。
![]()
这样我们就还是三种只存在一种可能设置产线 Code,我们修改一下逻辑代码。
class MyPageViewModel: BaseViewModel {
...
/// 获取车间下面的所有产线
private func getAllProductLine() async {
...
/// 是否存在之前选中保存的产线code
if let productLineCode = AppConfig.share.productLineCode {
/// 存在 判断在最新产线列表是否存在 就将之前的 code 进行更新
if let response = find(productLine: productLineCode, in: productLines) {
currentProductLine = response
} else {
/// 否则就默认第一个产线
currentProductLine = productLines.first
}
} else {
/// 如果不存在之前选中保存的产线 code 则直接默认第一个产线
currentProductLine = productLines.first
}
}
/// 根据产线 code 从产线列表查找对应模型
/// - Parameters:
/// - code: 产线 code
/// - list: 产线列表
/// - Returns: 对应模型
private func find(productLine code:String, in list:[GetAllProductLineApiResponse]) -> GetAllProductLineApiResponse? {
return list.first { response in
guard let _code = response.code else { return false }
return code == _code
}
}
...
}
我们已经拿到了当前选中的产线,我们将显示在当前页面上面。
struct MyPage: View {
...
private func productLineCell() -> some View {
MyDetailCellContentView(title: "产线",
detail: viewModel.currentProductLine?.name ?? "请选择产线")
}
...
}
![]()
我们切换车间的时候,产线也随之发生了改变。我们此时产线无法进行手动选择,我们添加一下功能。
struct MyPage: View {
@StateObject private var viewModel = MyPageViewModel()
...
@StateObject private var appConfig = AppConfig.share
var body: some View {
PageContentView(title: "我的", viewModel: viewModel) {
VStack(spacing: 0) {
...
productLineCell()
.onTapGesture(perform: didClickProductLine)
...
}
}
...
}
...
/// 点击了产线
private func didClickProductLine() {
guard let _ = appConfig.workShopCode else {
viewModel.showHUDMessage(message: "请先选择车间!");
return
}
DataPickerManager.manager.show {
PickerSheet(title: "产线",
items: viewModel.productLines,
selectItem: $viewModel.currentProductLine) {
DataPickerManager.manager.dismiss()
} confirmHandle: {
DataPickerManager.manager.dismiss()
}
}
}
}
为了不让调用设置背景色,我们将设置白色背景设置在 PickerSheet 里面。
struct PickerSheet<Item:DataPickerItem>: View {
...
var body: some View {
VStack {
...
}
...
.background(.white)
}
...
}
![]()
得意于我们之前封装,产线功能做起来才会这么顺手和快速。接下来我们来做选择仓库的功能,我们只需要获取仓库列表,之后设置仓库对应的 code。
![]()
struct MyPage: View {
@StateObject private var viewModel = MyPageViewModel()
...
var body: some View {
PageContentView(title: "我的", viewModel: viewModel) {
VStack(spacing: 0) {
...
storeHourseCell()
.onTapGesture(perform: didClickStoreHourse)
...
}
}
...
}
...
private func storeHourseCell() -> some View {
MyDetailCellContentView(title: "仓库",
detail: viewModel.currentStoreHouse?.name ?? "请选择仓库")
}
...
private func didClickStoreHourse() {
DataPickerManager.manager.show {
PickerSheet(title: "仓库",
items: viewModel.storeHouses,
selectItem: $viewModel.currentStoreHouse) {
DataPickerManager.manager.dismiss()
} confirmHandle: {
DataPickerManager.manager.dismiss()
}
}
}
}
class MyPageViewModel: BaseViewModel {
...
/// 仓库列表
var storeHouses:[GetAllStoreHouseApiResponse] = []
/// 当前选中的仓库
@Published var currentStoreHouse:GetAllStoreHouseApiResponse? {
didSet {
AppConfig.share.storeHouseCode = currentStoreHouse?.code
}
}
...
public func initData() async {
...
/// 仓库列表的数据只和工厂有关系 所以可以放在初始化进行请求
await getAllStoreHouse()
}
...
func getAllStoreHouse() async {
let api = GetAllStoreHouseApi()
let model:BaseModel<[GetAllStoreHouseApiResponse]> = await request(api: api, showHUD: false)
guard model._isSuccess, let data = model.data else {return}
storeHouses = data
/// 是否存在之前保存过的仓库
if let storeHouseCode = AppConfig.share.storeHouseCode {
if let response = find(storeHouse: storeHouseCode, in: storeHouses) {
currentStoreHouse = response
} else {
/// 如果最新的仓库列表已经不包含之前选中的仓库 则默认第一个仓库
currentStoreHouse = storeHouses.first
}
} else {
/// 如果不存在 则设置默认第一个仓库
currentStoreHouse = storeHouses.first
}
}
...
/// 根据仓库 code 从仓库列表查找对应模型
/// - Parameters:
/// - code: 仓库 code
/// - list: 仓库列表
/// - Returns: 查找的模型
private func find(storeHouse code:String, in list:[GetAllStoreHouseApiResponse]) -> GetAllStoreHouseApiResponse? {
return list.first { response in
guard let _code = response.code else { return false }
return code == _code
}
}
...
}
struct GetAllStoreHouseApi {}
extension GetAllStoreHouseApi: APIConfig {
var path: String { "/api/winplus/bm/store/search" }
}
struct GetAllStoreHouseApiResponse: Codable {
/// 仓库名称
let name:String?
/// 仓库 code
let code:String?
}
extension GetAllStoreHouseApiResponse: DataPickerItem {
var pickerItemTitle: String { name ?? "" }
static func ==(lhs:GetAllStoreHouseApiResponse, rhs:GetAllStoreHouseApiResponse) -> Bool {
guard let code = lhs.code, let _code = rhs.code else { return false }
return code == _code
}
}
![]()
第二十三章 UIHostingController|withAnimation|SwiftUI 默认动画时间
UIViewController 自定义 Sheet
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的", viewModel: viewModel) {
VStack(spacing: 0) {
...
workshopCell()
.onTapGesture {
DataPickerManager.manager.show {
PickerSheet(title: "工厂",
items: ["1","2"],
isShow: $viewModel.isShowDataPicker)
.background(.white)
}
}
...
}
}
...
}
...
}
UIHostingController 调用 SwiftUI 视图 withAnimation 默认动画
我们将使用 UIViewController 弹出封装在 DataPickerManager 里面调用。
class DataPickerManager {
/// 做成单利对象是为了记录当前弹出的 UIViewController 方便随意的调用消失
static let manager = DataPickerManager()
/// 当前展示 Data Picker 的控制器
private var currentShowDataPickerController:UIViewController?
/// show 方法采用 @ViewBuilder 获取自定义的视图
func show<Content:View>(@ViewBuilder _ content:() -> Content) {
/// 将自定义的视图封装为 DataPickerContentView 为了封装动画的弹出和消失
let contentView = DataPickerContentView(content: content)
/// 使用 UIHostingController 来展示 SwiftUI 的试图
let controller = UIHostingController(rootView: contentView)
/// 设置界面弹出方式为 overFullScreen 是支持设置界面半透明
controller.modalPresentationStyle = .overFullScreen
/// 设置背景为黑色半透明
controller.view.backgroundColor = .black.withAlphaComponent(0.6)
guard let rootViewController = keyWindow()?.rootViewController else {return}
/// 保存当前正在展示的模态试图 方便进行消失
currentShowDataPickerController = controller
rootViewController.present(controller, animated: false, completion: nil)
}
func dismiss() {
guard let currentShowDataPickerController = self.currentShowDataPickerController else {
return
}
currentShowDataPickerController.dismiss(animated: false, completion: nil)
self.currentShowDataPickerController = nil
}
/// 获取当前的 KeyWindow 在 iOS15上面 采用 UIWindowScene 获取
private func keyWindow() -> UIWindow? {
if #available(iOS 15.0, *) {
return UIApplication.shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow})
.first
} else {
return UIApplication.shared.windows
.filter({$0.isKeyWindow})
.first
}
}
}
/// 采用 PreferenceKey 获取 自定义视图的高度
fileprivate struct DataPickerSizeKey: PreferenceKey {
static var defaultValue: [CGSize] = []
static func reduce(value: inout [CGSize], nextValue: () -> [CGSize]) {
/// 为什么要通过数组合并? 尝试不通过这种方式 有的视图在外层获取不到大小
value.append(contentsOf: nextValue())
}
}
/// 封装自定义视图的弹出和消失
struct DataPickerContentView<Content:View>: View {
private let content:Content
/// 自定义视图的大小 需要消失的时候用到 所以需要进行保存
@State private var size:CGSize = .zero
/// 当前将自定义视图进行弹出的偏移量 通过偏移量的变更 来执行动画
@State private var offsetY:CGFloat = .zero
/// 初始化 Content 用户需要展示的自定义视图
init(@ViewBuilder content:() -> Content) {
self.content = content()
}
var body: some View {
VStack {
Spacer()
content
.background {
/// 可以通过封装在 background 或者 overlay 里面通过 geometry 获取试图的大小
GeometryReader { geometry in
Color.clear
/// 将获取的大小保存在 Preference 里面 ,向上进行传递
.preference(key: DataPickerSizeKey.self, value: [geometry.size])
}
}
/// 监听获取的视图的大小的变更
.onPreferenceChange(DataPickerSizeKey.self, perform: { value in
/// 可能获取不到 就直接中断执行
guard let size = value.first else {return}
/// 将获取的大小保存下来
self.size = size
/// 更改偏移量 用于 .offset 设置偏移量
offsetY = size.height
})
/// 偏移自定义视图高度 这样让初始化的位置在屏幕以下位置
.offset(x: 0, y: offsetY)
.onAppear {
/// 当界面展示的时候 设置 offsetY = 0 为了做到界面出现就进行弹出动画
withAnimation(.linear) {
offsetY = 0
}
}
}
}
}
![]()
我们通过 UIViewController 进行弹出,我们的动画终于正常了。但是消失呢?因为通过 取消和确定的按钮都能进行取消。我们简单的画一下功能流程图。
![]()
对于消失我们就需要先让 DataPickerContentView 执行消失动画,之后再让当前的 UIHostController 移除。但是外界的操作怎么通知到
DataPickerContentView 之后做消失动画呢?
为了做到信息互通,我们采用 ViewModel 的方式。
class DataPickerManager {
...
/// 通过 DataPickerContentViewModel 进行控制动画的弹出和消失
private let viewModel:DataPickerContentViewModel = DataPickerContentViewModel()
...
func dismiss() {
/// 当 DataPickerContentView 动画消失之后 再让界面消失
viewModel.endAnimation {[weak self] in
...
}
}
...
}
class DataPickerContentViewModel: ObservableObject {
/// 将 offsetY 转移到 DataPickerContentViewModel @Published 用于外界属性观察
@Published var offsetY:CGFloat = 0
/// 自定义视图大小 因为不需要观察 只需要进行存储 就设置为私有属性
private var contentSize:CGSize = .zero
/// 外界不需要进行读取 contentSize 我们就只写了更新 contentSize 方法
func updateContentSize(size:CGSize) {
contentSize = size
offsetY = size.height
}
/// 执行动画就只需要 offsetY = 0
func startAnimation() {
withAnimation(.linear) {
offsetY = 0
}
}
/// 结束动画就需要将 offsetY = contentSize.height
func endAnimation(completion:@escaping () -> Void) {
withAnimation(.linear) {
offsetY = contentSize.height
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
completion()
}
}
}
/// 封装自定义视图的弹出和消失
struct DataPickerContentView<Content:View>: View {
...
@ObservedObject private var viewModel:DataPickerContentViewModel
/// 初始化 Content 用户需要展示的自定义视图
init(viewModel:DataPickerContentViewModel, @ViewBuilder content:() -> Content) {
self.viewModel = viewModel
...
}
var body: some View {
VStack {
Spacer()
content
...
/// 监听获取的视图的大小的变更
.onPreferenceChange(DataPickerSizeKey.self, perform: { value in
/// 可能获取不到 就直接中断执行
guard let size = value.first else {return}
viewModel.updateContentSize(size: size)
})
/// 偏移自定义视图高度 这样让初始化的位置在屏幕以下位置
.offset(x: 0, y: viewModel.offsetY)
.onAppear {
/// 当界面展示的时候 设置 offsetY = 0 为了做到界面出现就进行弹出动画
viewModel.startAnimation()
}
}
}
}
我们此前的 PickerSheet 组件没有暴露出,取消按钮事件和确定按钮事件。我们调整代码暴露出来,我们觉得既然是事件还是通过闭包代理出来设计比较好。
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的", viewModel: viewModel) {
VStack(spacing: 0) {
...
workshopCell()
.onTapGesture {
DataPickerManager.manager.show {
PickerSheet(title: "工厂",
items: ["1","2"],
cancelHandle: {
DataPickerManager.manager.dismiss()
},
confirmHandle: {
DataPickerManager.manager.dismiss()
})
.background(.white)
}
}
...
}
}
...
}
...
}
struct PickerSheet<Item:DataPickerItem>: View {
...
/// 点击取消按钮回掉
typealias CancelHandle = () -> Void
private let cancelHandle:CancelHandle
/// 点击确定按钮回掉
typealias ConfirmHandle = () -> Void
private let confirmHandle:ConfirmHandle
init(title:String,
items:[Item],
cancelHandle:@escaping CancelHandle,
confirmHandle:@escaping ConfirmHandle) {
...
self.cancelHandle = cancelHandle
self.confirmHandle = confirmHandle
}
var body: some View {
VStack {
...
HStack {
Button(action:cancelHandle) {
...
}
Button(action: confirmHandle) {
...
}
}
...
}
...
}
}
![]()
默认动画时间从 0.25 变为 0.35
但是看起来,消失动画感觉还没有消失,这个界面就消失了?那么可能0.25秒默认时间不对,我们看了一下API。
public static func timingCurve(_ c0x: Double, _ c0y: Double, _ c1x: Double, _ c1y: Double, duration: Double = 0.35) -> Animation
果然默认的动画时间变成了0.35秒。
class DataPickerContentViewModel: ObservableObject {
...
/// 结束动画就需要将 offsetY = contentSize.height
func endAnimation(completion:@escaping () -> Void) {
...
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
completion()
}
}
}
![]()
这样看起来动画正常了,偷偷改了默认动画时间,有点坑。
此时我们封装的Modal的弹出和消失已经封装完毕,但是我们选择工厂点击确定,我们 却无法拿到数据,我们通过传入 @Binding可以让 PickerSheet 组件内部进行设置。
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的", viewModel: viewModel) {
VStack(spacing: 0) {
...
workshopCell()
.onTapGesture {
DataPickerManager.manager.show {
PickerSheet(title: "工厂",
items: viewModel.workShops,
selectItem: $viewModel.currentWorkshop,
cancelHandle: {
...
},
confirmHandle: {
...
})
...
}
}
...
}
}
...
}
...
}
class MyPageViewModel: BaseViewModel {
...
/// 当前选中的车间
@Published var currentWorkshop:GetAllWorkshopResponse?
...
}
struct PickerSheet<Item:DataPickerItem>: View {
...
/// 当前选中的 Item
@Binding private var selectItem:Item?
...
init(title:String,
items:[Item],
selectItem:Binding<Item?>,
cancelHandle:@escaping CancelHandle,
confirmHandle:@escaping ConfirmHandle) {
...
self._selectItem = selectItem
...
}
var body: some View {
VStack {
...
DataPickerView(items: items, selectItem: $selectItem)
...
}
...
}
}
struct DataPickerView<Item:DataPickerItem>: UIViewRepresentable {
...
@Binding private var selectItem:Item?
init(items:[Item], selectItem:Binding<Item?>) {
...
self._selectItem = selectItem
}
func makeCoordinator() -> DataPickerViewCoordinator<Item> {
return DataPickerViewCoordinator(items: items, selectItem: $selectItem)
}
func makeUIView(context: Context) -> UIPickerView {
...
if let selectItem = selectItem,
let index = items.firstIndex(where: {$0 == selectItem }) {
picker.selectRow(index, inComponent: 0, animated: false)
}
...
}
...
}
class DataPickerViewCoordinator<Item:DataPickerItem>: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
...
@Binding private var selectItem:Item?
init(items:[Item], selectItem:Binding<Item?>) {
...
self._selectItem = selectItem
}
...
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
selectItem = items[row]
}
...
}
![]()
但是选择完毕之后,我们的界面没有更新,因为我们开始用到的是 AppConfig的缓存的数据。当 currentWorkshop 值改变的时候,我们改变一下 AppConfig.share.workShopCode 的值。
class MyPageViewModel: BaseViewModel {
...
/// 当前选中的车间
@Published var currentWorkshop:GetAllWorkshopResponse? {
didSet {
AppConfig.share.workShopCode = currentWorkshop?.workshopCode
}
}
...
}
这样我们选择车间功能就封装完毕了。
第二十二章 onAppear|DataPickerView
获取当前工厂车间列表
这一章我们来给我的界面的数据写数据获取的实现和界面的交互。
![]()
对于显示当前选择的生产车间的,我们先是要获取到当前工厂可用的车间列表。
class Api: API {
...
static var defaultHeadersConfig: ((inout HTTPHeaders) -> Void)? {
return { headers in
...
if let currentFactoryCode = AppConfig.share.currentFactoryCode {
headers.add(name: "x-winplus-factory-code", value: currentFactoryCode)
}
}
}
}
class AppConfig: ObservableObject {
...
/// 选中的车间代码
@AppStorage("workShopCode")
var workShopCode:String?
}
class MyPageViewModel: BaseViewModel {
/// 工厂所有的车间列表
private var workShops:[GetAllWorkshopResponse] = []
override init() {
super.init()
Task {
await getAllWorkShop()
}
}
private func getAllWorkShop() async {
let api = GetAllWorkshopApi()
let model:BaseModel<[GetAllWorkshopResponse]> = await request(api: api, showHUD: false)
guard model._isSuccess else {return}
guard let data = model.data else {return}
workShops = data
if let workShopCode = AppConfig.share.workShopCode {
let index = workShops.firstIndex { response in
guard let _workshopCode = response.workshopCode else {return false}
return workShopCode == _workshopCode
}
guard let _ = index else {
/// 如果查询不到 意味着之前选中的数据已经不存在 则默认第一个
AppConfig.share.workShopCode = workShops.first?.workshopCode
return
}
} else {
/// 如果之前没有选中的车间 则默认第一个
AppConfig.share.workShopCode = workShops.first?.workshopCode
}
}
/// 当前选中车间的名称
func currentWorkShopName() -> String? {
return workShops.first { response in
guard let workShopCode = AppConfig.share.workShopCode else {return false}
guard let _workShopCode = response.workshopCode else {return false}
return workShopCode == _workShopCode
}?.name
}
}
我们将车间的名称设置到界面上去。
struct MyPage: View {
...
private func workshopCell() -> some View {
MyDetailCellContentView(title: "车间", detail: viewModel.currentWorkShopName() ?? "请选择车间")
}
...
}
{"success":false,"code":400,"message":"请选择工厂\n","data":null}
onAppear 请求数据
但是运行起来接口报错了,那说明我们Headers新增加的工厂的字段不存在。我们明明已经在我的页面获取工厂列表进行自动设置了,为啥还出现这种情况,经过分析问题处在下面代码。
class MyPageViewModel: BaseViewModel {
...
override init() {
super.init()
Task {
await getAllWorkShop()
}
}
...
}
我们MyPageViewModel初始化的时候就开始请求数据了,但是 MyPageViewModel 初始化和 MyPage一起初始化的,以为和首页刚初始化,就调用这个接口了。
我们修改一下调用的顺序。
class MyPageViewModel: BaseViewModel {
...
public func initData() async {
await getAllWorkShop()
}
...
}
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的", viewModel: viewModel) {
...
}
.onAppear {
Task {
await viewModel.initData()
}
}
}
...
}
在我的页面出现的时候再请求,我们就可以不会因为还没有设置工厂,导致接口报错。
刚才我们发现获取车间列表是默默请求的,为什么错误信息会被提示出来?而且提示信息还没完整的提示出来?
extension APIConfig {
func request<M:Codable>(model:M.Type) async -> BaseModel<M> {
do {
...
} catch(let e) {
...
return BaseModel(message: error.domain,
...
}
}
}
错误提示没有是因为忘记把错误信息赋值导致的。
@MainActor
class BaseViewModel: ObservableObject {
...
func request<T:Codable, API:APIConfig>(api:API, showHUD:Bool = false) async -> BaseModel<T> {
...
if (!model._isSuccess && showHUD) {
...
}
...
}
}
设置不展示HUD依然展示,是没有添加HUD的判断逻辑。
![]()
我们运行发现我的页面没有导航栏了,我们添加一个导航栏。
struct TabPage: View {
...
var body: some View {
TabView(selection:$currentTabIndex) {
...
NavigationView {
MyPage()
}
...
}
...
}
}
![]()
有了导航条,但是和下面的内容串起来很难受,不过我们暂时先不管。我们发现一个大问题,就是我们的车间显示是下面的样子。
![]()
我们车间列表已经有数据,最少也是显示一条默认的,不可能存在请选择车间提示。研究了一下逻辑发现,当之前选择的车间在最新列表存在,就不会重新赋值,导致就无法通知进行更新。
class MyPageViewModel: BaseViewModel {
...
private func getAllWorkShop() async {
...
if let workShopCode = AppConfig.share.workShopCode {
...
AppConfig.share.workShopCode = workShopCode
} else {
...
}
}
...
}
![]()
Sheet 弹窗
我们默认选择的车间已经可以显示出来了,但是人工没法操作,接下来我们封装操作的弹框。
![]()
我们可以将视图分成下面组成方式。
![]()
我们只需要将蓝色区域底部对齐即可。
struct PickerSheet: View {
@StateObject private var appColor = AppColor.share
var body: some View {
VStack {
Text("车间选择")
.foregroundColor(Color(uiColor: appColor.c_333333))
.font(.system(size: 16))
.padding(EdgeInsets(top: 20, leading: 0, bottom: 20, trailing: 0))
Rectangle()
.frame(height:0.5)
.foregroundColor(Color(appColor.c_d8d8d8))
Picker(selection: .constant(1), label: Text("Picker")) {
Text("1").tag(1)
Text("2").tag(2)
}
HStack {
Button {
} label: {
Text("取消")
.font(.system(size: 14))
.foregroundColor(Color(uiColor: appColor.c_999999))
.padding(EdgeInsets(top: 10, leading: 50, bottom: 10, trailing: 50))
.overlay {
RoundedRectangle(cornerSize: CGSize(width: 5, height: 5))
.stroke(Color(uiColor: appColor.c_999999),lineWidth: 0.5)
}
}
Button {
} label: {
Text("确定")
.font(.system(size: 14))
.foregroundColor(.white)
.padding(EdgeInsets(top: 10, leading: 50, bottom: 10, trailing: 50))
.background(Color(uiColor:appColor.c_209090))
.cornerRadius(5)
}
}
}
.frame(maxWidth: .infinity)
}
}
![]()
但是SwiftUI在iOS的表现已经不是 UIDataPicker的样式了,可能为了为了支持全平台做了改变。那么Picker这个组件我们就不能用了,我们通过创建 UIPickerView进行转换。
struct DataPickerView<Item:DataPickerItem>: UIViewRepresentable {
typealias UIViewType = UIPickerView
private let items:[Item]
init(items:[Item]) {
self.items = items
}
func makeCoordinator() -> DataPickerViewCoordinator<Item> {
return DataPickerViewCoordinator(items: items)
}
func makeUIView(context: Context) -> UIPickerView {
let picker = UIPickerView()
picker.dataSource = context.coordinator
picker.delegate = context.coordinator
let v = UIView()
v.backgroundColor = .red
picker.addSubview(v)
return picker
}
func updateUIView(_ uiView: UIPickerView, context: Context) {
}
}
class DataPickerViewCoordinator<Item:DataPickerItem>: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
private let items:[Item]
init(items:[Item]) {
self.items = items
}
func numberOfComponents(in pickerView: UIPickerView) -> Int {
1
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
items.count
}
func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView {
let contentView = rootView(row: row)
return UIHostingController(rootView: contentView).view
}
func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
50
}
private func rootView(row:Int) -> some View {
VStack {
Rectangle()
.frame(height:0.5)
.foregroundColor(Color(uiColor: AppColor.share.c_209090))
Spacer()
Text(items[row].pickerItemTitle)
.font(.system(size: 12))
.foregroundColor(Color(uiColor: AppColor.share.c_209090))
Spacer()
Rectangle()
.frame(height:0.5)
.foregroundColor(Color(uiColor: AppColor.share.c_209090))
}
}
}
protocol DataPickerItem {
var pickerItemTitle:String {get}
}
extension String: DataPickerItem {
var pickerItemTitle: String {self}
}
![]()
选中默认的灰色的遮罩暂时没有找到可以修改的方法,对于DataPicker暂时就这样。
struct PickerSheet: View {
...
var body: some View {
VStack {
...
DataPickerView(items: [
"1",
"2"
])
...
Spacer()
.frame(height:20)
}
...
}
}
![]()
我们将 PickerSheet 组件进行提炼。
struct PickerSheet<Item:DataPickerItem>: View {
...
private let title:String
private let items:[Item]
init(title:String, items:[Item]) {
self.title = title
self.items = items
}
var body: some View {
VStack {
Text(title)
...
...
DataPickerView(items: items)
...
}
...
}
}
为了可以方便调用,我们封装一个 ViewModify
struct DataPickerViewModify: ViewModifier {
@Binding var isShow:Bool
func body(content: Content) -> some View {
ZStack {
content
if isShow {
GeometryReader { geometry in
VStack {
Spacer()
PickerSheet(title: "仓库",
items: [
"1",
"2"
])
.background(.white)
}
}
.background(Color(uiColor: UIColor.black.withAlphaComponent(0.6)))
}
}
}
}
extension View {
func dataPicker(isShow:Binding<Bool>) -> some View {
self.modifier(DataPickerViewModify(isShow: isShow))
}
}
![]()
界面突然的出现有点不自然,我们添加一个从底部弹出的动画。
struct DataPickerViewModify: ViewModifier {
...
func body(content: Content) -> some View {
ZStack {
content
if isShow {
Color(uiColor: UIColor.black.withAlphaComponent(0.6))
.edgesIgnoringSafeArea(.all)
VStack {
Spacer()
PickerSheet(...)
}
.transition(.move(edge: .bottom))
.animation(.linear)
}
}
}
}
我们将 DataPickerViewModify 的标题和内容进行提炼。
struct DataPickerViewModify<Item:DataPickerItem>: ViewModifier {
...
private var title:String
private var items:[Item]
init(title:String, items:[Item], isShow:Binding<Bool>) {
self.title = title
self.items = items
self._isShow = isShow
}
func body(content: Content) -> some View {
ZStack {
...
if isShow {
...
VStack {
...
PickerSheet(title: title,
items: items)
...
}
...
}
}
}
}
此时我们的 DataPicker 弹出之后,没有办法进行消失。我们新增可以消失的方法。
struct DataPickerViewModify<Item:DataPickerItem>: ViewModifier {
...
func body(content: Content) -> some View {
ZStack {
...
if isShow {
Color(uiColor: UIColor.black.withAlphaComponent(0.6))
...
.onTapGesture {
isShow = false
}
VStack {
...
PickerSheet(title: title,
items: items,
isShow: $isShow)
.background(.white)
}
...
}
}
}
}
struct PickerSheet<Item:DataPickerItem>: View {
...
@Binding private var isShow:Bool
...
init(title:String, items:[Item], isShow:Binding<Bool>) {
...
self._isShow = isShow
}
var body: some View {
VStack {
...
HStack {
Button {
isShow = false
} label: {
...
}
Button {
isShow = false
} label: {
...
}
}
...
}
...
}
}
![]()
我们来实现一下点击车间,弹出所有可以选择的车间列表。
extension GetAllWorkshopResponse: DataPickerItem {
var pickerItemTitle: String {name ?? ""}
}
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的", viewModel: viewModel) {
VStack(spacing: 0) {
...
workshopCell()
.onTapGesture {
viewModel.isShowDataPicker.toggle()
}
...
}
}
...
.dataPicker(title: "车间",
items: viewModel.workShops,
isShow: $viewModel.isShowDataPicker)
}
...
}
![]()
我们发现我们的弹出试图被上层的TabPage遮挡了。如果想要在最外层。那么这个控件就要注入在最外层才可以。但是数据怎么传递到最外层呢?
这个方案实现起来难度很大,我们用系统的 fullScreenCover 来实现,但是最终的效果是下面的效果。
![]()
黑色透明背景也是一起跟着动的,就这一点不满足我们的需求。我们能否通过 UIViewController之前的一套做出来呢?
第二十一章 @ViewBuilder默认实现|Toggle|我的页面封装
首页的界面基本做完了,功能也挺简单,跳转到对应界面即可。我们就先做一下我的页面的内容,内容也不是很多。
![]()
我的页面是一个配置和显示的功能也不是很复杂,但是界面也需要标题栏和灰色的背景试图。但是我们就需要将首页的代码复制一份过来吗?在UIKit的时代,因为是继承关系,我们可以在父类进行设置,但是现在我们在SwiftUI里面。Struct是不能继承的,我们只能封装,使用的时候使用封装的组件来达到效果。
在封装的过程中,遇到了一些困难,差一点就放弃了封装,幸亏找到了解决思路。
遇到的困难就是对于 @ViewBuilder 怎么在初始化提供默认的实现,因为有一些有一些封装不是必须实现的,下面的链接提供了解决的方法。
stackoverflow.com/questions/6…
/// 页面的基础试图
struct PageContentView<Content:View, Leading:View, Trailing:View>: View {
private let title:String
private let content:Content
private let leading:Leading
private let trailing:Trailing
@StateObject private var appColor:AppColor = AppColor.share
/// 初始化页面试图
/// - Parameters:
/// - title: 导航标题
/// - contentBuilder: 内容
/// - leadingBuilder: 导航左侧按钮
/// - trailingBuildeder: 导航右侧按钮
init(title:String,
@ViewBuilder contentBuilder:() -> Content,
@ViewBuilder leadingBuilder:() -> Leading,
@ViewBuilder trailingBuildeder:() -> Trailing) {
self.title = title
self.content = contentBuilder()
self.leading = leadingBuilder()
self.trailing = trailingBuildeder()
}
var body: some View {
navigationBar {
ZStack {
Color(uiColor: appColor.c_efefef)
content
}
}
}
private func navigationBar<Content:View>(@ViewBuilder content:() -> Content) -> some View {
content()
.navigationTitle(Text(title))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement:.navigationBarLeading) {leading}
ToolbarItem(placement:.navigationBarTrailing) {trailing}
}
}
}
extension PageContentView where Leading == EmptyView, Trailing == EmptyView {
init(title:String, contentBuilder:() -> Content) {
self.init(title: title,
contentBuilder: contentBuilder,
leadingBuilder: {EmptyView()},
trailingBuildeder: {EmptyView()})
}
}
我们将刚才封装 PageContentView 用到我们的首页。
struct HomePage: View {
...
var body: some View {
NavigationView {
PageContentView(title: "首页") {
...
} leadingBuilder: {
...
} trailingBuildeder: {
EmptyView()
}
}
...
}
}
我们封装完毕 PageContentView 完毕之后,我们想让登录页面也统一走这个封装试图,我们尝试的修改一下登录界面。
struct LoginPage: View {
...
var body: some View {
PageContentView(title: "登陆") {
VStack {
...
}
.background(.white)
}
...
}
}
我们登陆页面使用 PageContentView 之后,背景颜色会变成灰色,我们重新设置一下内容区域颜色即可。
之前在封装登陆页面的时候,对于外部 HUDView 一致无法封装,我们现在能否封装在 PageContentView 里面呢?
/// 页面的基础试图
struct PageContentView<Content:View,
Leading:View,
Trailing:View,
ViewModel:BaseViewModel>: View {
...
@ObservedObject private var viewModel:ViewModel
...
/// 初始化页面试图
/// - Parameters:
/// - title: 导航标题
/// - contentBuilder: 内容
/// - leadingBuilder: 导航左侧按钮
/// - trailingBuildeder: 导航右侧按钮
init(title:String,
viewModel:ViewModel,
@ViewBuilder contentBuilder:() -> Content,
@ViewBuilder leadingBuilder:() -> Leading,
@ViewBuilder trailingBuildeder:() -> Trailing) {
...
self.viewModel = viewModel
...
}
var body: some View {
navigationBar {
ZStack {
Color(uiColor: appColor.c_efefef)
content
}
.hud(viewModel: $viewModel)
}
}
...
}
extension PageContentView where Leading == EmptyView, Trailing == EmptyView {
init(title:String,
viewModel:ViewModel,
contentBuilder:() -> Content) {
self.init(title: title,
viewModel: viewModel,
contentBuilder: contentBuilder,
leadingBuilder: {EmptyView()},
trailingBuildeder: {EmptyView()})
}
}
我们测试将HUD封装在 PageContentView内部,通过登陆页面调试一切正常。现在我们可以开始做我的页面了。
struct MyPage: View {
@StateObject private var viewModel = MyPageViewModel()
var body: some View {
PageContentView(title: "我的",
viewModel: viewModel) {
Text("Hello, World!")
}
}
}
struct MyPage_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
MyPage()
}
}
}
![]()
我的页面基本全是这种左右对齐的控件,我们先制作这个控件。
struct MyCellContentView: View {
@StateObject private var appColor = AppColor.share
var body: some View {
HStack {
Text("车间")
.font(.system(size: 14))
.foregroundColor(Color(uiColor: appColor.c_333333))
Spacer()
HStack {
Text("深圳车间")
.font(.system(size: 14))
.foregroundColor(Color(uiColor: appColor.c_333333))
Image(systemName: "chevron.right")
.foregroundColor(Color(uiColor: appColor.c_cccccc))
}
}
.frame(maxWidth: .infinity)
.padding(EdgeInsets(top: 15, leading: 10, bottom: 15, trailing: 10))
.background(.white)
}
}
![]()
我们进行提炼封装,方便后面使用。
struct MyCellContentView<Right:View>: View {
...
private let right:Right
...
init(title:String,
@ViewBuilder rightBuilder:() -> Right) {
self.title = title
self.right = rightBuilder()
}
var body: some View {
HStack {
Text(title)
...
...
right
}
...
}
}
我们使用上面的组件来给我的页面绘制下面的界面
![]()
private func userNameCell() -> some View {
MyCellContentView(title: "姓名") {
Text("我的名字")
.font(.system(size: 14))
.foregroundColor(Color(uiColor: appColor.c_cccccc))
}
}
我们这里需要展示用户的昵称,但是我们在用户登录的时候没有保存用户的昵称,我们在登录页面写一下保存的逻辑。
class AppConfig: ObservableObject {
...
@AppStorage("userInfo")
private var userInfo:String?
/// 用户信息
var userInfoModel:UserInfoModel? {
get {
guard let userInfo = userInfo, let jsonData = userInfo.data(using: .utf8) else {
return nil
}
return try? CleanJSONDecoder().decode(UserInfoModel.self, from: jsonData)
}
set {
guard let value = newValue, let jsonData = try? JSONEncoder().encode(value) else {
userInfo = nil
return
}
userInfo = String(data: jsonData, encoding: .utf8)
}
}
}
class LoginPageViewModel: BaseViewModel {
...
func login() async {
...
AppConfig.share.userInfoModel = model.data?.user
}
}
我们已经可以拿到保存的用户名了,我们更新一下刚才视图的代码。
struct MyPage: View {
...
private func userNameCell() -> some View {
MyCellContentView(title: "姓名") {
Text(AppConfig.share.userInfoModel?.userName ?? "")
...
}
}
}
接下来我们制作下面的视图
![]()
import SwiftUI
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的",
viewModel: viewModel) {
VStack {
...
workshopCell()
...
}
}
}
...
private func workshopCell() -> some View {
MyCellContentView(title: "车间") {
HStack {
Text("深圳车间")
.font(.system(size: 14))
.foregroundColor(Color(uiColor: appColor.c_333333))
Image(systemName: "chevron.right")
.foregroundColor(Color(uiColor: appColor.c_cccccc))
}
}
}
}
![]()
我们发现 MyCellContentView 组件的下面没有显示横线,这个和我们界面不太一样,我们就修改 MyCellContentView 新增一条线。
struct MyCellContentView<Right:View>: View {
...
var body: some View {
VStack(spacing: 0) {
HStack {
...
}
.frame(maxWidth: .infinity)
.padding(EdgeInsets(top: 15, leading: 10, bottom: 15, trailing: 10))
Rectangle()
.foregroundColor(Color(uiColor: appColor.c_cccccc))
.frame(height: 0.5)
.padding(.leading, 15)
}
.background(.white)
}
}
接下来我们做产线界面
![]()
我们发现和刚才做的车间的界面一模一样,我们可以先将车间的进行提炼。
struct MyDetailCellContentView: View {
@StateObject private var appColor = AppColor.share
private let title:String
private let detail:String
init(title:String,
detail:String) {
self.title = title
self.detail = detail
}
var body: some View {
MyCellContentView(title: title) {
HStack {
Text(detail)
.font(.system(size: 14))
.foregroundColor(Color(uiColor: appColor.c_333333))
Image(systemName: "chevron.right")
.foregroundColor(Color(uiColor: appColor.c_cccccc))
}
}
}
}
struct MyPage: View {
...
private func workshopCell() -> some View {
MyDetailCellContentView(title: "车间", detail: "深圳车间")
}
}
![]()
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的",
viewModel: viewModel) {
VStack(spacing: 0) {
...
productLineCell()
...
}
}
}
...
private func productLineCell() -> some View {
MyDetailCellContentView(title: "产线", detail: "生产线_yk")
}
}
![]()
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的",
viewModel: viewModel) {
VStack(spacing: 0) {
...
Spacer()
.frame(height:10)
storeHourseCell()
...
}
}
}
...
private func storeHourseCell() -> some View {
MyDetailCellContentView(title: "仓库", detail: "123")
}
}
![]()
Toggle 开关
我们自动登录模块稍微有一些不同,我们右侧是一个开关按钮,我们需要用到Toggle。
class AppConfig: ObservableObject {
...
/// 是否自动登录
@AppStorage("isAutoLogin")
var isAutoLogin = false
}
struct MyPage: View {
...
@StateObject private var appConfig = AppConfig.share
var body: some View {
PageContentView(title: "我的",
viewModel: viewModel) {
VStack(spacing: 0) {
...
autoLoginCell()
...
}
}
}
...
private func autoLoginCell() -> some View {
MyCellContentView(title: "自动登录") {
Toggle("", isOn: $appConfig.isAutoLogin)
}
}
}
![]()
显示当前版本,这个可以将显示名称的提炼共用一套。
struct MyDetailStyle1CellContentView: View {
@StateObject private var appColor = AppColor.share
private let title:String
private let detail:String
init(title:String,
detail:String) {
self.title = title
self.detail = detail
}
var body: some View {
MyCellContentView(title: title) {
Text(detail)
.font(.system(size: 14))
.foregroundColor(Color(uiColor: appColor.c_cccccc))
}
}
}
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的",
viewModel: viewModel) {
VStack(spacing: 0) {
...
appVersionCell()
...
}
}
}
private func userNameCell() -> some View {
MyDetailStyle1CellContentView(title: "姓名",
detail: AppConfig.share.userInfoModel?.userName ?? "")
}
...
private func appVersionCell() -> some View {
MyDetailStyle1CellContentView(title: "版本", detail: "1.2.0(1638264135)")
}
}
![]()
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的",
viewModel: viewModel) {
VStack(spacing: 0) {
...
logoutButton()
...
}
}
}
...
private func logoutButton() -> some View {
Button {
} label: {
Text("退出登录")
.frame(maxWidth:.infinity)
.frame(height:45)
.background(Color(uiColor: appColor.c_209090))
.foregroundColor(.white)
.font(.system(size: 16))
.cornerRadius(5)
.padding(30)
}
}
}
至此,我的界面算是全部做出来了。但是界面的数据和交互算是我的页面复杂的部分,虽然显示文本很简单,但是数据的获取十分的麻烦。
我们将我的页面添加到 TabPage里面。
struct TabPage: View {
...
var body: some View {
TabView(selection:$currentTabIndex) {
...
MyPage()
.tabItem {
VStack {
if currentTabIndex == 1 {
Image("我的1")
} else {
Image("我的2")
}
Text("我的")
}
}
.tag(1)
}
...
}
}
第 二十章 @Published sink
为了让选中工厂之后可以显示我们工厂的名称,我们修改代码如下。
HomePage
/// old
Text("请选择工厂")
/// new
Text(viewModel.currentFactory.factoryName ?? "请选择工厂")
@Published sink监听值的变化
但是我们想把选中的工厂编码保存到本地,用于下次启动可以显示上次选中的工厂。我们直接使用 @AppStorage吗?但是我们是一个模型呀,不行,我们怎么能够坚挺到值的变化进行操作呢?。
我们直接通过操作 @Published sink进行值更新的监听。
class HomePageViewModel: BaseViewModel {
....
@Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
factoryName: nil)
private var factorySink:AnyCancellable?
override init() {
super.init()
factorySink = $currentFactory.sink { model in
print("sink \(model.factoryName)")
}
}
....
}
这里有一个坑,不要直接进行这样的操作。
$currentFactory.sink { model in
print("sink \(model.factoryName)")
}
通过 didSet 监听值更新
没有强保留返回结果,是不能够监听后续值更新操作的。使用起来这么麻烦吗?其实不然,我们可以通过 Swift中对于值更新的 didSet 方法进行监听值更新。
class HomePageViewModel: BaseViewModel {
...
@Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
factoryName: nil) {
didSet {
print("didset \(currentFactory.factoryName)")
}
}
....
}
这种使用起来十分的方便,我推荐使用这一种方式。我们已经可以拿到选中工厂的代码了,那么我们就可以新增一个属性用于保存。
class AppConfig: ObservableObject {
...
/// 当前选中的工厂代码
@AppStorage("currentFactoryCode")
var currentFactoryCode:String?
}
我们接收到用户选中工厂之后,将最新选中的工厂代码进行保存。
class HomePageViewModel: BaseViewModel {
...
@Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
factoryName: nil) {
didSet {
AppConfig.share.currentFactoryCode = currentFactory.factoryCode
}
}
...
}
我们将选中的工厂代码保存到了本地,下次启动我们需要在最新的工厂代码寻找,如果找到,就用对应模型,否则就用第一个模型。
class HomePageViewModel: BaseViewModel {
/// 工厂列表
@Published var factoryList:[FactoryListResponseModel] = []
@Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
factoryName: nil) {
didSet {
AppConfig.share.currentFactoryCode = currentFactory.factoryCode
}
}
/// 请求工厂列表
func requestFactoryList() async {
...
factoryList = model.data ?? []
if let factoryModel = findFactory() {
currentFactory = factoryModel
} else if let firstModel = factoryList.first {
currentFactory = firstModel
}
}
/// 查找保存的工厂代码对应最新工厂列表的模型
private func findFactory() -> FactoryListResponseModel? {
return factoryList.first { model in
guard let currentFactoryCode = AppConfig.share.currentFactoryCode else {return false}
guard let factoryCode = model.factoryCode else {return false}
return currentFactoryCode == factoryCode
}
}
}
接下来我们就要编写首页功能组件了。
![]()
首页布局
我们发现一个功能组件大概有这样的特征。
- 高度随着组件数量变化
- 周围有圆角
- 左侧按钮垂直居左并各自居中对齐
- 中间居中
- 右侧按钮垂直巨右并且各自居中对齐
我们画一下模板就清楚了。
![]()
我们讲一个功能模块按照左侧功能区域,中间功能区域,和右侧功能区域进行布局。如果按照一行一行的布局按钮,会导致和下面的组件无法对齐。
如果直接使用 GridView,感觉也是不行,他们又不是均匀分布的,我觉得目前可行的布局方案就是按照模板进行布局,后续遇到问题再解决。
我们先来制作首页功能按钮
![]()
struct ActionButton: View {
var body: some View {
VStack {
Image("物料绑定托盘")
.frame(width:40, height: 40)
.background(Color(uiColor: UIColor("#209090")))
.cornerRadius(8.5)
Text("物料绑定托盘")
.foregroundColor(Color(uiColor: UIColor("#666666")))
}
}
}
![]()
因为图标和文本是动态,我们修改代码支持动态生成。
struct ActionButton: View {
let icon:String
let iconColor:UIColor
let title:String
var body: some View {
VStack {
Image(icon)
...
.background(Color(uiColor: iconColor))
...
Text(title)
...
}
}
}
我们功能组件封装完毕,接下来我们封装功能视图组件。
struct ActionView: View {
var body: some View {
VStack {
ActionButton(icon: "物料绑定托盘",
iconColor: UIColor("#209090"),
title: "物料绑定托盘")
ActionButton(icon: "托盘绑定箱号",
iconColor: UIColor("#F19037"),
title: "托盘绑定箱号")
ActionButton(icon: "灭菌",
iconColor: UIColor("#0EA1DA"),
title: "灭菌")
}
}
}
![]()
我们不确定我们一列到底显示多少个,所以我们需要动态的进行配置。
struct ActionItem: Hashable {
/// 图标名称
let icon:String
/// 图标背景色
let iconColor:UIColor
/// 按钮文本
let title:String
}
struct ActionView: View {
let actionItems:[ActionItem]
var body: some View {
VStack {
ForEach(actionItems, id: \.self) { item in
ActionButton(icon: item.icon,
iconColor: item.iconColor,
title: item.title)
}
}
}
}
我们一列按钮视图做好之后,我们封装一整块的功能。
struct ActionCardView: View {
var body: some View {
VStack {
HStack {
Text("生产执行")
.foregroundColor(Color(uiColor: UIColor("#333333")))
.fontWeight(.medium)
.font(.system(size: 14))
Spacer()
}
Spacer()
.frame(height:15)
HStack {
ActionView(actionItems: [
...
])
ActionView(actionItems: [
...
])
ActionView(actionItems: [
...
])
}
}
.frame(maxWidth:.infinity)
.padding(15)
.background(.white)
.cornerRadius(10)
}
}
![]()
总是感觉这界面有点乖乖的,和我们设计图一点都不搭。我们给 ActionView 添加一个背景颜色看一下。
struct ActionView: View {
...
var body: some View {
VStack {
...
}
.background(.red)
}
}
![]()
我们中间功能区域没有宽度没有完全的充满,我们先设置一下。
struct ActionCardView: View {
var body: some View {
VStack {
...
HStack() {
...
}
.frame(maxWidth:.infinity)
}
...
}
}
![]()
组件最大宽度已经发生了变化,但是三个没有充满,我们需要在组件的中间添加Spacer。
struct ActionCardView: View {
var body: some View {
VStack {
...
HStack() {
ActionView(actionItems: [
...
])
Spacer()
ActionView(actionItems: [
...
])
Spacer()
ActionView(actionItems: [
...
])
}
...
}
...
}
}
![]()
此时看起来好多了,但是中间的间隙是平分的,按照中间视图居中原则,当左侧和右侧视图宽度一致,那么间隙才可能宽度相等。
此时左侧和右侧的宽度不等,那么此时平分的话,中间视图一定偏右侧了。
那么我们就需要计算 左侧视图宽度,中间视图宽度,右侧视图宽度,总宽度。
struct ActionCardView: View {
@State private var leftViewWidth:CGFloat = 0
@State private var centerViewWidth:CGFloat = 0
@State private var rightViewWidth:CGFloat = 0
@State private var contentViewWidth:CGFloat = 0
var body: some View {
VStack {
...
HStack() {
ActionView(actionItems: [
...
])
.getWidth(width: $leftViewWidth)
Spacer()
.frame(width:spacer1Width)
ActionView(actionItems: [
...
])
.getWidth(width: $centerViewWidth)
Spacer()
.frame(width:spacer2Width)
ActionView(actionItems: [
...
])
.getWidth(width: $rightViewWidth)
}
...
.getWidth(width: $contentViewWidth)
}
...
}
private var spacer1Width:CGFloat {
let width = contentViewWidth / 2 - leftViewWidth - centerViewWidth / 2
return max(width, 0)
}
private var spacer2Width:CGFloat {
let width = contentViewWidth / 2 - rightViewWidth - centerViewWidth / 2
return max(width, 0)
}
}
fileprivate extension View {
func getWidth(width:Binding<CGFloat>) -> some View {
self.background {
GeometryReader { geometry in
_getWidth(width: width, geometry: geometry)
}
}
}
private func _getWidth(width:Binding<CGFloat>, geometry:GeometryProxy) -> some View {
width.wrappedValue = geometry.size.width
return Color.clear
}
}
我们通过设置计算出当第二个试图居中显示,第一个和第三个分别居左和居右的时候,Spacer1和Spacer2的宽度,来达到居中的目的。间隙不可能存在负数,如果存在就是重叠了,这在显示上面是不允许的。
![]()
此时布局已经分别居左 居中和居右显示了。从目前来看,的确没什么问题,但是我们如果按钮的标题十分的长,是怎么样的一个显示呢?
![]()
虽然按钮分组项目没有影响,但是按钮标题的环境导致横向的没有对齐,十分的难看,我们设置一下按钮的标题最大智能显示一行。
struct ActionButton: View {
...
var body: some View {
VStack {
...
Text(title)
...
.lineLimit(1)
}
}
}
![]()
这样看来感觉正常了。为了将功能模块可以一自定义的新增和删除,我们需要对于 ActionCardView进行提炼和封装。
struct ActionCardView: View {
let title:String
let actions:[ActionItem]
...
var body: some View {
VStack {
HStack {
Text(title)
...
}
...
HStack() {
ActionView(actionItems: actions(index: .left))
...
ActionView(actionItems: actions(index: .center))
...
ActionView(actionItems: actions(index: .right))
...
}
...
}
...
}
...
/// 根据索引获取对应的功能列表
/// - Parameter index: 功能索引
/// - Returns: 功能分组
private func actions(index:ActionIndex) -> [ActionItem] {
var actionItems:[ActionItem] = []
var itemInex = index.rawValue
while itemInex < actions.count {
actionItems.append(actions[itemInex])
itemInex += 3
}
return actionItems
}
/// 功能索引
private enum ActionIndex:Int {
/// 左侧功能区域
case left
/// 中间功能区域
case center
/// 右侧功能区域
case right
}
}
![]()
看起来我们已经提炼完毕了,但是目前我们的数据是对称的,因为是配置的,所以存在多多稍稍的情况。我们去掉两个看一下情况。
![]()
缺少之后我们的按钮瞬间就乱了顺序,我们设置顶部对齐。
struct ActionCardView: View {
...
var body: some View {
VStack {
...
HStack(alignment:.top) {
...
}
...
}
...
}
...
}
![]()
当我只剩下三四个功能的时候,竟然之前的布局不工作了,我干脆就让三等分,左侧就设置居左,中间的就居中,右侧就居右显示。
struct ActionCardView: View {
...
var body: some View {
VStack {
...
HStack(alignment:.top) {
HStack {
ActionView(actionItems: actions(index: .left))
Spacer()
}
.frame(maxWidth:.infinity)
HStack {
ActionView(actionItems: actions(index: .center))
}
.frame(maxWidth:.infinity)
HStack {
Spacer()
ActionView(actionItems: actions(index: .right))
}
.frame(maxWidth:.infinity)
}
...
}
...
}
...
}
![]()
我们把生产执行的功能添加到首页里面。
struct HomePage: View {
...
var body: some View {
NavigationView {
navigationBar {
ZStack {
Color(uiColor: appColor.c_efefef)
VStack {
ActionCardView(
title: "生产执行",
actions: [
...
])
.padding(EdgeInsets(top: 10, leading: 10, bottom: 0, trailing: 10))
Spacer()
}
}
}
}
...
}
...
}
![]()
第十九章 TabView|accentColor|AnyView|NavigationView|navigationTitle|navigationBarTit
用户登录之后,就可以进入首页了,我们看一下首页的 UI的样子。
![]()
我们先创建一个 HomePage。
![]()
我们在入口修改逻辑,支持登录完毕进入首页。
![]()
![]()
TabView 创建 TabBar
我们登录完毕,或者下次启动就进入了首页了。我们首页底部是有 Tab 的,我们需要用到 TabView。
我们创建一个 TabPage,用户显示我们首页底部的 Tab。
![]()
我们修改一下代码,将我们的 HomePage 添加进去。
![]()
![]()
这显示的效果明显不是我们需要的效果,而且文本怎么变成蓝色了?我们需要的是下面的效果
![]()
accentColor设置 TabBar 选中颜色
我们尝试一下设置一下文本的前景色。
![]()
但是没有任何的效果,这时候我们需要谷歌一下资料是什么原因了。发现网上一些文章答案已经不能用了,但是 accentColor 这个还是有效果的,但就是废弃了。
![]()
![]()
![]()
需要用最新的 tint(_:),如果想更改默认未选中 item 的颜色,需要通过下面代码设置。
![]()
尝试封装 TabView
为了我们的 TabItem 可以方便的进行设置,我们决定封装一下我们 TabView。
![]()
我们如果封装,需要用户提供试图内容,未选中的图标,选中图标,选中的颜色,未选中的颜色,还有当前选中的索引。
![]()
我们需要用户传入一个 TabItem 的数组,我们通过数组进行创建 TabView的item。
![]()
但是我们的范型的结构体无法放在数组里面。那么,我们可以将范型设置为 AnyView,这样报错解决了,但是在SwiftUI中最好不要用到 AnyView,这会导致系统无法推断最外层结构,从而无法优化Diff算法,优化性能。
走到这一步,我们发现还是不要封装为好,毕竟超过5个的tabItem就已经少之又少。
我们重新修改一下 TabPage的代码。
![]()
![]()
![]()
![]()
⚠️我们使用最新的 .tint(\_:) 会经常不起效果,但是换成 .accentColor(_:)就可以。
对于 TabView 我就先到此为止了,目前也是达到我们的效果,接下来我们开始做我们首页的逻辑。
![]()
NavigationView 使用导航
首页的头部是一个导航条,并且左侧有一个进行选择的选择框。对于导航,我们需要用到 NavigationView。
![]()
.navigationTitle 设置导航标题
但是我们怎么设置导航标题呢?我们可以在任何子组件通过 .navigationTitle进行设置。
![]()
![]()
.navigationBarTitleDisplayMode 设置导航样式
但是我们的导航显示是默认的大标题,是符合 iOS新版本的系统风格一样。不过我们可以通过.navigationBarTitleDisplayMode进行设置导航标题的显示模式。
![]()
![]()
.toolbar 添加导航按钮
此时我们的导航的标题已经显示正常了。但是我们工厂选择的组件怎么添加到首页左侧的位置呢?经过谷歌之后,我们发现可以通过.toolbar的方法轻松的添加左侧和右侧的视图。
![]()
![]()
我们将添加导航的代码提炼出来,并且设置页面背景颜色为淡灰色。
struct HomePage: View {
@StateObject private var appColor:AppColor = AppColor.share
var body: some View {
NavigationView {
navigationBar {
Color(uiColor: appColor.c_fefefe)
}
}
}
private func navigationBar<Content:View>(@ViewBuilder content:() -> Content) -> some View {
content()
.navigationTitle(Text("首页"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement:.navigationBarLeading) {
HStack(spacing:6) {
Text("请选择工厂")
.foregroundColor(Color(uiColor: appColor.c_999999))
.font(.system(size: 15))
Image("drop_icon")
}
}
}
}
}
首页进来需要先获取工厂列表,如果之前设置过并且不在工厂列表,或者都没设置过工厂,则默认为列表的第一个工厂。
我们不管怎么样的逻辑,第一步就是获取工厂列表,之后才能做剩下的逻辑。
class HomePageViewModel: BaseViewModel {
/// 请求工厂列表
func requestFactoryList() async {
let api = FactoryListApi()
let model:BaseModel<FactoryListResponseModel> = await request(api: api)
// 下面逻辑
}
}
现在我们就拿到了全部的工厂列表,我们判断请求成功就保存工厂列表到当前页面。
// 新建一个 @Published 可以接受请求的工厂列表并通知更新
/// 工厂列表
@Published var factoryList:[FactoryListResponseModel] = []
// 请求成功就将工厂数据更新
guard model._isSuccess else {return}
factoryList = model.data ?? []
Hashable 解决 ForEach 可能 Sting 相同报错
有了工厂列表我们就可以点击 PopMenuButton 展示所有的工厂列表了。但是我们渲染的时候出现了报错,提示下面的报错。
ForEach<Array<String>, String, ModifiedContent<ModifiedContent<PopMenuButtonItem, _BackgroundStyleModifier<BackgroundStyle>>, AddGestureModifier<_EndedGesture<TapGesture>>>>: the ID 111111 occurs multiple times within the collection, this will give undefined results!
提示我们多次出现了数据111111,可能会导致找不到唯一 ID的结果。这样一看,确实是我们当初封装的时候考虑的太浅,没有想到展示的数据可能名字一样,虽然不合理,但是存在。
为了解决这个问题,我们必须对 PopMenuButton 进行重构,我们需要将数据假设成一个协议。
protocol PopMenuItem:Hashable {
/// 显示在 Menu Item 的文字
var menuTitle:String {get}
}
/// old
struct PopMenuButton: View {
let items:[String]
@Binding var currentItem:String
/// new
struct PopMenuButton<T:PopMenuItem>: View {
let items:[T]
@Binding var currentItem:T
/// old
typealias ItemValueChanged = (String) -> Void
/// new
typealias ItemValueChanged = (T) -> Void
/// old
PopMenuButtonItem(title: item,
/// new
PopMenuButtonItem(title: item.menuTitle,
我们将PopMenButton 的代码修改成如上。但是之前的示例和引用都会报错,对于名字重复出现几率很小,不可能不允许纯文本数组支持。
String 实现 PopMenuItem 实现兼容
为了兼容和支持纯文本数组的支持,我们新增String的扩展。
extension String: PopMenuItem {
var menuTitle: String {self}
}
为了修复我们工厂数据源,因为工厂名字存在重复,我们将 FactoryListResponseModel 实现我们 PopMenuItem 协议。
extension FactoryListResponseModel: PopMenuItem {
var menuTitle: String { factoryName ?? "" }
/// 重写 == 方法 为了自定义实现两个模型是否一样
static func ==(lhs:FactoryListResponseModel, rhs:FactoryListResponseModel) -> Bool {
guard let leftCode = lhs.factoryCode, let rightCode = rhs.factoryCode else {return false}
return leftCode == rightCode
}
}
我们调整一下首页的代码,来支持 FactoryListResponseModel 模型。
/// HomePageViewMode
/// old
@Published var currentFactoryName:String = ""
/// new
@Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
factoryName: nil)
PopMenuButtonModify
/// old
struct PopMenuButtonModify: ViewModifier {
let items:[String]
@Binding var currentItem:String
/// new
struct PopMenuButtonModify<T:PopMenuItem>: ViewModifier {
let items:[T]
@Binding var currentItem:T
View+popMenuButton
/// old
func popMenuButton(items:[String],
currentItem:Binding<String>,
isShowPopMenuButton:Binding<Bool>) -> some View{
/// new
func popMenuButton<T:PopMenuItem>(items:[T],
currentItem:Binding<T>,
isShowPopMenuButton:Binding<Bool>) -> some View{
HomePage
/// old
.popMenuButton(items: factoryNames,
currentItem: $viewModel.currentFactoryName,
/// new
.popMenuButton(items: viewModel.factoryList,
currentItem: $viewModel.currentFactory,
此时我们工厂选择再也不报错误了,可以正常的显示出来了。
![]()
第十八章 封装HUD和完善登录界面逻辑
我们几乎在 LoginPageViewModel 添加了大量的代码,才实现了请求展示 HUD,请求完毕展示信息之后 2 秒自动消失。
我们需要每个界面都要写这么多的代码吗?我们可以考虑进行封装,那么我们可以将 @MainActor 和 相关的 HUD的属性和逻辑转移到 BaseViewModel 里面。
![]()
![]()
我们进行登陆的方法的代码依然没有减少,我觉得这样的代码量还是很多。我们可以精简一下,请求的时候可以根据我们传入的参数自动显示 HUD,和自动的展示 错误的提示。
正确的提示交给使用者,这样精简之后,使用起来岂不是更加的简单。
![]()
![]()
这样我们使用起来,代码量就变得很简单了,而且如果中间多次请求,只需要在最后关闭 HUD 即可。
我们思考一下,我们每一个界面都需要设置下面代码来支持 HUD的显示吗?
![]()
我们可以使用继承方式,但是遗憾的是 Struct 不支持继承。我们用协议实现怎么样呢?
![]()
我们将所有单独的页面需要实现我们新增的扩展方法。
![]()
虽然也代码量也没有省多少,但是不需要传递那么多的参数,从而后面的改动不会影响外层的使用。
我们登陆不可能这么的逻辑代码不可能这么的简单,如果用户没有输入用户名和密码,自然不需要进行请求,浪费网络资源。
那么对于没有用户名和密码输入,我们则进行提示用户。
![]()
我们发现当我们直接点击登录按钮的时候,界面没有任何的提示。这是因为我们当时设置了只有 isAnimating = true 才会展示 HUDView,我们修改一下代码。
![]()
这样就完成了如果用户名和密码没有输入就提示用户输入,之后进行登录就加载 HUD,请求完毕展示信息。
我们登陆完毕之后,如果用户开启了记住密码,我们需要将用户的用户名和密码保存下来,下次不需要用户输入用户名和密码。
那么我们需要用到 @AppStorage,但是我们直接修改成下面代码,会有什么问题呢?
![]()
这样就算我们开启记住密码,系统已经将用户名和密码保存下来了。我们只能放在AppConfig里面,当开启就设置 AppConfig 的用户名和密码,进入登陆页面就读取 AppConfig 之前保存的用户名和密码。
![]()
我们在 LoginPageViewModel 的登录方法里面,当登录成功并且开启保存将用户名和密码进行保存。
![]()
我们在 LoginPageViewModel 的初始化方法里面,将之前保存的 用户名和密码进行设置。
![]()
我们登录完毕获取到的 gatewayUserName 相当于 JWT,用于后续需要用户的接口,那么我们也保存在 AppConfig 里面。
![]()
![]()
到此为止,我们终于完成了 LoginPage 的交互和逻辑。
第十七章 @MainActor
HUDViewModify 封装完毕,我们添加在 LoginPage 主页面上面,首先需要在 LoginViewModel 新增一个 isLoadingHUD 的参数。
![]()
在 LoginPage 将 HUD 添加在最外层。
![]()
那么我们在 LoginPageViewModel 中的 login 方法在请求之前开始 HUD,和在请求完毕结束 HUD。
![]()
但是我们发现一个问题,是我们不注意导致的,我们竟然没有在初始化状态隐藏底部的背景,导致初始化状态就看到一个黑色的背景。
我们修改一个 HUDViewModify 的代码。
![]()
@MainActor 在 async 方法更新 UI
这样初始化状态不显示,当需要隐藏 HUD 的时候一起隐藏。我们修改完毕之后,我们测试一下登录功能。
从表面上一切都正常,但是我们看到打印的 Log,发现我们更新UI不在主线程,那么在Release就会导致出问题。
[SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
我们在 async 的方法内部怎么回到主线程更新 UI,我们还是用之前的方式更新吗?
![]()
也是可以的 ,但是我们出来了 async/await 还这么麻烦吗?大难是否定的,我们有一个 @MainActor 的修饰符。关于 @MainActor 大家可以参考下面的文章了解一下。
![]()
![]()
通过 @MainActor 装饰我们的 LoginPageViewModel 之后,我们就可以正常通过设置 @Published 值进行更新 SwiftUI 了。
但是我们执行登录请求,虽然也看到加动画和消失动画。对于用户来说,也不知道这次登录时是成功还是失败。虽然可以通过登录成功进入首页做到这一点,但是请求接口完毕做一个提示还是好的。
修改支持提示纯文本
我们修改一下 HUDView 允许可以显示提示的文本,修改的代码如下。
![]()
但是我们显示 UI 发生了变化,结果成为了下面显示的样子。
![]()
调整 HUD 大小等于内容大小
这和我们预想的不一样,我们预想的不管组件多大,我们都会在最外层添加一个 Padding,并且设置大小为 Fit 的大小。
我们想到我们设置地步黑色区域为宽度和高度相等,现在中间的文件是宽度大于高度,所以按照等比。那么背景大小宽度就等于高度,导致这个样子的出现。
![]()
我们删掉了上面的代码,界面效果恢复了。
![]()
![]()
现在是我们的文本比较长,但是当我们显示一个特别短的文本会是怎么样的 UI 呢?
![]()
设置最短显示宽度
这个也说的过去,但是总觉得显示宽度太短了,我们想设置最短的宽度为 50。
![]()
![]()
此时看起来还差不多。
我们就让 LoginPage 的登录操作,报错就提示错误,否则就提示成功。
![]()
![]()
延时 2 秒消失
但是我们的提示不会消失了,我们设置提示完毕之后延时 2 秒消失。
![]()
![]()
此时我们已经做到请求完毕提示 2 秒之后消失。