构建更复杂 UI

ColorPicker

ColorPicker 是 SwiftUI 提供的颜色选择器,用来让用户选择颜色,并绑定到一个 Color 类型变量。

它相当于:HTML:<input type="color">

最简单的例子

import SwiftUI

struct ContentView: View {

    @State private var color: Color = .blue

    var body: some View {
        VStack {

            Circle()
                .fill(color)
                .frame(width: 150, height: 150)

            ColorPicker("选择颜色", selection: $color, supportsOpacity: true)
                .labelsHidden()
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

综合 Demo

import SwiftUI

struct ContentView: View {

    @State private var textColor: Color = .white
    @State private var backgroundColor: Color = .blue
    @State private var circleColor: Color = .orange

    var body: some View {

        ZStack {

            backgroundColor
                .ignoresSafeArea()

            VStack(spacing: 30) {

                Text("ColorPicker Demo")
                    .font(.largeTitle)
                    .bold()
                    .foregroundStyle(textColor)

                Circle()
                    .fill(circleColor)
                    .frame(width: 120, height: 120)

                Form {
                    ColorPicker("文字颜色", selection: $textColor)

                    ColorPicker("背景颜色", selection: $backgroundColor)

                    ColorPicker(
                        "圆形颜色",
                        selection: $circleColor,
                        supportsOpacity: false
                    )
                }
            }
            .padding()
        }
    }
}

#Preview {
    ContentView()
}

Image

显示 Asset Catalog 图片

项目中放入 Assets.xcassets

Image("cat")
// 对应 Assets.xcassets cat
// 例如
struct ContentView: View {
    var body: some View {
        Image("cat")
    }
}

显示 SF Symbols

Apple 内置了几千个图标,下面这些都是矢量图标

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "heart.fill")
            Image(systemName: "trash")
            Image(systemName: "folder")
            Image(systemName: "person.fill")
            Image(systemName: "wifi")
        }
    }
}

#Preview {
    ContentView()
}

调整大小 .resizable()

如果图片很大,SwiftUI 会按原尺寸显示,通常需要 只有调用 .resizable() 图片才能改变大小。

Image("cat")
    .resizable()

调整大小 frame

这样调整会拉伸

Image("cat")
    .resizable()
    .frame(width: 200, height: 150)

保持比例 scaledToFit()

保持比例完整显示,其实最好不要定死的 大小,而是使用 max-width,min-height 之类的。

Image("cat")
    .resizable()
    .scaledToFit()
    .frame(width: 250)

.scaledToFill()

铺满

Image("cat")
    .resizable()
    .scaledToFill()
    .frame(width: 250, height: 200)

图片会超出,需要 .clipped()

Image("cat")
    .resizable()
    .scaledToFill()
    .frame(width: 250, height: 200)
    .clipped()

设置颜色

SF Symbols 支持 tint。

Image(systemName: "heart.fill")
    .foregroundStyle(.red)
// 如 .foregroundStyle(.pink)

// 多颜色
Image(systemName: "person.crop.circle.badge.plus")
    .foregroundStyle(.blue, .green)

SF Symbols font 控制大小

SF Symbols 实际上会跟随字体大小

Image(systemName: "star.fill")
    .font(.largeTitle)
// 或
Image(systemName: "bolt.fill")
    .font(.system(size: 80))

渲染模式

默认,采用系统默认颜色

Image(systemName: "star.fill")

template 模版模式,所有非透明像素都会变成蓝色

Image("logo")
    .renderingMode(.template)
    .foregroundStyle(.blue)

original 保持原图颜色

Image("logo")
    .renderingMode(.original)

opacity 透明图设置

Image("cat")
    .opacity(0.5)

圆角

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            AsyncImage(url: URL(string: "https://picsum.photos/400/300")) { image in
                image
                    .resizable()
                    .scaledToFit()
                    .clipShape(RoundedRectangle(cornerRadius: 20))
            } placeholder: {
                ProgressView() // 加载中的骨架屏
            }
            .frame(width: 200, height: 150)
            .clipped()
        }
    }
}

#Preview {
    ContentView()
}

圆形头像

.clipShape

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            AsyncImage(url: URL(string: "https://picsum.photos/400/300")) { image in
                image
                    .resizable()
                    .scaledToFill()
                    .frame(width: 120, height: 120)
                    .clipShape(Circle())
            } placeholder: {
                ProgressView() // 加载中的骨架屏
            }
            .frame(width: 200, height: 150)
            .clipped()
        }
    }
}

