用 SwiftUI 打造“会长大”的组件 —— 从一次性 Alert 到可扩展设计系统

原文链接

为什么旧写法撑不过三次迭代?

先来看一个"经典"写法

swift 复制代码
Alert(
    title: "Title",
    message: "Description",
    type: .info,
    showBorder: true,
    isDisabled: false,
    primaryButtonTitle: "OK",
    secondaryButtonTitle: "Cancel",
    primaryAction: { /* ... */ },
    secondaryAction: { /* ... */ }
)

痛点一句话总结:初始化即地狱。

• 参数爆炸,阅读困难

• 布局/样式/行为耦合,一改全改

• 无法注入自定义内容,复用性 ≈ 0

目标:像原生一样的 SwiftUI 组件

我们想要的最终形态:

swift 复制代码
AlertView(title: "...", message: "...") {
    AnyViewBuilder Content
}
.showBorder(true)
.disabled(isLoading)

为此,需要遵循 4 个关键词:

  1. Familiar APIs -- 看起来像 SwiftUI 自带的
  2. Composability -- 任意组合内容
  3. Scalability -- 业务扩张不炸窝
  4. Accessibility -- 无障碍不打补丁

三步重构法

Step 1:只保留「必须参数」

swift 复制代码
public struct AlertView: View {
    private let title: String
    private let message: String
    
    public init(title: String, message: String) {
        self.title = title
        self.message = message
    }
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(title).font(.headline)
            Text(message).font(.subheadline)
        }
        .padding()
    }
}

经验:先把最常用、不可省略的参数放进 init,其余全部踢出去。这一步就能干掉 70% 的参数。

Step 2:用 @ViewBuilder 把"内容"交出去

swift 复制代码
public struct AlertView<Footer: View>: View {
    private let title: String
    private let message: String
    private let footer: Footer
    
    public init(
        title: String,
        message: String,
        @ViewBuilder footer: () -> Footer
    ) {
        self.title = title
        self.message = message
        self.footer = footer()
    }
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(title).font(.headline)
            Text(message).font(.subheadline)
            footer.padding(.top, 25)
        }
        .padding()
    }
}

使用:

swift 复制代码
AlertView(title: "提示", message: "确定删除吗?") {
    HStack {
        Button("取消", role: .cancel) {}
        Button("删除", role: .destructive) {}
    }
}

Step 3:样式/行为用 环境值 + 自定义修饰符

我们想让边框可开关,但又不想回到"参数爆炸"。

swift 复制代码
struct ShowBorderKey: EnvironmentKey {
    static let defaultValue = false
}

extension EnvironmentValues {
    var showBorder: Bool {
        get { self[ShowBorderKey.self] }
        set { self[ShowBorderKey.self] = newValue }
    }
}

extension View {
    public func showBorder(_ value: Bool) -> some View {
        environment(\.showBorder, value)
    }
}

在 AlertView 内部读取

@Environment(\.showBorder) private var showBorder

swift 复制代码
// ...
.overlay(
    RoundedRectangle(cornerRadius: 12)
        .stroke(Color.accentColor, lineWidth: showBorder ? 1 : 0)
)

至此,API 回归简洁:

swift 复制代码
AlertView(...) { ... }
    .showBorder(true)

进阶:用 @resultBuilder 做「有约束的自由」

当设计规范新增"免责声明 + 倒计时"组合时,与其疯狂加 init,不如定义一个 InfoSectionBuilder:

swift 复制代码
@resultBuilder
public struct InfoSectionBuilder {
    public static func buildBlock(_ disclaimer: Text) -> some View {
        disclaimer.disclaimerStyle()
    }
    public static func buildBlock(_ timer: TimerView) -> some View {
        timer
    }
    public static func buildBlock(
        _ disclaimer: Text,
        _ timer: TimerView
    ) -> some View {
        VStack(alignment: .leading, spacing: 12) {
            disclaimer.disclaimerStyle()
            timer
        }
    }
}

把 AlertView 再升级一次:

swift 复制代码
public struct AlertView<Info: View, Footer: View>: View {
    private let title, message: String
    private let infoSection: Info
    private let footer: Footer
    
    public init(
        title: String,
        message: String,
        @InfoSectionBuilder infoSection: () -> Info,
        @ViewBuilder footer: () -> Footer
    ) {
        self.title = title
        self.message = message
        self.infoSection = infoSection()
        self.footer = footer()
    }
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(title).font(.headline)
            Text(message).font(.subheadline)
            infoSection.padding(.top, 16)
            footer.padding(.top, 25)
        }
        .padding()
    }
}

用法:

swift 复制代码
AlertView(
    title: "删除账户",
    message: "此操作不可撤销",
    infoSection: {
        Text("余额将在 24 小时内退回")
        TimerView(targetDate: .now + 100)
    },
    footer: {
        Button("确认删除", role: .destructive) {}
    }
)

无障碍:组件方 + 使用方 共同责任

组件内部负责结构级:

swift 复制代码
.accessibilityElement(children: .combine)
.accessibilityLabel("\(type.rawValue) alert: \(title). \(message)")
.accessibilityAddTraits(.isModal)

使用方负责内容级:

swift 复制代码
Button("延长会话") {}
    .accessibilityHint("延长 30 分钟")
    .accessibilityAction(named: "延长会话") { // 实际逻辑 }

写在最后的 checklist

维度 ✅ 自检问题
初始化 是否只有"最少必要参数"?
可组合 是否使用 @ViewBuilder / @resultBuilder
样式扩展 是否通过 EnvironmentKey + 自定义修饰符?
无障碍 结构 + 内容 是否都提供了 label / hint / action?
向后兼容 新增需求是否只"加 Builder 方法"而不是"改 init"?

源码仓库

所有示例已整理到 GitHub(非官方镜像,可直接跑 playground): github.com/muhammadosa...

当你用 .disabled(true) 把一整块区域关掉,子组件自动变灰、按钮自动失效 ------ 这种「像原生」的体验,正是可扩展设计系统给人的最大安全感。

相关推荐
大熊猫侯佩1 天前
雪山飞狐之 Swift 6.2 并发秘典:@concurrent 的江湖往事
swiftui·swift·apple
胎粉仔3 天前
Objective-C —— APIs declaration 自定义
ios·objective-c·swift
用户093 天前
Swift Concurrency 中的 Threads 与 Tasks
ios·swiftui·swift
低调小一3 天前
双端 FPS 全景解析:Android 与 iOS 的渲染机制、监控与优化
android·ios·kotlin·swift·fps
用户093 天前
更现代、更安全:Swift Synchronization 框架与 Mutex 锁
ios·面试·swift
大熊猫侯佩6 天前
鹿鼎记豪侠传:Rust 重塑 iOS 江湖(下)
rust·objective-c·swift
大熊猫侯佩6 天前
鹿鼎记豪侠传:Rust 重塑 iOS 江湖(上)
rust·objective-c·swift
用户098 天前
如何避免写垃圾代码:iOS开发篇
ios·swiftui·swift
HarderCoder8 天前
Swift 语法速通:iOS 开发必会的 8 大核心概念(图解+类比)
swift