普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月24日掘金 iOS

iOS Swift:蓝牙 BLE 连接外设CoreBluetooth

作者 tangbin583085
2026年2月24日 17:59

在 iOS 与智能硬件(手环、传感器、控制模块等)交互中,BLE(Bluetooth Low Energy)是最常用的通信方式。本文将基于 CoreBluetooth + Swift,给出一套工程可用的连接外设代码,并总结开发中最常遇到的注意事项。

适用场景:BLE 设备连接、读写特征、订阅通知、接收回包、断线重连。

一、准备工作

1)Info.plist 权限配置(必须)

iOS 13+ 起必须给出蓝牙使用说明,否则扫描/连接会失败或系统拒绝。

<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接外设进行数据通讯</string>

如果你还需要后台持续蓝牙通信:需要 capability + plist 配置(后文注意事项会讲)。

2)导入框架

import CoreBluetooth

二、BLE 连接的标准流程

  1. 初始化 CBCentralManager
  2. 蓝牙开启后开始扫描
  3. 找到目标外设并连接
  4. 发现 Service
  5. 发现 Characteristic
  6. 找到写特征(Write)与通知特征(Notify)
  7. 开启通知并开始收发数据

三、代码:BluetoothManager(可直接用)

下面给出一个轻量但工程化的 BLE 管理器,支持:

  • 按名称/服务 UUID 过滤
  • 扫描超时
  • 连接成功后自动发现服务/特征
  • 自动开启 Notify
  • 写入数据(支持写入响应/无响应)
  • 断开回调(可扩展重连)
  • 简单的写入节流(避免写太快导致丢包) ``

你只需要把 UUID 替换成你设备的即可。

import Foundation
import CoreBluetooth

final class BluetoothManager: NSObject {
    
    static let shared = BluetoothManager()
    
    // MARK: - Public callbacks (按需扩展)
    var onStateChanged: ((CBManagerState) -> Void)?
    var onDiscovered: ((CBPeripheral, NSNumber) -> Void)?
    var onConnected: ((CBPeripheral) -> Void)?
    var onDisconnected: ((CBPeripheral, Error?) -> Void)?
    var onReceiveData: ((Data, CBCharacteristic) -> Void)?
    
    // MARK: - CoreBluetooth
    private var central: CBCentralManager!
    private(set) var peripheral: CBPeripheral?
    
    private var writeChar: CBCharacteristic?
    private var notifyChar: CBCharacteristic?
    
    // MARK: - Config (替换为你的设备 UUID)
    /// 推荐:用 Service UUID 过滤扫描,效率更高、结果更准
    private let targetServiceUUID = CBUUID(string: "FFF0")
    private let writeCharUUID      = CBUUID(string: "FFF1")
    private let notifyCharUUID     = CBUUID(string: "FFF2")
    
    /// 可选:按名称过滤(如果设备名称稳定)
    private let targetNamePrefix = "MyBLE"
    
    // MARK: - Scan control
    private var scanTimer: Timer?
    private let scanTimeout: TimeInterval = 10
    
    // MARK: - Write throttle
    private var writeQueue: [Data] = []
    private var isWriting = false
    
    private override init() {
        super.init()
        // queue 建议用串行队列,避免回调并发导致状态错乱
        let queue = DispatchQueue(label: "com.tangbin.ble.queue")
        central = CBCentralManager(delegate: self, queue: queue)
    }
    
    // MARK: - Public APIs
    
    /// 开始扫描
    func startScan() {
        guard central.state == .poweredOn else { return }
        stopScan()
        
        // 只扫目标 Service:更省电更精准(强烈推荐)
        central.scanForPeripherals(withServices: [targetServiceUUID], options: [
            CBCentralManagerScanOptionAllowDuplicatesKey: false
        ])
        
        startScanTimeoutTimer()
    }
    
    /// 停止扫描
    func stopScan() {
        if central.isScanning {
            central.stopScan()
        }
        scanTimer?.invalidate()
        scanTimer = nil
    }
    
    /// 连接外设
    func connect(_ p: CBPeripheral) {
        stopScan()
        peripheral = p
        peripheral?.delegate = self
        
        central.connect(p, options: [
            CBConnectPeripheralOptionNotifyOnDisconnectionKey: true
        ])
    }
    
    /// 主动断开
    func disconnect() {
        guard let p = peripheral else { return }
        central.cancelPeripheralConnection(p)
    }
    
    /// 发送数据(写入队列节流)
    func send(_ data: Data, withResponse: Bool = false) {
        guard let p = peripheral, let w = writeChar else { return }
        let type: CBCharacteristicWriteType = withResponse ? .withResponse : .withoutResponse
        
        // 如果写入无响应,也建议做节流,避免外设来不及处理
        writeQueue.append(data)
        pumpWriteQueue(peripheral: p, characteristic: w, type: type)
    }
    
    // MARK: - Private
    
    private func startScanTimeoutTimer() {
        scanTimer?.invalidate()
        scanTimer = Timer.scheduledTimer(withTimeInterval: scanTimeout, repeats: false) { [weak self] _ in
            self?.stopScan()
        }
        RunLoop.main.add(scanTimer!, forMode: .common)
    }
    
    private func pumpWriteQueue(peripheral p: CBPeripheral,
                                characteristic c: CBCharacteristic,
                                type: CBCharacteristicWriteType) {
        guard !isWriting else { return }
        guard !writeQueue.isEmpty else { return }
        
        isWriting = true
        let packet = writeQueue.removeFirst()
        
        // 注意:此处写入发生在 central 的队列上
        p.writeValue(packet, for: c, type: type)
        
        // withoutResponse 的情况下不会走 didWriteValueFor,所以用延迟释放
        if type == .withoutResponse {
            DispatchQueue.global().asyncAfter(deadline: .now() + 0.02) { [weak self] in
                self?.isWriting = false
                self?.pumpWriteQueue(peripheral: p, characteristic: c, type: type)
            }
        }
    }
}

// MARK: - CBCentralManagerDelegate
extension BluetoothManager: CBCentralManagerDelegate {
    
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        onStateChanged?(central.state)
        
        if central.state == .poweredOn {
            // 可按需自动扫描
            // startScan()
        } else {
            // 蓝牙关闭/不可用时要清空状态
            stopScan()
            peripheral = nil
            writeChar = nil
            notifyChar = nil
        }
    }
    
    func centralManager(_ central: CBCentralManager,
                        didDiscover peripheral: CBPeripheral,
                        advertisementData: [String : Any],
                        rssi RSSI: NSNumber) {
        
        // 如果还想按名称做二次过滤
        if let name = peripheral.name, name.hasPrefix(targetNamePrefix) {
            onDiscovered?(peripheral, RSSI)
            connect(peripheral)
            return
        }
        
        // 不按名称过滤也行:只要服务 UUID 已经过滤,通常就很准
        onDiscovered?(peripheral, RSSI)
        connect(peripheral)
    }
    
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        onConnected?(peripheral)
        
        // 连接后发现服务
        peripheral.discoverServices([targetServiceUUID])
    }
    
    func centralManager(_ central: CBCentralManager,
                        didFailToConnect peripheral: CBPeripheral,
                        error: Error?) {
        onDisconnected?(peripheral, error)
    }
    
    func centralManager(_ central: CBCentralManager,
                        didDisconnectPeripheral peripheral: CBPeripheral,
                        error: Error?) {
        onDisconnected?(peripheral, error)
        
        // 可选:重连策略(示例:延迟重连)
        // DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        //     central.connect(peripheral, options: nil)
        // }
    }
}

// MARK: - CBPeripheralDelegate
extension BluetoothManager: CBPeripheralDelegate {
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard error == nil else { return }
        guard let services = peripheral.services else { return }
        