#Preview {
    ContentView()
}

边框

配合 overlay 实现

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            AsyncImage(url: URL(string: "https://picsum.photos/400/300")) { image in
                image
                    .resizable()
                    .scaledToFill()
                    .frame(width: 100, height: 100)
                    .clipShape(Circle())
                    .overlay(
                        Circle()
                            .stroke(.red, lineWidth: 4)
                    )
            } placeholder: {
                ProgressView() // 加载中的骨架屏
            }
            .frame(width: 200, height: 150)
            .clipped()
        }
    }
}

#Preview {
    ContentView()
}

阴影

.shadow

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            AsyncImage(url: URL(string: "https://picsum.photos/400/300")) { image in
                image.shadow(color: .black.opacity(0.3),
                            radius: 8,
                            x: 0,
                            y: 5)
            } placeholder: {
                ProgressView() // 加载中的骨架屏
            }
            .frame(width: 200, height: 150)
            .clipped()
        }
    }
}

#Preview {
    ContentView()
}

旋转

.rotationEffect

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            AsyncImage(url: URL(string: "https://picsum.photos/400/300")) { image in
                image.rotationEffect(.degrees(75))
            } placeholder: {
                ProgressView() // 加载中的骨架屏
            }
            .frame(width: 200, height: 150)
            .clipped()
        }
    }
}

#Preview {
    ContentView()
}

缩放

.scaleEffect

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            AsyncImage(url: URL(string: "https://picsum.photos/400/300")) { image in
                image.scaleEffect(0.7)
            } placeholder: {
                ProgressView() // 加载中的骨架屏
            }
        }
    }
}

#Preview {
    ContentView()
}

镜像

.scaleEffect(x: -1)