        for s in services {
            if s.uuid == targetServiceUUID {
                peripheral.discoverCharacteristics([writeCharUUID, notifyCharUUID], for: s)
            }
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral,
                    didDiscoverCharacteristicsFor service: CBService,
                    error: Error?) {
        guard error == nil else { return }
        guard let chars = service.characteristics else { return }
        
        for c in chars {
            if c.uuid == writeCharUUID { writeChar = c }
            if c.uuid == notifyCharUUID { notifyChar = c }
        }
        
        // 开启通知(Notify)
        if let n = notifyChar {
            peripheral.setNotifyValue(true, for: n)
        }
        
        // 可选:读取一次初始值
        // if let n = notifyChar { peripheral.readValue(for: n) }
    }
    
    func peripheral(_ peripheral: CBPeripheral,
                    didUpdateNotificationStateFor characteristic: CBCharacteristic,
                    error: Error?) {
        // 通知开关状态回调
    }
    
    func peripheral(_ peripheral: CBPeripheral,
                    didUpdateValueFor characteristic: CBCharacteristic,
                    error: Error?) {
        guard error == nil else { return }
        let data = characteristic.value ?? Data()
        onReceiveData?(data, characteristic)
    }
    
    func peripheral(_ peripheral: CBPeripheral,
                    didWriteValueFor characteristic: CBCharacteristic,
                    error: Error?) {
        // 只有 withResponse 才会进这个回调
        isWriting = false
        if let w = writeChar {
            pumpWriteQueue(peripheral: peripheral, characteristic: w, type: .withResponse)
        }
    }
}

四、调用示例(在 ViewController 里)

final class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let ble = BluetoothManager.shared
        
        ble.onStateChanged = { state in
            print("BLE state:", state.rawValue)
            if state == .poweredOn {
                ble.startScan()
            }
        }
        
        ble.onConnected = { p in
            print("已连接:", p.name ?? "unknown")
        }
        
        ble.onReceiveData = { data, ch in
            let hex = data.map { String(format: "%02x", $0) }.joined()
            print("收到数据((ch.uuid)):", hex)
        }
    }
    
    func sendCommand() {
        // 举例:发送一段 hex 指令
        let hex = "aabbccdd"
        let data = Data(hexString: hex) // 见下方扩展
        BluetoothManager.shared.send(data, withResponse: false)
    }
}

也可以复用我之前写的 ParseDataTool(Swift版)来做 Hex/Data 转换。


五、附:Data 十六进制扩展(可选)

extension Data {
    init(hexString: String) {
        let clean = hexString.replacingOccurrences(of: " ", with: "")
        var data = Data()
        var idx = clean.startIndex
        while idx < clean.endIndex {
            let next = clean.index(idx, offsetBy: 2)
            let byteStr = clean[idx..<next]
            if let b = UInt8(byteStr, radix: 16) {
                data.append(b)
            }
            idx = next
        }
        self = data
    }
}

六、开发注意事项(非常关键)

1)强烈建议:扫描时指定 Service UUID

更快、更省电、更准确
避免扫描全部 nil 会扫到大量无关设备,影响体验

central.scanForPeripherals(withServices: [targetServiceUUID], options: nil)

2)不要用 peripheral.name 当唯一标识

name 可能为空、可能变化。更靠谱的是:

  • 过滤 Service UUID
  • 使用 peripheral.identifier(UUID)做缓存识别(重连)

3)写数据别太快,否则丢包/外设卡死

BLE 写入速度过快常见问题:

  • 外设缓冲区溢出
  • 回包延迟或丢失
  • iOS 侧 write 被吞

建议做写入节流(本文示例已经做了队列 + 20ms 间隔)


4)区分 withResponse / withoutResponse

  • .withResponse:可靠,有回调 didWriteValueFor
  • .withoutResponse:速度快,但无写入确认,建议配合队列节流

实战建议:

  • 协议关键指令用 .withResponse
  • 大数据(如 OTA)用 .withoutResponse + 节流 + 外设 ACK

5)Notify 要记得开启(很多人漏掉)

外设回包多数走 Notify(通知),不打开你永远收不到数据:

peripheral.setNotifyValue(true, for: notifyChar)

6)断线是常态:要做重连策略

断线原因很多:

  • 超距
  • 外设省电休眠
  • 手机锁屏/系统资源回收

建议:

  • didDisconnectPeripheral 里做延迟重连
  • 或 UI 提示用户手动重连
  • 结合 peripheral.identifier 记住上次设备

7)后台蓝牙通信(可选)

如果你需要锁屏/后台持续通信:

  • Xcode → Signing & Capabilities → Background Modes → 勾选 Uses Bluetooth LE accessories
  • 并合理控制扫描/连接行为(后台会更耗电)

8)MTU 与分包问题

  • BLE 默认有效载荷常见为 20 字节(不同设备协商后可能变大)
  • 大数据(日志、图片、OTA)一定要做分包 + 协议确认

最后

本文给出了一套 Swift BLE 连接外设的我开发成熟项目过程中的代码,可直接运用在项目中,覆盖了:

  • 初始化、扫描、连接
  • 服务/特征发现
  • Notify 开启、收包回调
  • 写入(带队列节流)
  • 断开处理与重连扩展

如有写错的地方,敬请指正,相互学习进步,谢谢~

春节期间独立开发者从 0 到 1:呼吸训练 iOS App 的工程化落地

作者 SameX
2026年2月24日 16:33

项目:呼吸视界(iOS 已上架)
技术栈:SwiftUI + SwiftData + StoreKit 2 + WidgetKit + ActivityKit

各位新年快乐,春节期间体验到了人挤人,车挤车,闲来无事撸了个一直想做的APP,分享点技术心得大家共勉。

1. 架构目标:把“训练体验”和“增长闭环”同时做出来

这个项目不是只做一个呼吸动画,而是做一条完整链路:

  • 训练引擎:稳定跑节奏(吸气/停顿/呼气)
  • 多感官反馈:视觉 + 音频 + 触觉一致
  • 习惯闭环:课程进度、训练记录、分享卡片
  • 增长入口:提醒、Widget、Live Activity、深链
  • 商业化:订阅、恢复购买、权益门控

核心分层:

  • 状态中枢:breathing-iOS/breathing/Domain/AppStore.swift
  • 页面编排:breathing-iOS/breathing/UI/RootView.swift
  • 能力引擎:breathing-iOS/breathing/Engines/*
  • 数据模型:breathing-iOS/breathing/Data/*
  • 外部触达:breathing-iOS/breathingWidget/* + BreathingLiveActivityManager

2. 单一状态中枢:AppStore 统一收口

AppStore@MainActor + ObservableObject 统一管理业务状态,避免“每个页面自己存一份状态”。

@MainActor
final class AppStore: ObservableObject {
    @Published var activeMode: BreathingMode
    @Published var activeDuration: Int
    @Published var isPro: Bool
    @Published var settings: AppSettings
    @Published var soundEnabled: Bool
    @Published var soundscapeId: String

    let breathingEngine: BreathingEngine
    let hapticsEngine: HapticsEngine
    private let soundscapePlayer = SoundscapePlayer()
    private let liveActivityManager = BreathingLiveActivityManager()
}

同时把订阅商品 ID 固定在内部,避免散落字符串:

private enum ProProductID {
    static let monthly = "com.xun.breathing.pro.monthly"
    static let yearly = "com.xun.breathing.pro.yearly"
    static let all = [monthly, yearly]
}

收益:UI 层只绑定状态,不再承担复杂业务判断;后续加模式/加权益不会牵一发而动全身。


3. 训练引擎:状态机 + 双 Task 保证节奏稳定

BreathingEngine 的关键是“阶段推进”和“总时长倒计时”分离:

@MainActor
final class BreathingEngine: ObservableObject {
    @Published private(set) var phase: BreathPhase = .ready
    @Published private(set) var isPlaying: Bool = false
    @Published private(set) var timeRemaining: Int

    private var cycleTask: Task<Void, Never>?
    private var countdownTask: Task<Void, Never>?
    private var sessionId = UUID()
}

启动时并行两条异步任务:

func start() {
    guard !isPlaying else { return }
    isPlaying = true
    sessionId = UUID()
    timeRemaining = duration

    runCountdown(sessionId: sessionId)
    switch courseType {
    case .standard:
        runBreathingLoop(sessionId: sessionId)
    case .wimHof(let config):
        runWimHofSession(sessionId: sessionId, config: config)
    }
}

倒计时任务只做一件事:

private func runCountdown(sessionId: UUID) {
    countdownTask?.cancel()
    countdownTask = Task { [weak self] in
        guard let self else { return }
        while !Task.isCancelled {
            try? await Task.sleep(nanoseconds: 1_000_000_000)
            guard sessionId == self.sessionId, self.isPlaying else { return }
            self.timeRemaining = max(0, self.timeRemaining - 1)
            if self.timeRemaining <= 0 {
                self.completeSession()
                return
            }
        }
    }
}

收益:暂停/恢复/切模式时行为稳定,不会出现“相位跳变”或“倒计时错乱”。


4. 音景引擎:缓存 + 淡入淡出,解决听感跳变

音频引擎里最关键是三点:

  • bufferCache:避免每次重新解码 mp3
  • fadeIn/fadeOut:切换音景不突兀
  • updatePlayback:统一播放入口(按 isPlaying/isEnabled 决策)
func updatePlayback(isPlaying: Bool, isEnabled: Bool, soundscapeId: String) {
    guard isEnabled, isPlaying else {
        stop()
        return
    }
    play(soundscapeId)
}

private func loadBuffer(for soundscape: Soundscape) -> AVAudioPCMBuffer? {
    if let cached = bufferCache[soundscape.id] { return cached }
    guard let url = Bundle.main.url(forResource: soundscape.fileName, withExtension: "mp3") else { return nil }
    do {
        let file = try AVAudioFile(forReading: url)
        let frameCount = AVAudioFrameCount(file.length)
        guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) else { return nil }
        try file.read(into: buffer)
        bufferCache[soundscape.id] = buffer
        return buffer
    } catch {
        return nil
    }
}

停止时先淡出再停引擎:

fade(to: 0, duration: fadeOutDuration) { [weak self] in
    self?.stopNow(resetSession: resetSession)
}

5. 通知提醒:权限、频率、撤销一体化

提醒模块用 UNUserNotificationCenter,重点是“配置即覆盖”而不是“叠加创建”。

func configure(enabled: Bool, minutes: Int, frequency: ReminderFrequency) async -> Bool {
    if !enabled {
        cancel()
        return true
    }
    let allowed = await requestAuthorizationIfNeeded()
    guard allowed else {
        cancel()
        return false
    }
    schedule(minutes: minutes, frequency: frequency)
    return true
}

按周频次时生成固定 ID,方便后续精确取消:

let id = "\(weekdayPrefix)\(weekday)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
center.add(request, withCompletionHandler: nil)

6. Live Activity:状态去重,避免无效刷新

Live Activity 不是“每帧都更新”,而是先比对状态,只有变化才推送:

func update(state: BreathingLiveActivityAttributes.ContentState) {
    guard #available(iOS 16.1, *) else { return }
    guard let activity else { return }
    guard state != lastState else { return }
    lastState = state
    Task {
        let content = ActivityContent(state: state, staleDate: nil)
        await activity.update(content)
    }
}

收益:减少无意义更新,降低系统开销。


7. 数据闭环:训练记录 + 课程进度

7.1 会话记录模型(SwiftData)

@Model
final class SessionRecord {
    var id: UUID
    var timestamp: Date
    var modeId: String
    var courseId: String?
    var programId: String?
    var programDay: Int?
    var duration: Int
    var preCheckin: String?
    var postCheckin: String?
}

preCheckin/postCheckin 让“训练前后变化”可追踪,这是后续留存和转化分析的基础字段。

7.2 课程进度推进

static func nextDayIndex(program: BreathingProgram, record: ProgramProgressRecord?) -> Int? {
    let completed = Set(record?.completedDays ?? [])
    for index in program.plan.indices {
        if !completed.contains(index) {
            return index
        }
    }
    return nil
}

这个实现很朴素,但稳定,且便于后续做“断点继续”。


8. Widget 深链:缩短回流路径

Widget 直接绑定深链,用户从桌面可一跳进入训练:

private let quickURL = URL(string: "breathing://start?type=quick")!
private let emergencyURL = URL(string: "breathing://start?type=emergency")!

这比“打开 App -> 选模式 -> 开始”少至少 2 步,对高频场景(焦虑急救/会前调整)很关键。


9. 订阅链路:StoreKit 2 的最小闭环

关键流程:拉商品 -> 发起购买 -> 校验交易 -> finish -> 刷新权益。

func purchaseSelectedProduct() async {
    guard let product = selectedProduct else { return }
    let result = try await product.purchase()
    switch result {
    case .success(let verification):
        let transaction = try checkVerified(verification)
        await transaction.finish()
        await refreshSubscriptionStatus()
    case .pending, .userCancelled:
        break
    @unknown default:
        break
    }
}

恢复购买也单独兜底:

try await StoreKit.AppStore.sync()
await refreshSubscriptionStatus()

10. 工程复盘:最值得复用的 4 个点

  1. 状态收口AppStore 统一管理跨页面状态。
  2. 节奏分治:阶段循环和倒计时分为两条 Task。
  3. 增长内建:提醒/Widget/Live Activity 不是后补功能,而是留存系统。
  4. 数据先行:从第一天就保留训练前后字段,后续分析成本最低。

后记:APP已经上架,某书上反响还不错,赚钱是次要的,主要产品有人用,技术有积累就很开心啦。 体验链接:apps.apple.com/cn/app/%E5%… PS:要兑换码好说,哈哈~

7e175ace-ca50-4f8f-8f0f-fbc0a82eecd7.jpg

cbebea05-dd13-4128-bf5e-06d7dce991d5.jpg

开工第一天,别让AI写的代码触发3.2f封号。

作者 iOS研究院
2026年2月24日 14:17

背景

今天是农历正月初八,春节后的第一个工作日。后台有粉丝留言,迎来的开年的第一记重磅打击3.2f待终止通知。

踩线原因也是老生常谈了,严查分类之隐藏功能问题

中英对照.png

老iOSer对于这种情况已经是见怪不怪了,很多时候并非开发者想做某些Sao操作,实属无奈的多。毕竟,有业务苹果不能正面允许,不得已就采用这种上有政策下有对策的打法

原因分析

通过进一步沟通,层层抽丝剥茧。终于定位到踩到隐藏功能的导火索,在AI加持的情况下使用了非公开的API获取业务层面需要的功能权限。从业务的角度来看功能确实实现了,从苹果监管的角度来看调用了越权的API属性。通过键值对的方式Hook数据结果。

实话讲AI背大锅,对于很多跨行的开发者来说,为了满足公司的开发需求保住饭碗使用AI的方式本身没有问题。关键的问题在于,无法Review AI所编写的代码是否合规

所以,AI本质是一把双刃剑,在提高开发效率的同时,也需要额外考虑风控问题。

隐藏功能

隐藏功能的前身是苹果开发者指南中的-2.3.1条款。

主要意在通过一些动态下发的方式,直接或间接干预苹果审核所看到的内容。将符合苹果审核的内容作为A面,顺利通过审核,提高审核通过率。【俗称的AB面,也叫马甲包】

随着AppStore审核规则的加强,对于隐藏功能的判定不仅仅只是单纯的功能切换,而是上升到更为全面的元数据以及概念层面。

简单来说:

少做不做挂羊头卖狗肉的事情,苹果的算法比开发者想象中更加强大

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

春晚、机器人、AI 与 LLM -- 肘子的 Swift 周报 #124

作者 东坡肘子
2026年2月24日 07:49

issue124.webp

春晚、机器人、AI 与 LLM

作为一个观众数量超十亿的电视节目,央视春晚无疑是极佳的展示平台。今年春晚中,多家中国机器人厂商在不同节目中展示了其产品,其中讨论度最高的当属宇树(Unitree)的人形机器人。在表演环节,多款型号的人形机器人完成了大量较为复杂的武术与动态动作展示。与去年偏静态、偏站桩式的呈现相比,今年的动作复杂度与稳定性确实有明显提升,这一点也得到了全球媒体的关注与报道。

春晚之后,社交媒体上的讨论呈现出明显分化。除了对技术进步的惊叹之外,“预编程”、“没有 AI”、“缺乏实用性”等质疑声音同样不少。这在一定程度上反映了公众对机器人技术复杂度的低估——尤其是对运动控制、实时反馈系统和系统级整合难度的认知不足。

需要澄清的一点是:预训练并不等于“录制-回放”。当前人形机器人在此类表演中的确采用了高度规划的动作流程,这与人类舞者、运动员的训练逻辑有相通之处——大量的离线训练与调试构成了动作的基础,但在实际执行过程中,身体仍需依赖动态平衡与即时修正来应对真实环境的扰动。正是这种容错与实时修复能力,才让人形机器人这个天然不稳定的双足系统得以完成高动态的连续动作。

与此同时,近年来大语言模型(LLM)的爆发,让不少人将 LLM 与 AI 等同起来。事实上,AI 作为一个已有数十年发展的领域,远远不止语言理解这一分支。尤其是面对真实的物理世界时,视觉识别、路径规划、运动控制、强化学习等专用模型在工业与实体系统中的使用量,依然远高于 LLM。在机器人领域,真正决定能力上限的往往是感知系统、控制系统以及低延迟反馈算法,而不是语言推理能力。

即便未来为人形机器人引入更强的"认知能力",更适合的路径也未必是直接接入 LLM,而可能是构建能理解物理规律的世界模型(World Models)与具备低延迟响应能力的控制系统——这两点恰恰是 LLM 的固有短板。具身智能(Embodied AI)的挑战,与纯文本推理存在本质差异。

至于“实用性”的问题,功夫或舞蹈确实难以直接对应现实工作场景。但恰恰是这些对平衡性、协调性与动态响应要求极高的动作,为人形机器人这种高度复杂且不稳定的系统提供了极佳的验证场景。它们更像是工程能力的压力测试,展示的是机械设计、电子控制与算法系统整合的成熟度,而非短期商业落地能力。

我个人对于人形机器人未来的市场规模仍然持审慎态度。技术进步与商业普及之间往往存在不小的鸿沟。但从今年春晚所呈现的进步幅度来看,可以合理判断:在未来十年内,机器人或智能机器以某种形式融入日常工作与生活场景,已不再是科幻想象。无论你是否喜欢“机器人”,技术演进的趋势已经十分明确,我们终将需要与它们共存。

至于“机器人奴役人类”的情景,我暂时并不担心。我更现实的担忧是:如果它们在工作中出现 Bug,给我一拳,我真的挨不住。

本期内容 | 前一期内容 | 全部周报列表

🚀 《肘子的 Swift 周报》

每周为你精选最值得关注的 Swift、SwiftUI 技术动态

近期推荐

如何在不破坏 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 视野

昨天 — 2026年2月23日掘金 iOS

第三十二章 接下来我们开始做`灭菌整板`页面

作者 君赏
2026年2月23日 13:50

image-20211222183930334

新建 SterilizeWholeBoardPage 空页面

class SterilizeWholeBoardPageViewModel: BaseViewModel {
    
}
struct SterilizeWholeBoardPage: View {
    @StateObject private var viewModel = SterilizeWholeBoardPageViewModel()
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            EmptyView()
        }
        .makeToDetailPage()
    }
}

添加 【灭菌批号】【栈版号】【箱号】

image-20211222185405845

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()
            }
        }
        ...
    }
}

image-20211222192408026

添加 【栈板序号】【物料总体积】【箱数】

image-20211222192919873

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))
                }
                ...
            }
        }
        ...
    }
}

image-20211223082530347

使用 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)
    }
}

image-20211223100900788

将【栈板序号】【物料总体积】【箱数】更换为 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)
    }
}

这样的写法可以明确用意。

获取栈板序号

image-20211223113058626

栈板序号的值来源于通过灭菌批号查询

新增 @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 的背景颜色

image-20211223115539590

突然发现,我们的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)
            }
        }
        ...
    }
}

image-20211224105912683

添加 【重置】 【提交】按钮

image-20211224110123490

打印需要调用蓝牙和硬件交互,我们就把打印替换成重置

封装 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()
                        }
                        ...
                    }
                    ...
                }
            }
        }
        ...
    }
}

【提交】灭菌整板

image-20211226172444260

第三十一章 完善箱号列表

作者 君赏
2026年2月23日 13:49

我们已经通过栈版号获取到了箱子列表数据,那么我们用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()
                    }
                }
            }
        }
        ...
    }
}

image-20211222112442651

List 构建的是否存在性能问题?

image-20211222113027064

看了视图,核心还是利用UITableView重用的机制,所以使用List展示很多数据,是会走重用机制的。

设置 List 的 Style

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        ... {
            ... {
                ... {
                List {
                ...
                }
                .listStyle(.plain)
            }
        }
        ...
    }
}

image-20211222143008277

通过 listRowInsets 设置 Row 的间隙

显示出来的间隙,明显和我们BoxDetailView的间隙大很多,为了看一下差距,我们给BoxDetailView设置一个红色背景色。

image-20211222143722283

看起来左右留白多一些,上下留白很少。

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))

                    }

                }
                ...
            }
        }
        ...
    }
}

image-20211222155658090

通过 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))
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222163931659

通过 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)
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222165502211

通过 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)
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222170038600

传递 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)
                            ...
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222183243829

第三十章 接下来我们写首页的功能,首先是我们的`托盘绑定箱号`。

作者 君赏
2026年2月23日 13:49

托盘绑定箱号

创建托盘绑定箱号界面

新建 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: {
            ...
        }
        ...
    }
}

37102565-781C-4B7A-A024-6E871B4AF579-12013-00001DCBE6C95C10

修复返回按钮样式不对

image-20211217083137276

隐藏返回按钮文本

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

image-20211217104349972

目前在 SwiftUI中暂时没有任何方便的方法可以在 NavigationView 进行 Push 跳转隐藏底部的 Tabbar。我们只能在需要隐藏的界面的 onAppearonDisappear去隐藏。

/// ❌ 这样设置是不起作用的
UITabBar.appearance().isHidden = true

我们在运行时候,看一下布局。

image-20211217110816676

我们按照结构找出 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
        }
    }
}

image-20211217114659348

隐藏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 问题

E30B5E5D-03E3-4AB4-9B9E-F1D57042B2AC-6170-0000086B21AA549E

从上面的掩饰发现,第一次是可以正常的进入,点击返回,第二次无法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
        }
        ...
    }
}

2DD48351-20FB-4E98-A12F-93D5DE903641-6170-00000B685153E499

经过重置,第二次Push无法跳转问题解决了。

封装扫描输入组件

image-20211217144859246

接下来我们封装上面的组件,大致的界面构造如下。

image-20211217145319468

新建一个 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()
    }
}

image-20211217152532232

添加栈版号和箱号

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 = ""
}

image-20211217170131720

固定 ScanTextView 的 Title 的宽度

image-20211217170240820

提示语是没有对齐的,因为是自动布局,很难会让自动的对齐,我们需要设置左侧标题固定长度。

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)
                   ...
                }
                ...
            }
        }
        ...
    }
}

箱号详情组件

image-20211217171227535

分析布局如下。

image-20211217173615566

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)
    }
}

image-20211217175610405

制作标题信息组件

我们需要标题和信息上对齐,类似下面的排版方案。

image-20211220090538999

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)
    }
}

image-20211220091532902

将箱号详情标题和描述替换为 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")
            }
        }
        ...
    }
}

image-20211220092135131

调整上下组件的间距

struct BoxDetailView: View {
    var body: some View {
        HStack {
            VStack {
                ...
                Spacer()
                    .frame(height: 7.5)
                ...
            }
            VStack {
                ...
                Spacer()
                    .frame(height: 7.5)
                ...
            }
        }
        ...
    }
}

image-20211220092422575

可手动控制 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)
            }
        }
        ...
    }
}

image-20211221105603227

固定 ScanTextView的高度

image-20211221110011577

经过自动布局之后的ScanTextView的高度达到了65的高度,超出了设计图50的高度,主要是输入框固定了高度,我们将去掉Padding,给ScanTextView设置固定高度为50

struct ScanTextView: View {
    ...
    var body: some View {
        HStack {
            ...
        }
        ...
        .frame(height:50)
    }
}

image-20211221110351933

只增加左右间距

高度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))
    }
}

image-20211221111955278

获取箱号列表

新增 @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()
                            }
                        }
                    ...
                }
                ...
            }
        }
        ...
    }
}

添加或者删除箱号

此时我们的栈板上是没有数据的,需要我们输入箱号进行新增和删除操作。

image-20211221142928191

上图的逻辑都封装在接口里面,所以我们只需要关心输入箱号之后,调用接口即可。

添加新增或者删除箱号逻辑方法

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)
        ...
    }
    ...
}

06AD995C-44B3-4F97-85FA-EB546715CFE9-32874-0000111BEBD404CE

此时我们已经获取到列表了,但是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字符串。

8E0091D0-4423-4821-A20E-60F1D087D5AF-32874-000011FEBD5094D8

但是展示和隐藏十分的快,在显示没有结束之前,被后面获取箱子列表接口在请求完毕之后隐藏了。

image-20211221164303927

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    
    /// 添加或者移除箱号
    func addOrRemoveBox() async {
        ...
        if let message = model.data {
            showHUDMessage(message: message)
        }
    }
}

A25D7D14-4057-4781-80AC-049B8CC6DFD8-32874-0000127C741D4A5D

修复HUD开始显示之前内容的问题

HUD展示逻辑

image-20211221170320889

HUD Message展示逻辑

image-20211221194016155

我们看到在展示文本延时两秒之后,文本没有清空,导致下次请求进行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的文本内容只是一个临时的展示内容,应该在展示完毕重置,所以第一种方案比较好。

6FB09227-0EC0-4CE3-9B88-C3E71BE1A47A-32874-00001AAF68A2AB4A

第二十九章 修复首页 PopMenuView 显示问题

作者 君赏
2026年2月23日 13:48

在首页切换工厂的时候,我们发现了一处严重的UI问题。

image-20211215154246752

本来我们做的PopMenuButton竟然被导航栏遮挡在最下面。出现的原因在于,我们无法确保我们的PopMenuView一定在最外面,因此可能被其他外层遮挡。为了确保PopMenuView一定会在最外层弹出,我们只能弹出一个 UIViewController,这样保证一定出现在最外层。

image-20211215160233695

我们只需要获取到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对应globalpoint

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: {
            ...
        }
        ...
    }
}

修复偏移问题

image-20211216112926889

修改完毕,我们运行之后发现,这偏移的位置和也太远了。为了探明原因,我们修改一下PopMenuContentView背景颜色,看一下问题所在。

struct PopMenuContentView<T:PopMenuItem>: View {
    ...
    var body: some View {
        GeometryReader { geometry in
            ...
        }
        .background(.blue)
    }
    
    ...
}

image-20211216115621948

发现PopMenuContentView是完全铺满的,不是因为安全距离造成的。那是不是偏移量导致的吗?我们去掉offset.

struct PopMenuContentView<T:PopMenuItem>: View {
    ...
    var body: some View {
        GeometryReader { geometry in
            popMenuButton
        }
        .background(.blue)
    }
    
    ...
}

image-20211216133502604

我们去掉offset之后,竟然布局好像从安全距离开始的。我们就忽略掉安全距离,再次试一下。

struct PopMenuContentView<T:PopMenuItem>: View {
    ...
    var body: some View {
        GeometryReader { geometry in
            popMenuButton
        }
        .ignoresSafeArea()
        .background(.blue)
    }
    ...
}

image-20211216133756258

这个就符合我们的预期了。我们将代码恢复,运行,我们的组件已经布局正常了。

image-20211216134027691

此时凭空出现的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()
        }
        ...
    }
}

image-20211216134857931

此时我们的界面看起来好一些,但是还是很丑。

封装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: {
            ...)
        }
        ...
    }
}

发现我们使用起来更加的简单方便。

0D07AB75-0887-484F-8BF7-684E23D93833-12013-000015F807E392BE

修改登录页面选择服务器组件

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
                        }
                    ...
                }
                ...
            }
            ...
        }
        ...
    }
}

7727EAFC-70E9-4001-AA74-A241C40B098A-12013-000016214B3C2D07

第二十八章 重置 ObservableObject 模型数据

作者 君赏
2026年2月23日 13:47

经过通过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)
        }
    }
}

6FDE33D6-DF4B-4834-9416-70881C78607D-2852-0000052D9D15DAC7

当是我运行看到效果达到的时候,我并没有满足当前的解决方案,我觉得通过传递参数这一种有点复杂,并不是我们想要的,后来经过尝试了很多次,终于发现了另外的一种。

通过@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 方法的逻辑

image-20211215101153452

private func getUserConfig() -> UserConfig {
    /// 流程图地址 ![](https://gitee.com/joser_zhang/upic/raw/master/uPic/202112151011488.png)
    /// 如果服务器地址为空 或者 当前登录用户不存在 则返回 [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)
}

但是这个方式还是存在一些问题,可能还存在下面的情况

image-20211215104451184

既然是用户配置,自然和用户强相关的,没有服务器地址用户就不能登录,没有登录就不存在用户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的逻辑。我们假设一下我们我们不修改会造成什么的危害?

image-20211215113925183

红色箭头的逻辑是有问题的,因为覆盖安装,旧版本已经登录情况下,用户操作的所有配置都保存在默认配置下面,是存在问题的。

为了解决旧版本已经登录的情况,我们就修改isLogin的逻辑,让旧版本已经登录的用户保持未登录的状态。

image-20211215134408761

我们将之前通过判断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 {
        ...
    }
}

分别存在两个错误

  • currentAppServeruserConfig之前使用,因为调用方法和变量默认省去了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
}

75AB18D4-F91F-4C66-8988-39546467BB58-2852-0000160BA34BBE50

运行登录,看起来十分的正常。但是我们操作退出的时候发现了竟然无法退出。

A173D48C-59DA-4048-BEF6-9F4DB40921A5-2852-000016289EB2DBED

修复退出登录异常

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: {
            ...
        }

    }
    ...
}

C8D72444-4FAB-4D5F-9FB6-5DE21768F4A5-2852-00001836F3D406E7

第二十七章 UINavigationBarAppearance|Divider

作者 君赏
2026年2月23日 13:46

在我的界面,导航栏和内容视图已经融合在一起了,我们没有办法分清楚。

image-20211210180515722

我们准备让导航条和内容分开,不然这样看起来的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
    }
    
    ...
}

image-20211210180650420

此时我们创建一个默认导航条的配置,可以轻松和内容是如区分。我们设置一下导航条的背景颜色为白色,和我们底部的颜色保持一致。

let appearance = UINavigationBarAppearance()
appearance.backgroundColor = .white

image-20211213105741257

如图所示,我们在最后一行也显示了线,导致界面上十分的丑,我们将界面可以进行配置这条线。

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 {
                ...
            }
        }
        ...
    }
}

image-20211213112110861

我们自动登录的高度明显要高于其他,主要原因我们设置自动布局,并且设置外边距是 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)
    }
}

image-20211213113045069

此时我们的界面已经优化的和设计图差不多了,接下来我们开始优化我们功能。

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_
    }
}

image-20211213171441348

我们要获取用户的配置的时候必须要拿到用户ID,获取用户ID的时候必须拿到用户配置。这个似乎陷入了死循环中,我们看下面的流程。

image-20211213171843676

我们在整个流程中发现,只有当用户没有登录,重新登录可以拿到用户ID获取到用户配置,才能打破这个死循环。但是在已经登录的流程,想要获取到用户配置就是一个死循环。

想要打破这个循环,就要改变上面的逻辑。

image-20211213172240351

我们将判断是否登录换成了判断本地是否有用户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的逻辑。

image-20211214162930988

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监听值更新的。我们可以在 currentUserIddidSet中去操作设置新的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

作者 君赏
2026年2月23日 13:46

新增 Profile 环境

到此我们已经做完了登录页面 首页 我的页面,但是还是存在一些问题需要进行优化,比如登录页面在第一次安装App的时候,默认没有服务器地址,需要用户手动的选择一个,这样就让用户可能多一次操作,体验不是很好。

为了可以优化体验,我们觉得第一次安装App的时候给服务器地址增加一项默认值,但是默认值设置为那一个?

image-20211208172545636

我们目前可以获取是否是 DEBUG编译还是 RELEASE编译,但是无法区分是PROFILE编译。那么我们基于DEBUG环境新增一套环境作为我们PROFILE环境。

image-20211208173338173

这样我们已经可以区分是否是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十分的困难。不过我们可以通过 focusedModify获取到输入框获取到焦点。

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)
    }
}

553E5A26-8F29-42E9-AE0A-CD5323313394-13561-00000AFEB2A5D84B

我们运行看了一下效果,发现输入框的高度在获取焦点和失去焦点相互跳跃。原来在于我们给最后 Clear 按钮添加 Padding的时候,上下也添加了 Padding,导致 Clear出现的时候比 输入框的高度还搞 就自动拉伸了整个控件的高度。

为了解决高度跳动的问题,我们将输入框的高度固定在33,因为在UIKitUITextField的默认高度就是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)
    }
    ...
}

5796E81B-65F3-4BF2-B432-85B1575E39ED-13561-00000B6F570E1035

此时我们的输入框的高度不会来回的跳跃了。

虽然我们用户名和密码可以一键的清空,但是我们我们修改用户名的时候,密码按道理说应该被清空。我们不考虑不同账号同一个密码的情况,这种极少存在。

那么我们监听到用户名输入框内容修改的时候,我们就清空密码输入框。

class LoginPageViewModel: BaseViewModel {    
    ...
    /// 监听用户名输入
    private var userNameCancellabel:AnyCancellable?
    
    override init() {
        ...
        userNameCancellabel = $userName.sink(receiveValue: {[weak self] _ in
            guard let self = self else {return}
            self.password = ""
        })
    }
    
    ...
}

image-20220104150820876

我们运行发现,我们本来记住密码的功能失效了,密码被清空了。不明白为什么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 = ""
        })
    }
    ...
}

CEE35567-50CF-4C8B-8076-E51B41E28BDD-6495-00000700DF581C6D

我们输入框在被用户变更之后已经可以正常的删除密码,但是我们的登录按钮在用户名和密码都为空的情况下,竟然依然可以点击操作,这是不合理的。

我们希望在用户名和密码没有输入的情况下,按钮的背景颜色灰色看起来不可点击,当用户输入用户名和密码之后,登录按钮变亮可以点击。

image-20211209161725362

在我们封装登录按钮的时候我们无法感知外界对于按钮的因素变化,现在只需要判断用户名和密码是否存在,后面可能会需要判断用户名的组成格式是否正确。

我们修改一下流程图。

这样我们登录按钮只需要关心外面传入的是否可以点击控制自己的状态。

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
    }
}

B66B5806-C0B3-4C13-AD26-B5FD3495B9D0-6495-00000B49C1511169

第二十五章 完善登录逻辑

作者 君赏
2026年2月23日 13:45

实现自动登录

接下来我们需要做 `自动登陆功能,自动登陆就是登陆之后,下次启动开启状态下,直接进入首页。关闭情况下,则进入登陆页面。

image-20211208090638855

@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()
    }
}

但是我们的判断逻辑依然十分的复杂,我们可以变更一下流程图。

image-20211208091618692

我们将判断的逻辑封装成一个方法,这样虽然看起来没啥变化,但是对于页面处理逻辑清晰。

@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 默认关闭的。经过思考,我们上面的逻辑是有问题的,需要修改一些逻辑。

image-20211208112523686

1 当App全新未安装的时候(红线代表逻辑走向)

image-20211208112559886

2 当执行登陆完毕之后

image-20211208112735217

3 第二次启动 App已经登录过 但是没有开启自动登录

image-20211208112839580

4 App启动 App已经登录过,开启了自动登录

image-20211208113106569

我们按照流程图写一下代码

@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
        }
        ...
    }
}

image-20211208115656854

接下来我们需要获取版本号和 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: {
            ...
        }

    }
    
    ...
}

9DA7C7E9-5D85-4341-94BF-99C27531E814-2354-00000163CADE1649

研究界面的初始化和重建

但是我们重新进来还是在我的界面,既然重新登录,我认为就应该回到首页。我们在研究生命周期时候发现下面的打印。

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为单位。

image-20211208145421464

我们通过 @State 将数结构细化一点

image-20211208151019693

从首页切换到我的页面,为啥切换到我的页面会打印这么多次 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, 而 MyPageNavigationView 是加在 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不会随着初始化的,导致我们重新登陆完毕,展示给我们的是我的界面的问题。

因为 @StateTabPage私有的,所以我们在我的页面退出登录也无法操作 TabPagecurrentTabIndex。目前想到了两种方案,第一种采用通知的形式,第二种采用@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

作者 君赏
2026年2月23日 13:44

选择车间功能做完之后,我们接下来开始做产线的功能。

image-20211206160005636转存失败,建议直接上传图片文件

但是产线的功能来源于车间,意思当车间更换之后,我们的产线就要发生变更。那么我们就要监听AppConfigworkShopCode 值发生改变,我们请求产线的数据。

但是不幸的是,我们的 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
    }
    ...
}

我们根据切换车间,就重新获取新的产线,设置新的产线。

image-20211207140305370转存失败,建议直接上传图片文件

为了可以获取到之前保存的产线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
        })
    }
    ...
}

我们这样写逻辑也没有问题,但是自信的一想,最后一个获取当前选中模型赋值操作之后再次更新了 AppConfigproductLineCode的值。

image-20211207144826949转存失败,建议直接上传图片文件

此时我们有四条线可以更新产线的code,灰色区域三种只存在一种可能,加上外面可以更新产线code。整个流程下面会因为设置当前产线模型多了一次更新。

虽然性能方面不会产生大的影响,但是后续如果其他地方监听产线变更做一些逻辑,就会导致问题出现。我们修改一下逻辑图如下所示。

image-20211207151724845转存失败,建议直接上传图片文件

这样我们就还是三种只存在一种可能设置产线 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

image-20211207161639977转存失败,建议直接上传图片文件

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 默认动画时间

作者 君赏
2026年2月23日 13:43

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
                    }
                }
        }
    }
}

Kapture 2021-12-06 at 11.32.36

我们通过 UIViewController 进行弹出,我们的动画终于正常了。但是消失呢?因为通过 取消和确定的按钮都能进行取消。我们简单的画一下功能流程图。

image-20211206115250607

对于消失我们就需要先让 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) {
                    ...
                }
            }
            ...
        }
        ...
    }
}

Kapture 2021-12-06 at 14.37.31

默认动画时间从 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()
        }
    }
}

Kapture 2021-12-06 at 14.44.34

这样看起来动画正常了,偷偷改了默认动画时间,有点坑。

此时我们封装的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]
    }
    ...
}

Kapture 2021-12-06 at 15.30.45

但是选择完毕之后,我们的界面没有更新,因为我们开始用到的是 AppConfig的缓存的数据。当 currentWorkshop 值改变的时候,我们改变一下 AppConfig.share.workShopCode 的值。

class MyPageViewModel: BaseViewModel {
    ...
    /// 当前选中的车间
    @Published var currentWorkshop:GetAllWorkshopResponse? {
        didSet {
            AppConfig.share.workShopCode = currentWorkshop?.workshopCode
        }
    }
    ...
}

这样我们选择车间功能就封装完毕了。

第二十二章 onAppear|DataPickerView

作者 君赏
2026年2月23日 13:42

获取当前工厂车间列表

这一章我们来给我的界面的数据写数据获取的实现和界面的交互。

image-20211201095644549

对于显示当前选择的生产车间的,我们先是要获取到当前工厂可用的车间列表。

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的判断逻辑。

image-20211201115230300

我们运行发现我的页面没有导航栏了,我们添加一个导航栏。

struct TabPage: View {
    ...
    var body: some View {
        TabView(selection:$currentTabIndex) {
            ...
            NavigationView {
                MyPage()
            }
               ...
        }
        ...
    }
}

image-20211201115532461

有了导航条,但是和下面的内容串起来很难受,不过我们暂时先不管。我们发现一个大问题,就是我们的车间显示是下面的样子。

image-20211201115708387

我们车间列表已经有数据,最少也是显示一条默认的,不可能存在请选择车间提示。研究了一下逻辑发现,当之前选择的车间在最新列表存在,就不会重新赋值,导致就无法通知进行更新。

class MyPageViewModel: BaseViewModel {
    ...
    private func getAllWorkShop() async {
        ...
        if let workShopCode = AppConfig.share.workShopCode {
            ...
            AppConfig.share.workShopCode = workShopCode
        } else {
            ...
        }
    }
    
    ...
}

image-20211201135659955

Sheet 弹窗

我们默认选择的车间已经可以显示出来了,但是人工没法操作,接下来我们封装操作的弹框。

image-20211201140241449

我们可以将视图分成下面组成方式。

image-20211201144151134

我们只需要将蓝色区域底部对齐即可。

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)
    }
}

image-20211201150507004

但是SwiftUIiOS的表现已经不是 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}
}

image-20211201163205763

选中默认的灰色的遮罩暂时没有找到可以修改的方法,对于DataPicker暂时就这样。

struct PickerSheet: View {
    ...
    var body: some View {
        VStack {
            ...
            DataPickerView(items: [
                "1",
                "2"
            ])
            ...
          Spacer()
                .frame(height:20)
        }
        ...
    }
}

image-20211201163637849

我们将 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))
    }
}

image-20211201171608227

界面突然的出现有点不自然,我们添加一个从底部弹出的动画。

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: {
                   ...
                }
            }
            ...
        }
        ...
    }
}

Kapture 2021-12-02 at 17.55.18

我们来实现一下点击车间,弹出所有可以选择的车间列表。

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)
    }
    
    ...
}

Kapture 2021-12-02 at 18.09.34

我们发现我们的弹出试图被上层的TabPage遮挡了。如果想要在最外层。那么这个控件就要注入在最外层才可以。但是数据怎么传递到最外层呢?

这个方案实现起来难度很大,我们用系统的 fullScreenCover 来实现,但是最终的效果是下面的效果。

Kapture 2021-12-03 at 11.06.19

黑色透明背景也是一起跟着动的,就这一点不满足我们的需求。我们能否通过 UIViewController之前的一套做出来呢?

第二十一章 @ViewBuilder默认实现|Toggle|我的页面封装

作者 君赏
2026年2月23日 13:41

首页的界面基本做完了,功能也挺简单,跳转到对应界面即可。我们就先做一下我的页面的内容,内容也不是很多。

image-20220104142656114

我的页面是一个配置和显示的功能也不是很复杂,但是界面也需要标题栏和灰色的背景试图。但是我们就需要将首页的代码复制一份过来吗?在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()
        }
    }
}

image-20211130162551419

我的页面基本全是这种左右对齐的控件,我们先制作这个控件。

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)
    }
}

image-20211130173858393

我们进行提炼封装,方便后面使用。

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
        }
        ...
    }
}

我们使用上面的组件来给我的页面绘制下面的界面

image-20220104142759957

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 ?? "")
                ...
        }
    }
}

接下来我们制作下面的视图

image-20211130193208936

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))
            }
        }
    }
}

image-20211130193636926

我们发现 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)
    }
}

接下来我们做产线界面

image-20211130195751061

我们发现和刚才做的车间的界面一模一样,我们可以先将车间的进行提炼。

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: "深圳车间")
    }
}

image-20211201084936232

struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的",
                        viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                productLineCell()
                ...
            }
        }
    }
    
    ...
    private func productLineCell() -> some View {
        MyDetailCellContentView(title: "产线", detail: "生产线_yk")
    }
}

image-20211201085424077

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")
    }
}

image-20211201085803791

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)
        }
    }
}

image-20211201090524227

显示当前版本,这个可以将显示名称的提炼共用一套。

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)")
    }
}

image-20211201091936577

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

作者 君赏
2026年2月23日 13:40

为了让选中工厂之后可以显示我们工厂的名称,我们修改代码如下。

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
        }
    }
}

接下来我们就要编写首页功能组件了。

image-20211126154503612

首页布局

我们发现一个功能组件大概有这样的特征。

  • 高度随着组件数量变化
  • 周围有圆角
  • 左侧按钮垂直居左并各自居中对齐
  • 中间居中
  • 右侧按钮垂直巨右并且各自居中对齐

我们画一下模板就清楚了。

image-20211126160425834

我们讲一个功能模块按照左侧功能区域,中间功能区域,和右侧功能区域进行布局。如果按照一行一行的布局按钮,会导致和下面的组件无法对齐。

如果直接使用 GridView,感觉也是不行,他们又不是均匀分布的,我觉得目前可行的布局方案就是按照模板进行布局,后续遇到问题再解决。

我们先来制作首页功能按钮

image-20211126162706988

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")))
        }
    }
}

image-20211126163703347

因为图标和文本是动态,我们修改代码支持动态生成。

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: "灭菌")
        }
    }
}

image-20211126165217186

我们不确定我们一列到底显示多少个,所以我们需要动态的进行配置。

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)
    }
}

image-20211126171837270

总是感觉这界面有点乖乖的,和我们设计图一点都不搭。我们给 ActionView 添加一个背景颜色看一下。

struct ActionView: View {
    ...
    var body: some View {
        VStack {
            ...
        }
        .background(.red)
    }
}

image-20211126172229456

我们中间功能区域没有宽度没有完全的充满,我们先设置一下。

struct ActionCardView: View {
    var body: some View {
        VStack {
            ...
            HStack() {
                ...
            }
            .frame(maxWidth:.infinity)
        }
        ...
    }
}

image-20211126172511729

组件最大宽度已经发生了变化,但是三个没有充满,我们需要在组件的中间添加Spacer

struct ActionCardView: View {
    var body: some View {
        VStack {
            ...
            HStack() {
                ActionView(actionItems: [
                    ...
                ])
                Spacer()
                ActionView(actionItems: [
                    ...
                ])
                Spacer()
                ActionView(actionItems: [
                    ...
                ])
            }
            ...
        }
        ...
    }
}

image-20211126172758955

此时看起来好多了,但是中间的间隙是平分的,按照中间视图居中原则,当左侧和右侧视图宽度一致,那么间隙才可能宽度相等。

此时左侧和右侧的宽度不等,那么此时平分的话,中间视图一定偏右侧了。

那么我们就需要计算 左侧视图宽度,中间视图宽度,右侧视图宽度,总宽度。

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
    }
}

我们通过设置计算出当第二个试图居中显示,第一个和第三个分别居左和居右的时候,Spacer1Spacer2的宽度,来达到居中的目的。间隙不可能存在负数,如果存在就是重叠了,这在显示上面是不允许的。

image-20211129105113162

此时布局已经分别居左 居中和居右显示了。从目前来看,的确没什么问题,但是我们如果按钮的标题十分的长,是怎么样的一个显示呢?

image-20211129105408292

虽然按钮分组项目没有影响,但是按钮标题的环境导致横向的没有对齐,十分的难看,我们设置一下按钮的标题最大智能显示一行。

struct ActionButton: View {
...
    var body: some View {
        VStack {
            ...
            Text(title)
                ...
                .lineLimit(1)
        }
    }
}

image-20211129105646864

这样看来感觉正常了。为了将功能模块可以一自定义的新增和删除,我们需要对于 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
    }
}

image-20211130091041655

看起来我们已经提炼完毕了,但是目前我们的数据是对称的,因为是配置的,所以存在多多稍稍的情况。我们去掉两个看一下情况。

image-20211130091220706

缺少之后我们的按钮瞬间就乱了顺序,我们设置顶部对齐。

struct ActionCardView: View {
    ...
    var body: some View {
        VStack {
            ...
            HStack(alignment:.top) {
                ...
            }
            ...
        }
        ...
    }
    
    ...
}

image-20211130091442627

当我只剩下三四个功能的时候,竟然之前的布局不工作了,我干脆就让三等分,左侧就设置居左,中间的就居中,右侧就居右显示。

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)
            }
            ...
        }
        ...
    }
    
    ...
}

image-20211130100905583

我们把生产执行的功能添加到首页里面。

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()
                    }
                }
            }
        }
        ...
    }
    
    ...
}

image-20211130102607315

第十九章 TabView|accentColor|AnyView|NavigationView|navigationTitle|navigationBarTit

作者 君赏
2026年2月23日 13:36

用户登录之后,就可以进入首页了,我们看一下首页的 UI的样子。

image-20211124173333111

我们先创建一个 HomePage

image-20211124173511480

我们在入口修改逻辑,支持登录完毕进入首页。

image-20211124174225448

image-20211124174240112

TabView 创建 TabBar

我们登录完毕,或者下次启动就进入了首页了。我们首页底部是有 Tab 的,我们需要用到 TabView

我们创建一个 TabPage,用户显示我们首页底部的 Tab

image-20211124181149736

我们修改一下代码,将我们的 HomePage 添加进去。

image-20211125080109642

image-20211125080220365

这显示的效果明显不是我们需要的效果,而且文本怎么变成蓝色了?我们需要的是下面的效果

image-20211125080508984

accentColor设置 TabBar 选中颜色

我们尝试一下设置一下文本的前景色。

image-20211125080800417

但是没有任何的效果,这时候我们需要谷歌一下资料是什么原因了。发现网上一些文章答案已经不能用了,但是 accentColor 这个还是有效果的,但就是废弃了。

image-20211125082732340

image-20211125082750634

image-20211125082457016

需要用最新的 tint(_:),如果想更改默认未选中 item 的颜色,需要通过下面代码设置。

image-20211125083803216

尝试封装 TabView

为了我们的 TabItem 可以方便的进行设置,我们决定封装一下我们 TabView

image-20211125084535863

我们如果封装,需要用户提供试图内容,未选中的图标,选中图标,选中的颜色,未选中的颜色,还有当前选中的索引。

image-20211125085531525

我们需要用户传入一个 TabItem 的数组,我们通过数组进行创建 TabViewitem

image-20211125085908959

但是我们的范型的结构体无法放在数组里面。那么,我们可以将范型设置为 AnyView,这样报错解决了,但是在SwiftUI中最好不要用到 AnyView,这会导致系统无法推断最外层结构,从而无法优化Diff算法,优化性能。

走到这一步,我们发现还是不要封装为好,毕竟超过5个的tabItem就已经少之又少。

我们重新修改一下 TabPage的代码。

image-20211125093235601

image-20211125093332975

image-20211125093436760

image-20211125093459780

⚠️我们使用最新的 .tint(\_:) 会经常不起效果,但是换成 .accentColor(_:)就可以。

对于 TabView 我就先到此为止了,目前也是达到我们的效果,接下来我们开始做我们首页的逻辑。

image-20211125094058101

NavigationView 使用导航

首页的头部是一个导航条,并且左侧有一个进行选择的选择框。对于导航,我们需要用到 NavigationView

image-20211125094315054

.navigationTitle 设置导航标题

但是我们怎么设置导航标题呢?我们可以在任何子组件通过 .navigationTitle进行设置。

image-20211125094511501

image-20211125094547006

.navigationBarTitleDisplayMode 设置导航样式

但是我们的导航显示是默认的大标题,是符合 iOS新版本的系统风格一样。不过我们可以通过.navigationBarTitleDisplayMode进行设置导航标题的显示模式。

image-20211125094912042

image-20211125094929459

.toolbar 添加导航按钮

此时我们的导航的标题已经显示正常了。但是我们工厂选择的组件怎么添加到首页左侧的位置呢?经过谷歌之后,我们发现可以通过.toolbar的方法轻松的添加左侧和右侧的视图。

image-20211125112305287

image-20211125112323176

我们将添加导航的代码提炼出来,并且设置页面背景颜色为淡灰色。

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,

此时我们工厂选择再也不报错误了,可以正常的显示出来了。

image-20211126140408654

第十八章 封装HUD和完善登录界面逻辑

作者 君赏
2026年2月23日 13:36

我们几乎在 LoginPageViewModel 添加了大量的代码,才实现了请求展示 HUD,请求完毕展示信息之后 2 秒自动消失。

我们需要每个界面都要写这么多的代码吗?我们可以考虑进行封装,那么我们可以将 @MainActor 和 相关的 HUD的属性和逻辑转移到 BaseViewModel 里面。

image-20211124144747582

image-20211124144901361

我们进行登陆的方法的代码依然没有减少,我觉得这样的代码量还是很多。我们可以精简一下,请求的时候可以根据我们传入的参数自动显示 HUD,和自动的展示 错误的提示。

正确的提示交给使用者,这样精简之后,使用起来岂不是更加的简单。

image-20211124145535053

image-20211124145706324

这样我们使用起来,代码量就变得很简单了,而且如果中间多次请求,只需要在最后关闭 HUD 即可。

我们思考一下,我们每一个界面都需要设置下面代码来支持 HUD的显示吗?

image-20211124145905712

我们可以使用继承方式,但是遗憾的是 Struct 不支持继承。我们用协议实现怎么样呢?

image-20211124152311934

我们将所有单独的页面需要实现我们新增的扩展方法。

image-20211124152413428

虽然也代码量也没有省多少,但是不需要传递那么多的参数,从而后面的改动不会影响外层的使用。

我们登陆不可能这么的逻辑代码不可能这么的简单,如果用户没有输入用户名和密码,自然不需要进行请求,浪费网络资源。

那么对于没有用户名和密码输入,我们则进行提示用户。

image-20211124153247867

我们发现当我们直接点击登录按钮的时候,界面没有任何的提示。这是因为我们当时设置了只有 isAnimating = true 才会展示 HUDView,我们修改一下代码。

image-20211124154457551

这样就完成了如果用户名和密码没有输入就提示用户输入,之后进行登录就加载 HUD,请求完毕展示信息。

我们登陆完毕之后,如果用户开启了记住密码,我们需要将用户的用户名和密码保存下来,下次不需要用户输入用户名和密码。

那么我们需要用到 @AppStorage,但是我们直接修改成下面代码,会有什么问题呢?

image-20211124155239898

这样就算我们开启记住密码,系统已经将用户名和密码保存下来了。我们只能放在AppConfig里面,当开启就设置 AppConfig 的用户名和密码,进入登陆页面就读取 AppConfig 之前保存的用户名和密码。

image-20211124155545184

我们在 LoginPageViewModel 的登录方法里面,当登录成功并且开启保存将用户名和密码进行保存。

image-20211124155821597

我们在 LoginPageViewModel 的初始化方法里面,将之前保存的 用户名和密码进行设置。

image-20211124155950487

我们登录完毕获取到的 gatewayUserName 相当于 JWT,用于后续需要用户的接口,那么我们也保存在 AppConfig 里面。

image-20211124160557975

image-20211124160641694

到此为止,我们终于完成了 LoginPage 的交互和逻辑。

❌
❌