综合样例

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 30) {

            Image("cat")
                .resizable()
                .scaledToFill()
                .frame(width: 220, height: 180)
                .clipShape(RoundedRectangle(cornerRadius: 20))
                .shadow(radius: 8)

            Image(systemName: "heart.circle.fill")
                .font(.system(size: 80))
                .foregroundStyle(.red)

            Image(systemName: "person.crop.circle.fill")
                .font(.system(size: 100))
                .foregroundStyle(.blue)
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

.sheet

.sheet 是 SwiftUI 的模态弹窗 Modal Presentation。iPhone那种从底部向上弹出的一层卡片页面基本就是这个东西。

最简单的 .sheet

点击按钮,Sheet 从底部滑出来。

import SwiftUI

struct ContentView: View {
    @State private var showSheet = false

    var body: some View {
        Button("打开 Sheet") {
            showSheet = true
        }
        .sheet(
            isPresented: $showSheet,
            onDismiss: {
                print("✅ Sheet 已关闭")
            }
        ) {
            SheetView()
        }
    }
}

struct SheetView: View {
    @Environment(\.dismiss)
    private var dismiss

    var body: some View {
        VStack(spacing: 20) {
            Text("Hello Sheet")
                .font(.largeTitle)

            Button("关闭") {
                dismiss()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

isPresented

关闭打开的状态。

.sheet(isPresented: $showSheet) {
}
@State private var showSheet = false
// 打开
showSheet = true
// 关闭
showSheet = false

自动关闭 Sheet

Sheet 内部可以,自行关闭

import SwiftUI

struct DetailView: View {

    @Environment(\.dismiss)
    private var dismiss

    var body: some View {
        Button("关闭") {
            dismiss()
        }
    }
}

struct ContentView: View {
    @State private var showSheet = false

    var body: some View {
        Button("打开 Sheet") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            DetailView()
        }
    }
}

#Preview {
    ContentView()
}

item 方式

很多时候不是一个 Bool。而是:当前打开哪个对象。

struct User: Identifiable {
    let id = UUID()
    let name: String
}
@State private var selectedUser: User?
.sheet(item: $selectedUser) { user in
    Text(user.name)
}
// 点击
selectedUser = User(name: "Tom")
// 关闭
selectedUser = nil

编辑列表完整例子

用 item 方式不用多写一个 Bool

import SwiftUI

struct User: Identifiable {
    let id = UUID()
    let name: String
}

struct ContentView: View {

    let users = [
        User(name: "Tom"),
        User(name: "Lucy"),
        User(name: "Jack")
    ]

    @State private var selectedUser: User?

    var body: some View {

        List(users) { user in
            Button(user.name) {
                selectedUser = user
            }
        }
        .sheet(item: $selectedUser) { user in
            VStack {
                Text(user.name)
                    .font(.largeTitle)

                Button("关闭") {
                    selectedUser = nil
                }
            }
            .padding()
        }
    }
}

#Preview {
    ContentView()
}

常见修饰符

修饰符 作用
.presentationDetents() 设置可停留高度
.presentationBackground() 设置背景
.presentationCornerRadius() 设置圆角
.presentationDragIndicator() 控制顶部拖拽条
.interactiveDismissDisabled() 禁止手势关闭
.presentationContentInteraction() 控制内容滚动与拖拽的交互方式
.presentationCompactAdaptation() 控制紧凑尺寸环境下的展示方式

.onSubmit

.onSubmit 是 SwiftUI 中用于处理用户提交输入的修饰符,最常见于 TextField、SecureField、TextEditor(部分场景)以及 searchable。

下面样例,输入内容输入回车就会触发 .onSubmit

import SwiftUI

struct ContentView: View {
    @State private var username = ""
    @State private var password = ""

    var body: some View {
        TextField("用户名", text: $username)
            .textFieldStyle(.roundedBorder)
            .padding()
            .onSubmit {
                print("提交:\(username)")
            }
        TextField("密码", text: $password)
            .textFieldStyle(.roundedBorder)
            .padding()
            .onSubmit {
                print("提交:\(password)")
            }
    }
}

#Preview {
    ContentView()
}

SubmitLabel

.submitLabel(_:) 是 SwiftUI 用来设置键盘右下角提交按钮文字/样式的 Modifier。

它不会处理提交事件,只负责修改键盘上的按钮显示。真正响应点击的是 .onSubmit {}

TextField("请输入用户名", text: $username)
    .submitLabel(.done)
    .onSubmit {
        print("点击完成")
    }

常见的 SubmitLabel SwiftUI 提供了多个选项。

Label 键盘按钮 适用场景
.done Done(完成) 输入结束
.go Go 打开页面、搜索
.send Send 发送消息
.search Search 搜索框
.join Join 加入房间
.next Next 跳到下一个输入框
.continue Continue 下一步
.return Return 回车
.route Route 导航
.emergencyCall Emergency Call 特殊用途

多个 TextField

FocusState

@FocusState.focused 是 SwiftUI 中 管理输入焦点(Focus) 的一套 API。

可以把它理解成:

import SwiftUI

struct ContentView: View {

    enum Field {
        case username
        case password
    }

    @State private var username = ""
    @State private var password = ""

    @FocusState private var focus: Field?

    var body: some View {

        VStack {

            TextField("用户名", text: $username)
                .focused($focus, equals: .username)
                .submitLabel(.next)
                .onSubmit {
                    focus = .password
                }

            SecureField("密码", text: $password)
                .focused($focus, equals: .password)
                .submitLabel(.done)
                .onSubmit {
                    login()
                }
        }
        .padding()
        .onAppear {
            focus = .username
        }
    }

    func login() {
        print("登录")
    }
}

.autocapitalization

.autocapitalization 是 SwiftUI 中用于控制文本输入时是否自动大写的修饰符,主要用于 TextField。

不过,它已经废弃(Deprecated),现在应该使用 .textInputAutocapitalization(_:)

TextField("用户名", text: $username)
    .autocapitalization(.none)
import SwiftUI

struct ContentView: View {
    @State private var username = ""
    
    var body: some View {
        TextField("用户名", text: $username)
            .textInputAutocapitalization(.words)
    }
}

#Preview {
    ContentView()
}

完整示例

import SwiftUI

struct ContentView: View {
    @State private var name = ""
    @State private var email = ""
    @State private var code = ""

    var body: some View {
        Form {
            TextField("姓名", text: $name)
                .textInputAutocapitalization(.words)

            TextField("邮箱", text: $email)
                .keyboardType(.emailAddress)
                .textInputAutocapitalization(.never)
                .autocorrectionDisabled()

            TextField("激活码", text: $code)
                .textInputAutocapitalization(.characters)
        }
    }
}

#Preview {
    ContentView()
}

.autocorrectionDisabled

.autocorrectionDisabled(_:) 是 SwiftUI 中控制 自动纠错(Auto Correction) 的 Modifier,用于决定输入框是否启用系统的拼写纠错功能。

.autocorrectionDisabled(_ disabled: Bool = true)
// true 关闭自动纠错
// false 开启自动纠错

输入一些离谱的内容,系统可能会在输入的内容下面画红线提示。

import SwiftUI

struct ContentView: View {
    @State private var username = ""

    var body: some View {
        TextField("Username", text: $username)
            .textFieldStyle(.roundedBorder)
            .autocorrectionDisabled(false)
            .padding()
    }
}

#Preview {
    ContentView()
}
import SwiftUI

struct ContentView: View {
    @State private var email = ""
    @State private var password = ""

    var body: some View {
        Form {
            TextField("Email", text: $email)
                .keyboardType(.emailAddress)
                .textInputAutocapitalization(.never)
                .autocorrectionDisabled(false)

            SecureField("Password", text: $password)
                .autocorrectionDisabled()
        }
    }
}

#Preview {
    ContentView()
}

.disabled

.disabled(_:) 是 SwiftUI 用来禁用用户交互的 Modifier。

当设置为 true 时,视图不能响应点击、输入等事件,同时系统会自动调整外观(通常会变灰)。

import SwiftUI

struct ContentView: View {
    @State private var username = ""
    @State private var password = ""
    @State private var isLoading = false

    var body: some View {
        VStack(spacing: 20) {
            TextField("用户名", text: $username)
                .textFieldStyle(.roundedBorder)

            SecureField("密码", text: $password)
                .textFieldStyle(.roundedBorder)

            Button {
                isLoading = true

                Task {
                    try? await Task.sleep(for: .seconds(2))
                    isLoading = false
                }
            } label: {
                if isLoading {
                    ProgressView()
                } else {
                    Text("登录")
                }
            }
            .disabled(
                username.isEmpty ||
                password.isEmpty ||
                isLoading
            )
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

.alert

.alert 是 SwiftUI 中用于 弹出警告框(Alert) 的修饰符,用来提示用户信息、确认操作或者展示错误。

最简单的 Alert 通过 Bool 控制显示

import SwiftUI

struct ContentView: View {
    @State private var showAlert = false

    var body: some View {
        Button("删除文件") {
            showAlert = true
        }
        .alert("确认删除?", isPresented: $showAlert) {
            Button("取消", role: .cancel) {}

            Button("删除", role: .destructive) {
                print("删除")
            }
        } message: {
            Text("删除后无法恢复。")
        }
    }
}

#Preview {
    ContentView()
}

完整示例

import SwiftUI

struct ContentView: View {

    @State private var showAlert = false
    @State private var result = ""

    var body: some View {

        VStack(spacing: 20) {

            Button("删除文件") {
                showAlert = true
            }

            Text(result)
                .font(.title3)

        }
        .padding()
        .alert("确认删除?", isPresented: $showAlert) {

            Button("取消", role: .cancel) {
                result = "取消删除"
            }

            Button("删除", role: .destructive) {
                result = "已删除"
            }

        } message: {
            Text("删除后无法恢复。")
        }
    }
}

#Preview {
    ContentView()
}

Button role

role 的作用,按钮可以指定角色

Button("取消", role: .cancel) { }
Button("删除", role: .destructive) { }
Button("确定") { }
role 效果
.cancel 取消按钮
.destructive 红色危险按钮
nil 普通按钮

Environment 是什么

可以理解成一棵 View 树共享的数据,例如

RootView

    ├── A
    │    └── C

    └── B
         └── D

Root 可以提供一个值

.environment(\.locale, Locale(identifier: "zh_CN"))

所有子 View 都可以读取,而不用一级一级穿参数,这就是 Environment。

@Environment(\.locale) var locale

官方有哪些 Environment Value

SwiftUI 自带很多,例如

// 暗黑模式
@Environment(\.colorScheme) var colorScheme
// 语言
@Environment(\.locale) var locale
// 关闭 Sheet
@Environment(\.dismiss) var dismiss
// App 生命周期
@Environment(\.scenePhase) var scenePhase
// 字体大小
@Environment(\.dynamicTypeSize) var size
// 打开链接
@Environment(\.openURL) var openURL

还有等等等等。

@Environment(\.scenePhase) 是 SwiftUI 提供的环境值(Environment Value),用于获取当前 Scene(窗口)的生命周期状态。不过它监听的是 Scene,而不是整个 App,因此支持多窗口(如 iPad、macOS)。

enum ScenePhase {
    case active // 应用正在使用
    case inactive // 应用暂时不能响应用户
    case background // 应用进入后台
}
import SwiftUI

struct ContentView: View {

    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        Text("Hello")

            .onChange(of: scenePhase) { oldValue, newValue in
                print(oldValue)
                print(newValue)
            }
    }
}

#Preview {
    ContentView()
}

自定义 Environment Key Value

第一步 定义 EnvironmentKey

struct WordsKey: EnvironmentKey {
    static let defaultValue: [String] = []
}

第二部 扩展 EnvironmentValues

extension EnvironmentValues {
    var words: [String] {
        get { self[WordsKey.self] }
        set { self[WordsKey.self] = newValue }
    }
}

现在就有了

\.words

第三步 注入

ContentView()
    .environment(\.words, [
        "Swift",
        "SwiftUI",
        "Apple"
    ])

第四步 读取

struct ChildView: View {

    @Environment(\.words)
    private var words

    var body: some View {
        List(words, id: \.self) {
            Text($0)
        }
    }
}

为什么不用 @State,那么每一级都要传,参数层层透传(Prop Drilling)会让代码越来越臃肿。

使用 Environment 后,所有子 View 都可以直接读取,中间 View 完全不用关心这个数据。

自定义实现 ViewModifier 与 .modifier

ViewModifier 是 SwiftUI 中封装一组 View 修饰器的协议。

如果你发现一堆 .padding().background().cornerRadius()... 到处都在写,就应该考虑抽成一个 ViewModifier。程序员最大的乐趣之一,就是把重复代码包装起来,然后假装自己发明了新的语法。

Modifier 内部不能使用 @State 虽然可以编译,但不要这么做,Modifier 本身不是一个独立 View,生命周期依赖宿主 View,状态容易令人困惑。

定义一个 ViewModifier

import SwiftUI

struct CardModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.blue.opacity(0.2))
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .shadow(radius: 5)
    }
}

// 使用
Text("Hello")
    .modifier(CardModifier())

// 效果等价于
Text("Hello")
    .padding()
    .background(.blue.opacity(0.2))
    .clipShape(RoundedRectangle(cornerRadius: 12))
    .shadow(radius: 5)

完整 Demo

import SwiftUI

struct RedBorderModifier: ViewModifier {

    func body(content: Content) -> some View {
        content
            .padding()
            .border(.red, width: 2)
            .font(.title)
    }
}

struct ContentView: View {

    var body: some View {

        VStack(spacing: 20) {

            Text("Hello")

            Button("Button") {}

        }
        .modifier(RedBorderModifier())
    }
}

#Preview {
    ContentView()
}

带参数的 Modifier

例如颜色可以传入:

struct MyModifier: ViewModifier {

    let color: Color

    func body(content: Content) -> some View {
        content
            .padding()
            .background(color)
            .foregroundStyle(.white)
    }
}
// 使用
Text("SwiftUI")
    .modifier(MyModifier(color: .blue))

多个参数

struct BadgeModifier: ViewModifier {

    var background: Color
    var cornerRadius: CGFloat

    func body(content: Content) -> some View {

        content
            .padding()
            .background(background)
            .clipShape(
                RoundedRectangle(cornerRadius: cornerRadius)
            )
    }
}
// 调用
Text("VIP")
    .modifier(
        BadgeModifier(
            background: .orange,
            cornerRadius: 20
        )
    )

更推荐扩展 View

虽然可以自定义 Modifier 但 SwiftUI 更推荐扩展 View。

// 虽然可以直接
Text("Hello")
    .modifier(CardModifier())
// 但可以
extension View {

    func cardStyle() -> some View {
        modifier(CardModifier())
    }
}
// 以后
Text("Hello")
    .cardStyle()

Button("OK") {}
    .cardStyle()

这也是 SwiftUI 大量内置修饰器(如 .padding().foregroundStyle() 等)的设计思路。

struct HighlightModifier: ViewModifier {

    let color: Color

    func body(content: Content) -> some View {
        content
            .padding()
            .background(color)
            .clipShape(Capsule())
    }
}

extension View {

    func highlight(_ color: Color) -> some View {
        modifier(HighlightModifier(color: color))
    }
}

// 使用

Text("Swift")
    .highlight(.green)

Text("Apple")
    .highlight(.blue)