阅读视图

发现新文章,点击刷新页面。

《swiftUI进阶 第9章SwiftUI 状态管理完全指南》

概述

状态管理是 SwiftUI 应用的核心。本章将系统介绍从 iOS 13 到 iOS 17+ 的所有状态管理技术,包括传统的 ObservableObject 系列和现代的 @Observable 宏,帮助你根据项目需求选择最合适的方案。


第一部分:基础状态管理(iOS 13+)

1. @State:本地视图状态

@State 用于管理视图内部的简单状态,当值改变时自动刷新 UI。

struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") { count += 1 }
        }
    }
}

要点

  • 标记为 private,仅当前视图使用
  • 适合 IntStringBool 等简单类型
  • 当状态变化时,SwiftUI 重新计算 body

2. @Binding:父子视图双向绑定

@Binding 创建对现有状态的引用,允许子视图修改父视图的状态。

struct ParentView: View {
    @State private var isOn = false
    
    var body: some View {
        ToggleView(isOn: $isOn)  // 传递绑定
    }
}

struct ToggleView: View {
    @Binding var isOn: Bool
    
    var body: some View {
        Toggle("开关", isOn: $isOn)
    }
}

第二部分:传统响应式状态管理(iOS 13+)

3. ObservableObject 协议与 @Published

ObservableObject 用于创建可观察的类,@Published 标记需要通知视图的属性。

import Combine

class UserViewModel: ObservableObject {
    @Published var name = "张三"
    @Published var age = 25
    
    func updateName(_ newName: String) {
        name = newName
    }
}

4. @StateObject vs @ObservedObject

特性 @StateObject @ObservedObject
生命周期 视图创建时初始化一次 随视图重建而重建
所有权 拥有对象 仅观察外部对象
适用场景 视图的主要数据源 从父视图传入的对象
struct ContentView: View {
    @StateObject private var viewModel = UserViewModel()  // 拥有
    
    var body: some View {
        ChildView(viewModel: viewModel)  // 传递
    }
}

struct ChildView: View {
    @ObservedObject var viewModel: UserViewModel  // 观察
    
    var body: some View {
        Text(viewModel.name)
    }
}

5. @EnvironmentObject:全局共享状态

通过环境在任意层级共享对象,避免逐层传递。

class AppState: ObservableObject {
    @Published var isLoggedIn = false
}

@main
struct MyApp: App {
    @StateObject private var appState = AppState()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appState)
        }
    }
}

struct ProfileView: View {
    @EnvironmentObject var appState: AppState
    
    var body: some View {
        Text(appState.isLoggedIn ? "已登录" : "未登录")
    }
}

6. @Environment:系统环境值

访问系统提供的环境值,如颜色方案、尺寸类等。

struct ThemeAwareView: View {
    @Environment(\.colorScheme) var colorScheme
    @Environment(\.horizontalSizeClass) var horizontalSizeClass
    
    var body: some View {
        Text("当前模式: \(colorScheme == .dark ? "深色" : "浅色")")
    }
}

7. @AppStorage:持久化存储

使用 UserDefaults 自动持久化简单数据。

struct SettingsView: View {
    @AppStorage("username") var username = ""
    @AppStorage("isDarkMode") var isDarkMode = false
    
    var body: some View {
        TextField("用户名", text: $username)
        Toggle("深色模式", isOn: $isDarkMode)
    }
}

8. @SceneStorage:场景持久化

在场景(如多窗口)中保持状态,窗口关闭后自动清除。

struct DocumentView: View {
    @SceneStorage("scrollPosition") var scrollPosition: Double = 0
    
    var body: some View {
        ScrollView {
            // 内容
        }
    }
}

第三部分:现代状态管理(iOS 17+)

9. @Observable 宏

iOS 17 引入 @Observable 宏,简化了可观察对象的创建,无需 ObservableObject@Published

import SwiftUI

@Observable
class UserModel {
    var name = "张三"
    var age = 25
    var email = "zhangsan@example.com"
}

struct ContentView: View {
    @State private var userModel = UserModel()
    
    var body: some View {
        VStack {
            Text("姓名: \(userModel.name)")
            TextField("修改姓名", text: $userModel.name)  // 直接使用 $ 绑定
        }
    }
}

优势

  • 语法更简洁,无需协议和属性包装器
  • 所有属性默认可观察
  • 性能更优(直接访问)

10. @Bindable 双向绑定

当需要将 @Observable 对象的属性传递给需要绑定的子视图时,使用 @Bindable

@Observable
class Settings {
    var isDarkMode = false
}

struct ParentView: View {
    @State private var settings = Settings()
    
    var body: some View {
        ChildView(settings: settings)  // 直接传递
    }
}

struct ChildView: View {
    @Bindable var settings: Settings  // 添加 @Bindable
    
    var body: some View {
        Toggle("深色模式", isOn: $settings.isDarkMode)  // 可绑定
    }
}

11. 使用 @Environment 与 @Observable 结合

现代方式也可以将可观察对象放入环境。

@Observable
class AppState {
    var isLoggedIn = false
    var userName = ""
}

@main
struct MyApp: App {
    @State private var appState = AppState()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)  // 注入环境
        }
    }
}

struct ProfileView: View {
    @Environment(AppState.self) private var appState  // 读取环境
    
    var body: some View {
        Text(appState.isLoggedIn ? "欢迎 \(appState.userName)" : "未登录")
    }
}

第四部分:最佳实践与迁移指南

选择合适的状态管理工具

场景 推荐方式(iOS 13-16) 推荐方式(iOS 17+)
单个视图内部状态 @State @State
父子视图共享 @Binding @Binding
复杂业务逻辑 @StateObject + ObservableObject @State + @Observable
全局共享状态 @EnvironmentObject @Environment + @Observable
持久化简单数据 @AppStorage @AppStorage
场景临时状态 @SceneStorage @SceneStorage

从 ObservableObject 迁移到 @Observable

迁移步骤

  1. class SomeModel: ObservableObject 改为 @Observable class SomeModel
  2. 移除所有 @Published 包装器
  3. @StateObject 改为 @State(如果对象是视图拥有的)
  4. @ObservedObject 改为 @Bindable(如果需要双向绑定)
  5. @EnvironmentObject 改为 @Environment(SomeModel.self)

迁移示例

// 旧方式
class OldViewModel: ObservableObject {
    @Published var text = ""
}
struct OldView: View {
    @StateObject private var vm = OldViewModel()
    var body: some View { TextField("", text: $vm.text) }
}

// 新方式
@Observable
class NewViewModel {
    var text = ""
}
struct NewView: View {
    @State private var vm = NewViewModel()
    var body: some View { TextField("", text: $vm.text) }
}

第五部分:实战:完整的待办事项应用(双版本对比)

传统方式(ObservableObject)

import SwiftUI
import Combine

class TodoViewModel: ObservableObject {
    @Published var todos: [Todo] = []
    @Published var newTitle = ""
    
    struct Todo: Identifiable {
        let id = UUID()
        var title: String
        var isCompleted = false
    }
    
    func addTodo() {
        guard !newTitle.isEmpty else { return }
        todos.append(Todo(title: newTitle))
        newTitle = ""
    }
    
    func toggle(_ todo: Todo) {
        if let idx = todos.firstIndex(where: { $0.id == todo.id }) {
            todos[idx].isCompleted.toggle()
        }
    }
}

struct TodoListView: View {
    @StateObject private var viewModel = TodoViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    TextField("新待办", text: $viewModel.newTitle)
                        .textFieldStyle(.roundedBorder)
                    Button("添加") { viewModel.addTodo() }
                }
                .padding()
                List {
                    ForEach(viewModel.todos) { todo in
                        HStack {
                            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                .onTapGesture { viewModel.toggle(todo) }
                            Text(todo.title)
                        }
                    }
                }
            }
            .navigationTitle("待办事项")
        }
    }
}

现代方式(@Observable)

import SwiftUI

@Observable
class TodoViewModel {
    var todos: [Todo] = []
    var newTitle = ""
    
    struct Todo: Identifiable {
        let id = UUID()
        var title: String
        var isCompleted = false
    }
    
    func addTodo() {
        guard !newTitle.isEmpty else { return }
        todos.append(Todo(title: newTitle))
        newTitle = ""
    }
    
    func toggle(_ todo: Todo) {
        if let idx = todos.firstIndex(where: { $0.id == todo.id }) {
            todos[idx].isCompleted.toggle()
        }
    }
}

struct TodoListView: View {
    @State private var viewModel = TodoViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    TextField("新待办", text: $viewModel.newTitle)
                        .textFieldStyle(.roundedBorder)
                    Button("添加") { viewModel.addTodo() }
                }
                .padding()
                List {
                    ForEach(viewModel.todos) { todo in
                        HStack {
                            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                .onTapGesture { viewModel.toggle(todo) }
                            Text(todo.title)
                        }
                    }
                }
            }
            .navigationTitle("待办事项")
        }
    }
}

总结

SwiftUI 提供了从基础到高级的完整状态管理方案:

  • 基础层@State@Binding – 适用于简单、局部的状态
  • 传统响应式层ObservableObject@Published@StateObject@ObservedObject@EnvironmentObject – 适用于 iOS 13-16 的复杂状态管理
  • 持久化层@AppStorage@SceneStorage – 适用于数据持久化
  • 现代层(iOS 17+)@Observable@Bindable – 更简洁、更高效,推荐新项目使用

选择建议:

  • 新项目且最低支持 iOS 17:优先使用 @Observable + @Environment
  • 需要兼容 iOS 16 及以下:继续使用 ObservableObject 系列
  • 两者可以在同一项目中共存,逐步迁移

掌握这些工具,你将能够构建出响应迅速、结构清晰的 SwiftUI 应用。


参考资料


本内容为《SwiftUI 进阶》第9章,涵盖从基础到现代的全部状态管理技术。欢迎关注后续更新。

《 SwiftUI 进阶第8章:表单与设置界面》

8.1 Form 组件

核心概念

Form 是 SwiftUI 中用于创建表单界面的专用组件,它提供了:

  • 自动的分组和分隔线
  • 自适应的布局
  • 与系统设置一致的外观
  • 支持多种表单控件

基本使用

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Text("个人信息")
                }
                
                Section {
                    Text("姓名: 张三")
                    Text("年龄: 25")
                    Text("邮箱: zhangsan@example.com")
                }
            }
            .navigationTitle("个人资料")
        }
    }
}

动态表单

import SwiftUI

struct ContentView: View {
    @State private var name = "张三"
    @State private var age = 25
    @State private var email = "zhangsan@example.com"
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Text("个人信息")
                }
                
                Section {
                    TextField("姓名", text: $name)
                    Stepper("年龄: \(age)", value: $age, in: 1...100)
                    TextField("邮箱", text: $email)
                }
            }
            .navigationTitle("编辑资料")
        }
    }
}

8.2 常见表单控件组合

基础控件

控件类型 用途 示例代码
TextField 文本输入 TextField("输入", text: $text)
SecureField 密码输入 SecureField("密码", text: $password)
Toggle 开关 Toggle("启用", isOn: $isEnabled)
Picker 选择器 Picker("选择", selection: $selection) { ... }
Stepper 步进器 Stepper("数量: \(count)", value: $count)
Slider 滑块 Slider(value: $value, in: 0...100)
DatePicker 日期选择 DatePicker("日期", selection: $date)

组合使用

import SwiftUI

struct ContentView: View {
    @State private var notifications = true
    @State private var sound = true
    @State private var theme = "浅色"
    @State private var brightness = 0.5
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Toggle("通知", isOn: $notifications)
                    Toggle("声音", isOn: $sound)
                }
                
                Section {
                    Picker("主题", selection: $theme) {
                        Text("浅色").tag("浅色")
                        Text("深色").tag("深色")
                        Text("跟随系统").tag("跟随系统")
                    }
                }
                
                Section {
                    Text("亮度: \(Int(brightness * 100))%")
                    Slider(value: $brightness, in: 0...1)
                }
            }
            .navigationTitle("设置")
        }
    }
}

8.3 表单验证

基本验证

import SwiftUI

struct ContentView: View {
    @State private var email = ""
    @State private var password = ""
    @State private var showError = false
    @State private var errorMessage = ""
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("邮箱", text: $email)
                        .keyboardType(.emailAddress)
                    SecureField("密码", text: $password)
                }
                
                Section {
                    Button("登录") {
                        if !validateForm() {
                            showError = true
                        }
                    }
                }
            }
            .navigationTitle("登录")
            .alert("错误", isPresented: $showError) {
                Button("确定") {}
            } message: {
                Text(errorMessage)
            }
        }
    }
    
    func validateForm() -> Bool {
        if email.isEmpty {
            errorMessage = "请输入邮箱"
            return false
        }
        if !email.contains("@") {
            errorMessage = "请输入有效的邮箱"
            return false
        }
        if password.count < 6 {
            errorMessage = "密码至少需要6个字符"
            return false
        }
        return true
    }
}

实时验证

import SwiftUI

struct ContentView: View {
    @State private var email = ""
    @State private var password = ""
    
    var emailIsValid: Bool {
        !email.isEmpty && email.contains("@")
    }
    
    var passwordIsValid: Bool {
        password.count >= 6
    }
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("邮箱", text: $email)
                        .keyboardType(.emailAddress)
                        .foregroundColor(emailIsValid ? .primary : .red)
                    
                    SecureField("密码", text: $password)
                        .foregroundColor(passwordIsValid ? .primary : .red)
                    
                    if !emailIsValid && !email.isEmpty {
                        Text("请输入有效的邮箱")
                            .foregroundColor(.red)
                            .font(.caption)
                    }
                    
                    if !passwordIsValid && !password.isEmpty {
                        Text("密码至少需要6个字符")
                            .foregroundColor(.red)
                            .font(.caption)
                    }
                }
                
                Section {
                    Button("登录") {
                        // 登录逻辑
                    }
                    .disabled(!emailIsValid || !passwordIsValid)
                }
            }
            .navigationTitle("登录")
        }
    }
}

8.4 实战:用户设置页面

完整示例

import SwiftUI

struct ContentView: View {
    @State private var notifications = true
    @State private var sound = true
    @State private var haptic = true
    @State private var darkMode = false
    @State private var language = "简体中文"
    @State private var autoLock = 5 // 分钟
    
    var body: some View {
        NavigationStack {
            List {
                Section("通知设置") {
                    Toggle("推送通知", isOn: $notifications)
                    Toggle("声音", isOn: $sound)
                    Toggle("震动", isOn: $haptic)
                }
                
                Section("外观设置") {
                    Toggle("深色模式", isOn: $darkMode)
                }
                
                Section("语言设置") {
                    Picker("语言", selection: $language) {
                        Text("简体中文").tag("简体中文")
                        Text("English").tag("English")
                    }
                }
                
                Section("安全设置") {
                    Picker("自动锁定", selection: $autoLock) {
                        Text("30秒").tag(0)
                        Text("1分钟").tag(1)
                        Text("5分钟").tag(5)
                        Text("10分钟").tag(10)
                        Text("永不").tag(-1)
                    }
                }
                
                Section("关于") {
                    HStack {
                        Text("版本")
                        Spacer()
                        Text("1.0.0")
                            .foregroundColor(.gray)
                    }
                    
                    Button("检查更新") {
                        // 检查更新逻辑
                    }
                    
                    Button("隐私政策") {
                        // 打开隐私政策
                    }
                }
            }
            .navigationTitle("设置")
        }
    }
}

分组样式

Form {
    // 表单内容
}
.formStyle(.grouped) // 分组样式

最佳实践

  1. 分组逻辑:按照功能将表单控件分组
  2. 标签清晰:为每个控件提供明确的标签
  3. 验证反馈:及时提供验证错误反馈
  4. 默认值:为控件设置合理的默认值
  5. 布局合理:使用合适的控件类型和布局

性能优化

  1. 避免复杂计算:不要在 body 中进行复杂计算
  2. 使用 @State 优化:合理使用 @State 管理表单状态
  3. 延迟加载:对于复杂表单,考虑使用延迟加载

与 iOS 专家博客对比

根据 SwiftUI by Example 的建议:

  • 使用 Section 组织表单内容
  • 为表单控件提供合适的键盘类型
  • 利用 Form 的自动布局特性
  • 结合 NavigationStack 构建设置页面层次

高级技巧

自定义表单样式

struct CustomFormStyle: FormStyle {
    func makeBody(configuration: Configuration) -> some View {
        VStack(spacing: 0) {
            ForEach(configuration.content) {
                $0
                    .padding()
                    .background(Color.white)
                    .border(Color.gray.opacity(0.2), edges: .bottom)
            }
        }
        .background(Color.gray.opacity(0.1))
    }
}

// 使用
Form {
    // 表单内容
}
.formStyle(CustomFormStyle())

表单数据持久化

import SwiftUI

struct ContentView: View {
    @AppStorage("notifications") private var notifications = true
    @AppStorage("darkMode") private var darkMode = false
    @AppStorage("language") private var language = "简体中文"
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Toggle("通知", isOn: $notifications)
                    Toggle("深色模式", isOn: $darkMode)
                    Picker("语言", selection: $language) {
                        Text("简体中文").tag("简体中文")
                        Text("English").tag("English")
                    }
                }
            }
            .navigationTitle("设置")
        }
    }
}

总结

表单与设置界面是应用中常见的组成部分,SwiftUI 提供了强大的 Form 组件来简化开发:

  • Form:创建结构化的表单布局
  • 多种内置控件:满足各种输入需求
  • 实时验证:提供良好的用户反馈
  • 与系统风格一致:确保视觉一致性

通过合理组织表单内容、提供清晰的验证反馈、使用适当的控件类型,可以创建出既美观又实用的设置界面。


参考资料


本内容为《SwiftUI 进阶》第八章,欢迎关注后续更新。

《SwiftUI 进阶第7章:导航系统》

7.1 NavigationStack 基础导航

核心概念

NavigationStack 是 SwiftUI 中用于构建导航层次结构的核心组件,它替代了旧版的 NavigationView(在 iOS 16+ 中已被废弃)。

基本使用

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("前往详情页", destination: DetailView())
            }
            .navigationTitle("主页面")
        }
    }
}

struct DetailView: View {
    var body: some View {
        Text("详情页内容")
            .navigationTitle("详情页")
    }
}

程序化导航

NavigationStack 支持使用路径进行程序化导航:

import SwiftUI

struct ContentView: View {
    @State private var path: [Int] = []
    
    var body: some View {
        NavigationStack(path: $path) {
            List(1..<10) { number in
                NavigationLink(value: number) {
                    Text("项目 \(number)")
                }
            }
            .navigationTitle("主页面")
            .navigationDestination(for: Int.self) {
                DetailView(number: $0, path: $path)
            }
        }
    }
}

struct DetailView: View {
    let number: Int
    @Binding var path: [Int]
    
    var body: some View {
        VStack {
            Text("详情页 \(number)")
            Button("前往下一页") {
                path.append(number + 10)
            }
            Button("返回首页") {
                path.removeAll()
            }
        }
        .navigationTitle("详情页 \(number)")
    }
}

与官方文档对比

根据苹果官方文档,NavigationStack 提供了更灵活的导航控制,包括:

  • 路径管理:可以通过绑定的数组控制导航状态
  • 类型安全:使用 navigationDestination(for:) 提供类型安全的导航目标
  • 向后兼容:在 iOS 16+ 中推荐使用

7.2 NavigationLink 页面跳转

核心概念

NavigationLink 是用于创建导航链接的组件,它可以:

  • 直接指定目标视图
  • 使用值传递方式(配合 navigationDestination
  • 控制激活状态

直接目标方式

NavigationLink("前往详情页", destination: DetailView())

值传递方式

NavigationLink(value: item) {
    Text(item.name)
}

条件导航

NavigationLink(
    "登录", 
    destination: LoginView(),
    isActive: $isLoggedIn
)

7.3 navigationTitle 与 navigationBarTitleDisplayMode

navigationTitle

设置导航栏标题:

.navigationTitle("页面标题")

navigationBarTitleDisplayMode

控制标题显示模式:

模式 描述
.automatic 自动(默认)
.inline 内联模式(小字体)
.large 大标题模式
.navigationBarTitleDisplayMode(.large)

7.4 Sheet 模态视图

核心概念

Sheet 用于显示模态视图,通常用于:

  • 表单填写
  • 详情展示
  • 辅助操作

基本使用

import SwiftUI

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("显示 Sheet") {
            isSheetPresented = true
        }
        .sheet(isPresented: $isSheetPresented) {
            SheetView(isPresented: $isSheetPresented)
        }
    }
}

struct SheetView: View {
    @Binding var isPresented: Bool
    
    var body: some View {
        VStack {
            Text("这是一个 Sheet 视图")
            Button("关闭") {
                isPresented = false
            }
        }
        .padding()
    }
}

带值的 Sheet

.sheet(item: $selectedItem) {
    DetailView(item: $0)
}

7.5 TabView 标签页导航

核心概念

TabView 用于创建底部标签栏导航,是构建多标签应用的基础。

基本使用

import SwiftUI

struct ContentView: View {
    @State private var selectedTab = 0
    
    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView()
                .tabItem {
                    Label("首页", systemImage: "house")
                }
                .tag(0)
            
            ProfileView()
                .tabItem {
                    Label("个人", systemImage: "person")
                }
                .tag(1)
        }
    }
}

struct HomeView: View {
    var body: some View {
        Text("首页")
    }
}

struct ProfileView: View {
    var body: some View {
        Text("个人中心")
    }
}

自定义样式

TabView {
    // 标签内容
}
.tabViewStyle(.automatic) // 自动样式

最佳实践

  1. 导航层次:保持导航层次清晰,避免过深的导航栈
  2. 标题设置:为每个页面设置合适的标题和显示模式
  3. 模态视图:合理使用 Sheet 展示临时内容
  4. 标签栏:控制标签数量(建议 3-5 个)
  5. 状态管理:使用 @State@Observable 管理导航状态

性能优化

  1. 延迟加载:使用 LazyView 包装目标视图
  2. 导航栈管理:及时清理不需要的导航路径
  3. 避免过度动画:减少导航过程中的复杂动画

建议:

  • 优先使用 NavigationStack 而非 NavigationView
  • 使用值类型传递而非对象引用
  • 结合 @ObservableObservableObject 管理复杂导航状态

实战:多页面应用

import SwiftUI

struct ContentView: View {
    @State private var path: [String] = []
    
    var body: some View {
        NavigationStack(path: $path) {
            TabView {
                HomeView(path: $path)
                    .tabItem {
                        Label("首页", systemImage: "house")
                    }
                
                SettingsView()
                    .tabItem {
                        Label("设置", systemImage: "gear")
                    }
            }
        }
    }
}

struct HomeView: View {
    @Binding var path: [String]
    
    var body: some View {
        List {
            NavigationLink(value: "detail") {
                Text("详情页")
            }
            NavigationLink(value: "profile") {
                Text("个人资料")
            }
        }
        .navigationTitle("首页")
        .navigationDestination(for: String.self) {
            switch $0 {
            case "detail":
                DetailView()
            case "profile":
                ProfileView()
            default:
                Text("未知页面")
            }
        }
    }
}

struct DetailView: View {
    var body: some View {
        Text("详情页内容")
            .navigationTitle("详情")
    }
}

struct ProfileView: View {
    var body: some View {
        Text("个人资料")
            .navigationTitle("个人")
    }
}

struct SettingsView: View {
    var body: some View {
        Text("设置页面")
            .navigationTitle("设置")
    }
}

总结

导航系统是构建 iOS 应用的核心部分,SwiftUI 提供了现代化的导航组件:

  • NavigationStack:构建导航层次结构
  • NavigationLink:创建导航链接
  • Sheet:显示模态视图
  • TabView:实现标签页导航

掌握这些组件的使用,将帮助你构建结构清晰、用户体验良好的多页面应用。


参考资料


本内容为《SwiftUI 进阶》第七章,欢迎关注后续更新。

《SwiftUI 进阶第5章:数据处理与网络请求》

学习目标

  • 掌握 SwiftUI 中的数据处理基本方法
  • 了解如何进行网络请求
  • 学习如何处理网络请求的加载状态和错误
  • 掌握数据过滤和排序的方法
  • 了解如何使用 JSONDecoder 解析 JSON 数据

核心概念

数据模型

在 SwiftUI 中,数据模型通常使用结构体来定义,并且需要符合 Identifiable 协议以便在列表中使用。

示例代码:

struct Post: Identifiable, Decodable {
    let id: Int
    let title: String
    let body: String
    let userId: Int
}

本地数据处理

本地数据处理包括数据的添加、删除、修改和查询等操作。

示例代码:

@State private var localData: [String] = ["本地数据1", "本地数据2", "本地数据3"]
@State private var newData = ""

HStack {
    TextField("输入新数据", text: $newData)
        .textFieldStyle(RoundedBorderTextFieldStyle())
        .padding()
    
    Button("添加") {
        if !newData.isEmpty {
            localData.append(newData)
            newData = ""
        }
    }
}

List(localData, id: \.self) { item in
    Text(item)
}

网络请求

在 SwiftUI 中,网络请求通常使用 URLSession 来实现,并且需要在后台线程中执行,然后在主线程中更新 UI。

示例代码:

@State private var posts: [Post] = []
@State private var isLoading = false
@State private var errorMessage: String? = nil

func fetchPosts() {
    isLoading = true
    errorMessage = nil
    
    guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
        self.isLoading = false
        self.errorMessage = "无效的URL"
        return
    }
    
    URLSession.shared.dataTask(with: url) { data, response, error in
        DispatchQueue.main.async {
            self.isLoading = false
            
            if let error = error {
                self.errorMessage = error.localizedDescription
                return
            }
            
            guard let data = data else {
                self.errorMessage = "无数据返回"
                return
            }
            
            do {
                let decodedPosts = try JSONDecoder().decode([Post].self, from: data)
                self.posts = decodedPosts
            } catch {
                self.errorMessage = "解析数据失败"
            }
        }
    }.resume()
}

数据状态管理

在网络请求过程中,需要管理不同的状态:加载中、加载成功和加载失败。

示例代码:

if isLoading {
    ProgressView("加载中...")
        .padding()
} else if let errorMessage = errorMessage {
    Text("错误: \(errorMessage)")
        .foregroundColor(.red)
        .padding()
} else {
    List(posts) { post in
        VStack(alignment: .leading) {
            Text(post.title)
                .font(.headline)
            Text(post.body)
                .font(.body)
                .foregroundColor(.gray)
        }
    }
}

数据过滤

使用 filter 方法可以对数据进行过滤,只显示符合条件的数据。

示例代码:

List(localData.filter { $0.contains("1") }, id: \.self) {
    Text($0)
}

数据排序

使用 sorted 方法可以对数据进行排序,按照指定的规则排列数据。

示例代码:

List(localData.sorted(), id: \.self) {
    Text($0)
}

实践示例:完整数据处理与网络请求演示

以下是一个完整的数据处理与网络请求演示示例:

import SwiftUI

// 数据模型
struct Post: Identifiable, Decodable {
    let id: Int
    let title: String
    let body: String
    let userId: Int
}

struct DataProcessingAndNetworkingDemo: View {
    // 状态管理
    @State private var posts: [Post] = []
    @State private var isLoading = false
    @State private var errorMessage: String? = nil
    @State private var localData: [String] = ["本地数据1", "本地数据2", "本地数据3"]
    @State private var newData = ""
    @State private var filterKeyword = ""
    @State private var sortAscending = true
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("数据处理与网络请求")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 1. 本地数据处理
                VStack(alignment: .leading, spacing: 12) {
                    Text("1. 本地数据处理")
                        .font(.headline)
                    
                    HStack {
                        TextField("输入新数据", text: $newData)
                            .textFieldStyle(.roundedBorder)
                        Button("添加") {
                            if !newData.isEmpty {
                                localData.append(newData)
                                newData = ""
                            }
                        }
                        .buttonStyle(.borderedProminent)
                    }
                    
                    List(localData, id: \.self) { item in
                        Text(item)
                    }
                    .frame(height: 150)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 2. 网络请求
                VStack(alignment: .leading, spacing: 12) {
                    Text("2. 网络请求")
                        .font(.headline)
                    
                    Button("获取网络数据") {
                        fetchPosts()
                    }
                    .buttonStyle(.borderedProminent)
                    
                    if isLoading {
                        ProgressView("加载中...")
                            .padding()
                    } else if let errorMessage = errorMessage {
                        Text("错误: \(errorMessage)")
                            .foregroundColor(.red)
                            .padding()
                    } else if !posts.isEmpty {
                        List(posts) { post in
                            VStack(alignment: .leading) {
                                Text(post.title)
                                    .font(.headline)
                                Text(post.body)
                                    .font(.body)
                                    .foregroundColor(.gray)
                            }
                        }
                        .frame(height: 250)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 3. 数据过滤
                VStack(alignment: .leading, spacing: 12) {
                    Text("3. 数据过滤")
                        .font(.headline)
                    
                    TextField("输入过滤关键词", text: $filterKeyword)
                        .textFieldStyle(.roundedBorder)
                    
                    List(localData.filter { 
                        filterKeyword.isEmpty ? true : $0.contains(filterKeyword) 
                    }, id: \.self) { item in
                        Text(item)
                    }
                    .frame(height: 120)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 4. 数据排序
                VStack(alignment: .leading, spacing: 12) {
                    Text("4. 数据排序")
                        .font(.headline)
                    
                    Toggle("升序排列", isOn: $sortAscending)
                    
                    List(sortAscending ? localData.sorted() : localData.sorted(by: >), id: \.self) { item in
                        Text(item)
                    }
                    .frame(height: 120)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
    
    // 网络请求方法
    func fetchPosts() {
        isLoading = true
        errorMessage = nil
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
            self.isLoading = false
            self.errorMessage = "无效的URL"
            return
        }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            DispatchQueue.main.async {
                self.isLoading = false
                
                if let error = error {
                    self.errorMessage = error.localizedDescription
                    return
                }
                
                guard let data = data else {
                    self.errorMessage = "无数据返回"
                    return
                }
                
                do {
                    let decodedPosts = try JSONDecoder().decode([Post].self, from: data)
                    self.posts = decodedPosts
                } catch {
                    self.errorMessage = "解析数据失败"
                }
            }
        }.resume()
    }
}

#Preview {
    DataProcessingAndNetworkingDemo()
}

常见问题与解决方案

1. 网络请求在主线程执行

问题:网络请求在主线程执行,导致 UI 卡顿。

解决方案:使用 DispatchQueue.global().async 将网络请求放在后台线程执行,然后在主线程中更新 UI。实际上 URLSession.dataTask 的回调默认就在后台线程,只需确保 UI 更新在 DispatchQueue.main.async 中。

URLSession.shared.dataTask(with: url) { data, response, error in
    DispatchQueue.main.async {
        // 更新 UI
    }
}.resume()

2. 数据解析失败

问题:JSON 数据解析失败。

解决方案

  • 确保数据模型与 JSON 数据结构完全匹配(字段名、类型)
  • 使用 CodingKeys 处理字段名不一致的情况
  • 使用 try?do-catch 捕获错误
struct Post: Decodable {
    let id: Int
    let title: String
    
    enum CodingKeys: String, CodingKey {
        case id
        case title = "post_title"  // 如果 JSON 字段名不同
    }
}

3. 加载状态未正确显示

问题:网络请求过程中没有显示加载状态。

解决方案:使用 @State 变量管理加载状态,并在请求开始前设置为 true,完成后设置为 false

4. 错误处理不完善

问题:网络请求失败时没有显示错误信息。

解决方案:捕获并处理网络请求中的错误,将错误信息显示给用户。

if let errorMessage = errorMessage {
    Text("错误: \(errorMessage)")
        .foregroundColor(.red)
}

总结

本章介绍了 SwiftUI 中的数据处理与网络请求,包括:

  • 数据模型的定义:使用 IdentifiableDecodable 协议
  • 本地数据处理:增删改查、列表展示
  • 网络请求的实现:使用 URLSession 和异步回调
  • 数据状态管理:加载中、成功、失败三种状态
  • 数据过滤:使用 filter 方法按条件筛选
  • 数据排序:使用 sorted 方法自定义排序规则

通过这些技术,可以实现数据的获取、处理和展示,为应用提供丰富的数据源。在实际开发中,数据处理与网络请求是应用的核心功能之一,掌握这些技术对于开发高质量的 SwiftUI 应用至关重要。


参考资料


本内容为《SwiftUI 进阶》第五章,欢迎关注后续更新。

《SwiftUI 进阶第4章:响应式布局》

Snip20260418_7.png

学习目标

  • 掌握 SwiftUI 中的响应式布局概念
  • 了解如何根据屏幕尺寸调整布局
  • 学习使用环境变量获取设备信息
  • 掌握动态网格布局的实现方法
  • 了解几何读取器和安全区域的使用

核心概念

响应式布局基础

在 SwiftUI 中,响应式布局是通过环境变量、条件布局和自适应组件来实现的,它可以根据不同的屏幕尺寸和设备类型自动调整布局。


环境变量

尺寸类

SwiftUI 提供了尺寸类来描述设备的屏幕尺寸,主要有两种尺寸类:

  • horizontalSizeClass - 水平尺寸类,分为 .compact(紧凑)和 .regular(常规)
  • verticalSizeClass - 垂直尺寸类,同样分为 .compact.regular

示例代码:

@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass

Text("水平尺寸类: \(horizontalSizeClass == .compact ? "紧凑" : "常规")")
Text("垂直尺寸类: \(verticalSizeClass == .compact ? "紧凑" : "常规")")

自适应布局

根据尺寸类调整布局是响应式设计的核心。

示例代码:

// 根据水平尺寸类调整布局
if horizontalSizeClass == .compact {
    // 紧凑模式 - 垂直布局
    VStack(spacing: 10) {
        Color.red
            .frame(height: 100)
            .cornerRadius(10)
        Color.green
            .frame(height: 100)
            .cornerRadius(10)
        Color.blue
            .frame(height: 100)
            .cornerRadius(10)
    }
} else {
    // 常规模式 - 水平布局
    HStack(spacing: 10) {
        Color.red
            .frame(height: 100)
            .cornerRadius(10)
        Color.green
            .frame(height: 100)
            .cornerRadius(10)
        Color.blue
            .frame(height: 100)
            .cornerRadius(10)
    }
}

动态网格布局

使用 LazyVGridGridItem 可以创建动态网格布局,根据屏幕尺寸自动调整列数。

示例代码:

// 根据水平尺寸类调整网格列数
let columns = horizontalSizeClass == .compact ? [
    GridItem(.flexible()),
    GridItem(.flexible())
] : [
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible())
]

LazyVGrid(columns: columns, spacing: 10) {
    ForEach(1..<9) { index in
        Color(hue: Double(index)/10, saturation: 0.8, brightness: 0.8)
            .frame(height: 100)
            .cornerRadius(10)
            .overlay(
                Text("\(index)")
                    .foregroundColor(.white)
                    .font(.headline)
            )
    }
}

几何读取器

GeometryReader 可以获取父视图的尺寸和位置信息,用于创建更加灵活的布局。

示例代码:

GeometryReader { geometry in
    VStack {
        Text("屏幕宽度: \(geometry.size.width, specifier: "%.0f")")
        Text("屏幕高度: \(geometry.size.height, specifier: "%.0f")")
        
        Rectangle()
            .fill(.purple)
            .frame(width: geometry.size.width * 0.8, height: 100)
            .cornerRadius(10)
    }
}
.frame(height: 200)

安全区域

安全区域是指屏幕上不会被系统 UI(如状态栏、导航栏、底部安全区域)遮挡的区域。

示例代码:

Color.blue
    .frame(height: 100)
    .ignoresSafeArea(edges: .top)
    .cornerRadius(10)

自适应文本

使用 .multilineTextAlignment() 可以创建自适应文本,根据屏幕宽度自动换行。

示例代码:

Text("这是一段自适应文本,会根据屏幕宽度自动换行")
    .font(.body)
    .multilineTextAlignment(.center)
    .padding()
    .background(.gray.opacity(0.1))
    .cornerRadius(10)

条件内容

根据尺寸类显示不同的内容,实现设备特定的布局。

示例代码:

if horizontalSizeClass == .compact {
    Text("当前是手机模式,显示手机专用内容")
        .font(.body)
        .padding()
        .background(.green)
        .foregroundColor(.white)
        .cornerRadius(10)
} else {
    Text("当前是平板模式,显示平板专用内容")
        .font(.body)
        .padding()
        .background(.blue)
        .foregroundColor(.white)
        .cornerRadius(10)
}

动态间距

根据屏幕尺寸调整组件之间的间距。

示例代码:

// 根据水平尺寸类调整间距
let spacing = horizontalSizeClass == .compact ? 10.0 : 20.0

VStack(spacing: spacing) {
    Color.red
        .frame(height: 50)
        .cornerRadius(10)
    Color.green
        .frame(height: 50)
        .cornerRadius(10)
    Color.blue
        .frame(height: 50)
        .cornerRadius(10)
}

实践示例:完整响应式布局演示

以下是一个完整的响应式布局演示示例,包含了各种响应式设计技术:

import SwiftUI

struct ResponsiveLayoutDemo: View {
    // 环境变量 - 用于获取屏幕尺寸
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    @Environment(\.verticalSizeClass) private var verticalSizeClass
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("响应式布局")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 1. 屏幕尺寸信息
                VStack {
                    Text("1. 屏幕尺寸信息")
                        .font(.headline)
                    HStack {
                        Text("水平尺寸类:")
                        Text(horizontalSizeClass == .compact ? "紧凑 (Compact)" : "常规 (Regular)")
                            .foregroundColor(.blue)
                    }
                    HStack {
                        Text("垂直尺寸类:")
                        Text(verticalSizeClass == .compact ? "紧凑 (Compact)" : "常规 (Regular)")
                            .foregroundColor(.blue)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 2. 自适应布局(垂直/水平切换)
                VStack {
                    Text("2. 自适应布局")
                        .font(.headline)
                    
                    if horizontalSizeClass == .compact {
                        VStack(spacing: 10) {
                            Color.red.frame(height: 60).cornerRadius(8)
                            Color.green.frame(height: 60).cornerRadius(8)
                            Color.blue.frame(height: 60).cornerRadius(8)
                        }
                    } else {
                        HStack(spacing: 10) {
                            Color.red.frame(height: 80).cornerRadius(8)
                            Color.green.frame(height: 80).cornerRadius(8)
                            Color.blue.frame(height: 80).cornerRadius(8)
                        }
                    }
                    Text("\(horizontalSizeClass == .compact ? "垂直堆叠" : "水平排列")")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 3. 动态网格布局
                VStack {
                    Text("3. 动态网格布局")
                        .font(.headline)
                    
                    let columns = horizontalSizeClass == .compact ? [
                        GridItem(.flexible()),
                        GridItem(.flexible())
                    ] : [
                        GridItem(.flexible()),
                        GridItem(.flexible()),
                        GridItem(.flexible()),
                        GridItem(.flexible())
                    ]
                    
                    LazyVGrid(columns: columns, spacing: 10) {
                        ForEach(1..<9) { index in
                            Color(hue: Double(index)/10, saturation: 0.7, brightness: 0.9)
                                .frame(height: 80)
                                .cornerRadius(8)
                                .overlay(
                                    Text("\(index)")
                                        .foregroundColor(.white)
                                        .font(.headline)
                                )
                        }
                    }
                    Text("\(horizontalSizeClass == .compact ? "2列网格" : "4列网格")")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 4. 几何读取器
                VStack {
                    Text("4. 几何读取器")
                        .font(.headline)
                    
                    GeometryReader { geometry in
                        VStack {
                            Text("可用宽度: \(geometry.size.width, specifier: "%.0f")")
                                .font(.caption)
                            Rectangle()
                                .fill(.purple)
                                .frame(width: geometry.size.width * 0.7, height: 40)
                                .cornerRadius(8)
                                .overlay(
                                    Text("70% 宽度")
                                        .font(.caption)
                                        .foregroundColor(.white)
                                )
                        }
                        .frame(maxWidth: .infinity)
                    }
                    .frame(height: 100)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 5. 安全区域示例
                VStack {
                    Text("5. 安全区域")
                        .font(.headline)
                    
                    Color.blue
                        .frame(height: 60)
                        .cornerRadius(8)
                        .overlay(
                            Text("默认在安全区域内")
                                .foregroundColor(.white)
                        )
                    
                    Color.orange
                        .frame(height: 60)
                        .cornerRadius(8)
                        .ignoresSafeArea(edges: .horizontal)
                        .overlay(
                            Text("忽略水平安全区域")
                                .foregroundColor(.white)
                        )
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 6. 自适应文本
                VStack {
                    Text("6. 自适应文本")
                        .font(.headline)
                    
                    Text("这是一段自适应文本,会根据屏幕宽度自动换行。当屏幕较窄时,文字会折行显示;屏幕较宽时,可以在一行内完整显示。")
                        .font(.body)
                        .multilineTextAlignment(.center)
                        .padding()
                        .background(Color.gray.opacity(0.2))
                        .cornerRadius(8)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 7. 条件内容(设备专用)
                VStack {
                    Text("7. 条件内容")
                        .font(.headline)
                    
                    if horizontalSizeClass == .compact {
                        Text("📱 手机模式:显示紧凑型布局")
                            .font(.body)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(.green)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                    } else {
                        Text("🖥️ 平板模式:显示扩展型布局")
                            .font(.body)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(.blue)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 8. 动态间距
                VStack {
                    Text("8. 动态间距")
                        .font(.headline)
                    
                    let dynamicSpacing = horizontalSizeClass == .compact ? 8.0 : 20.0
                    
                    VStack(spacing: dynamicSpacing) {
                        Color.red.frame(height: 40).cornerRadius(6)
                        Color.green.frame(height: 40).cornerRadius(6)
                        Color.blue.frame(height: 40).cornerRadius(6)
                    }
                    Text("当前间距: \(dynamicSpacing, specifier: "%.0f") pt")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
}

#Preview {
    ResponsiveLayoutDemo()
}

常见问题与解决方案

1. 布局在不同设备上显示不一致

问题:布局在手机上显示正常,但在平板上显示异常。

解决方案:使用尺寸类和条件布局,为不同尺寸的设备提供不同的布局方案。

// 根据水平尺寸类选择不同的布局结构
if horizontalSizeClass == .compact {
    // 手机布局:垂直堆叠
    VStack { ... }
} else {
    // 平板布局:水平排列或更复杂的网格
    HStack { ... }
}

2. 内容被安全区域遮挡

问题:内容被状态栏或导航栏遮挡。

解决方案:使用 .ignoresSafeArea() 修饰符或确保内容在安全区域内。

// 方法一:忽略安全区域(适用于背景视图)
Color.blue.ignoresSafeArea()

// 方法二:使用 safeAreaInset 添加自定义内容
List {
    // 内容
}
.safeAreaInset(edge: .bottom) {
    Button("底部按钮") { }
        .padding()
}

3. 网格布局在小屏幕上显示拥挤

问题:网格布局在小屏幕上列数过多,导致内容拥挤。

解决方案:根据屏幕尺寸动态调整网格列数。

let columns = horizontalSizeClass == .compact ? [
    GridItem(.flexible()),
    GridItem(.flexible())
] : [
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible())
]

4. GeometryReader 导致布局异常

问题:使用 GeometryReader 后,子视图大小不符合预期。

解决方案:注意 GeometryReader 会占据父视图提供的全部空间,可以在内部使用 frame(height:) 限制高度。

GeometryReader { geometry in
    // 内容
}
.frame(height: 200)  // 固定高度

总结

本章介绍了 SwiftUI 中的响应式布局技术,包括:

  • 环境变量:使用 @Environment 获取设备尺寸信息(horizontalSizeClassverticalSizeClass
  • 自适应布局:根据尺寸类调整布局结构(VStackHStack
  • 动态网格布局:使用 LazyVGridGridItem 创建响应式网格
  • 几何读取器:通过 GeometryReader 获取父视图尺寸,实现精确布局
  • 安全区域:处理状态栏、导航栏等系统 UI 遮挡问题
  • 自适应文本:使用 .multilineTextAlignment() 实现文本自动换行
  • 条件内容:为不同设备类型显示不同的 UI 组件
  • 动态间距:根据屏幕尺寸调整组件之间的间距

通过这些技术,可以创建在不同设备上都能良好显示的布局,提升用户体验。在实际开发中,响应式布局是确保应用在各种设备上都能正常显示的重要手段。


参考资料


本内容为《SwiftUI 进阶》第四章,欢迎关注后续更新。

《SwiftUI 进阶学习第3章:手势与交互》

手势基础

在 SwiftUI 中,手势是通过各种手势类型和修饰符来实现的,它们可以附加到任何视图上,以响应用户的交互。


常用手势类型

1. 点击手势

点击手势通过 onTapGesture 修饰符实现,用于检测用户的点击操作。

示例代码:

@State private var isTapped = false

Rectangle()
    .fill(.blue)
    .frame(width: 200, height: 100)
    .cornerRadius(10)
    .onTapGesture {
        isTapped.toggle()
    }

点击次数:

可以通过 count 参数指定点击次数,例如双击:

Rectangle()
    .onTapGesture(count: 2) {
        tapCount += 1
    }

2. 长按手势

长按手势通过 onLongPressGesture 修饰符实现,用于检测用户的长按操作。

示例代码:

@State private var isLongPressed = false

Rectangle()
    .fill(.green)
    .frame(width: 200, height: 100)
    .cornerRadius(10)
    .onLongPressGesture {
        isLongPressed.toggle()
    }

3. 拖拽手势

拖拽手势通过 DragGesture 实现,用于检测用户的拖拽操作。

示例代码:

@State private var dragOffset = CGSize.zero

Circle()
    .fill(.red)
    .frame(width: 50, height: 50)
    .offset(dragOffset)
    .gesture(
        DragGesture()
            .onChanged { value in
                dragOffset = value.translation
            }
            .onEnded { value in
                // 可以在这里添加结束拖动的逻辑
            }
    )

4. 缩放手势

缩放手势通过 MagnificationGesture 实现,用于检测用户的缩放操作。

示例代码:

@State private var scale = 1.0

Rectangle()
    .fill(.purple)
    .frame(width: 200, height: 200)
    .cornerRadius(10)
    .scaleEffect(scale)
    .gesture(
        MagnificationGesture()
            .onChanged { value in
                scale = value
            }
    )

5. 旋转手势

旋转手势通过 RotationGesture 实现,用于检测用户的旋转操作。

示例代码:

@State private var rotation = 0.0

Rectangle()
    .fill(.orange)
    .frame(width: 200, height: 200)
    .cornerRadius(10)
    .rotationEffect(.degrees(rotation))
    .gesture(
        RotationGesture()
            .onChanged { value in
                rotation = value.degrees
            }
    )

组合手势

组合手势是将多种手势效果结合在一起,可以使用 .simultaneousGesture() 修饰符。

示例代码:

@State private var offset = CGSize.zero
@State private var scale = 1.0
@State private var rotation = 0.0

Rectangle()
    .fill(.blue)
    .frame(width: 200, height: 200)
    .cornerRadius(10)
    .offset(offset)
    .scaleEffect(scale)
    .rotationEffect(.degrees(rotation))
    .gesture(
        DragGesture()
            .onChanged { value in
                offset = value.translation
            }
    )
    .simultaneousGesture(
        MagnificationGesture()
            .onChanged { value in
                scale = value
            }
    )
    .simultaneousGesture(
        RotationGesture()
            .onChanged { value in
                rotation = value.degrees
            }
    )

手势状态管理

手势通常与状态管理结合使用,以追踪手势的状态和数据。

示例代码:

@State private var isDragging = false

Rectangle()
    .fill(.purple)
    .frame(width: 100, height: 100)
    .cornerRadius(10)
    .gesture(
        DragGesture()
            .onChanged { _ in
                isDragging = true
            }
            .onEnded { _ in
                isDragging = false
            }
    )

实践示例:完整手势演示

以下是一个完整的手势演示示例,包含了各种手势类型和组合:

import SwiftUI

struct GestureAndInteractionDemo: View {
    // 状态管理
    @State private var isTapped = false
    @State private var isLongPressed = false
    @State private var offset = CGSize.zero
    @State private var scale = 1.0
    @State private var rotation = 0.0
    @State private var dragOffset = CGSize.zero
    @State private var isDragging = false
    @State private var tapCount = 0
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("手势与交互")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 1. 点击手势
                VStack {
                    Text("1. 点击手势")
                        .font(.headline)
                    Rectangle()
                        .fill(isTapped ? Color.green : Color.blue)
                        .frame(width: 200, height: 80)
                        .cornerRadius(10)
                        .onTapGesture {
                            withAnimation {
                                isTapped.toggle()
                            }
                        }
                    Text("状态: \(isTapped ? "已点击" : "未点击")")
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 2. 长按手势
                VStack {
                    Text("2. 长按手势")
                        .font(.headline)
                    Rectangle()
                        .fill(isLongPressed ? Color.red : Color.green)
                        .frame(width: 200, height: 80)
                        .cornerRadius(10)
                        .onLongPressGesture {
                            withAnimation {
                                isLongPressed.toggle()
                            }
                        }
                    Text("状态: \(isLongPressed ? "长按中" : "未长按")")
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 3. 拖拽手势
                VStack {
                    Text("3. 拖拽手势")
                        .font(.headline)
                    Circle()
                        .fill(.red)
                        .frame(width: 60, height: 60)
                        .offset(dragOffset)
                        .gesture(
                            DragGesture()
                                .onChanged { value in
                                    dragOffset = value.translation
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        dragOffset = .zero
                                    }
                                }
                        )
                    Text("拖拽小球后自动归位")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 4. 缩放手势
                VStack {
                    Text("4. 缩放手势")
                        .font(.headline)
                    Rectangle()
                        .fill(.purple)
                        .frame(width: 120, height: 120)
                        .cornerRadius(10)
                        .scaleEffect(scale)
                        .gesture(
                            MagnificationGesture()
                                .onChanged { value in
                                    scale = value
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        scale = 1.0
                                    }
                                }
                        )
                    Text("双指缩放,松手复位")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 5. 旋转手势
                VStack {
                    Text("5. 旋转手势")
                        .font(.headline)
                    Rectangle()
                        .fill(.orange)
                        .frame(width: 120, height: 120)
                        .cornerRadius(10)
                        .rotationEffect(.degrees(rotation))
                        .gesture(
                            RotationGesture()
                                .onChanged { value in
                                    rotation = value.degrees
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        rotation = 0
                                    }
                                }
                        )
                    Text("双指旋转,松手复位")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 6. 组合手势
                VStack {
                    Text("6. 组合手势")
                        .font(.headline)
                    Rectangle()
                        .fill(.blue)
                        .frame(width: 120, height: 120)
                        .cornerRadius(10)
                        .offset(offset)
                        .scaleEffect(scale)
                        .rotationEffect(.degrees(rotation))
                        .gesture(
                            DragGesture()
                                .onChanged { value in
                                    offset = value.translation
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        offset = .zero
                                    }
                                }
                        )
                        .simultaneousGesture(
                            MagnificationGesture()
                                .onChanged { value in
                                    scale = value
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        scale = 1.0
                                    }
                                }
                        )
                        .simultaneousGesture(
                            RotationGesture()
                                .onChanged { value in
                                    rotation = value.degrees
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        rotation = 0
                                    }
                                }
                        )
                    Text("支持拖动、缩放、旋转")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 7. 双击计数
                VStack {
                    Text("7. 双击计数")
                        .font(.headline)
                    Rectangle()
                        .fill(.teal)
                        .frame(width: 200, height: 80)
                        .cornerRadius(10)
                        .onTapGesture(count: 2) {
                            tapCount += 1
                        }
                    Text("双击次数: \(tapCount)")
                    Button("重置") {
                        tapCount = 0
                    }
                    .buttonStyle(.bordered)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
}

#Preview {
    GestureAndInteractionDemo()
}

常见问题与解决方案

1. 手势不响应

问题:视图添加了手势,但没有响应。

解决方案

  • 确保视图有足够的大小(例如,不要将手势添加到 frame(width: 0, height: 0) 的视图上)
  • 检查视图是否被其他视图遮挡(使用 .contentShape(Rectangle()) 扩大可点击区域)
  • 确保没有其他手势冲突
// 扩大点击区域
Rectangle()
    .fill(.clear)
    .contentShape(Rectangle())  // 使透明区域也能响应手势
    .onTapGesture { }

2. 组合手势冲突

问题:多个手势同时应用时出现冲突。

解决方案

  • 使用 .simultaneousGesture() 修饰符来允许同时处理多个手势
  • 使用 .highPriorityGesture() 让某个手势优先
  • 使用 .gesture()including 参数控制手势识别行为
// 高优先级手势(会阻断其他手势)
view.highPriorityGesture(
    TapGesture().onEnded { }
)

// 同时识别多个手势
view.simultaneousGesture(dragGesture)
    .simultaneousGesture(rotationGesture)

3. 手势状态管理

问题:手势结束后状态没有正确更新。

解决方案

  • .onEnded 回调中正确更新状态
  • 对于需要持久化的状态,使用 @State@StateObject
DragGesture()
    .onChanged { value in
        // 实时更新
        offset = value.translation
    }
    .onEnded { value in
        // 结束时的处理
        withAnimation {
            offset = .zero
        }
    }

总结

本章介绍了 SwiftUI 中的手势与交互,包括:

  • 基本手势类型:点击、长按、拖拽、缩放、旋转
  • 手势的状态管理和数据处理
  • 组合手势的实现方法(.simultaneousGesture
  • 手势的高级应用技巧(优先级控制、自定义识别)

通过这些手势,可以使应用界面更加交互友好,提升用户体验。在实际开发中,合理使用手势可以为应用增添交互性,使界面操作更加直观自然。


参考资料


本内容为《SwiftUI 进阶》第三章,欢迎关注后续更新。

《SwiftUI 进阶学习第2章:动画与过渡》

学习目标

  • 掌握 SwiftUI 中的基本动画实现
  • 了解不同类型的动画效果
  • 学习如何创建组合动画
  • 掌握过渡效果的使用方法
  • 了解不同动画曲线的特点

核心概念

动画基础

在 SwiftUI 中,动画是通过 withAnimation 函数来实现的,它可以将状态变化包装在动画中,使 UI 变化更加平滑自然。

withAnimation {
    // 状态变化
}

动画类型

1. 淡入淡出动画

淡入淡出动画通过改变视图的不透明度来实现,可以使用 .transition(.opacity) 修饰符。

struct FadeAnimationDemo: View {
    @State private var isVisible = false
    
    var body: some View {
        VStack {
            Button("显示/隐藏") {
                withAnimation {
                    isVisible.toggle()
                }
            }
            
            if isVisible {
                Text("Hello, Animation!")
                    .transition(.opacity)
            }
        }
    }
}

2. 缩放动画

缩放动画通过改变视图的缩放比例来实现,可以使用 .scaleEffect() 修饰符。

struct ScaleAnimationDemo: View {
    @State private var scale = 1.0
    
    var body: some View {
        VStack {
            Button("缩放") {
                withAnimation(.spring()) {
                    scale = scale == 1.0 ? 1.5 : 1.0
                }
            }
            
            Circle()
                .fill(.red)
                .frame(width: 100, height: 100)
                .scaleEffect(scale)
        }
    }
}

3. 旋转动画

旋转动画通过改变视图的旋转角度来实现,可以使用 .rotationEffect() 修饰符。

struct RotationAnimationDemo: View {
    @State private var rotation = 0.0
    
    var body: some View {
        VStack {
            Button("旋转") {
                withAnimation(.easeInOut(duration: 1.0)) {
                    rotation += 360
                }
            }
            
            Rectangle()
                .fill(.yellow)
                .frame(width: 100, height: 100)
                .rotationEffect(.degrees(rotation))
        }
    }
}

4. 位移动画

位移动画通过改变视图的位置来实现,可以使用 .offset() 修饰符。

struct OffsetAnimationDemo: View {
    @State private var offset = CGSize.zero
    
    var body: some View {
        VStack {
            Button("移动") {
                withAnimation(.interactiveSpring()) {
                    offset = offset == .zero ? CGSize(width: 100, height: 50) : .zero
                }
            }
            
            Rectangle()
                .fill(.blue)
                .frame(width: 100, height: 100)
                .offset(offset)
        }
    }
}

5. 颜色动画

颜色动画通过改变视图的颜色来实现,可以直接动画化颜色属性。

struct ColorAnimationDemo: View {
    @State private var color = Color.blue
    
    var body: some View {
        VStack {
            Button("变色") {
                withAnimation(.easeInOut(duration: 1.0)) {
                    color = color == .blue ? .red : .blue
                }
            }
            
            Rectangle()
                .fill(color)
                .frame(width: 200, height: 100)
                .cornerRadius(10)
        }
    }
}

6. 组合动画

组合动画是将多种动画效果结合在一起,可以同时应用多个动画修饰符。

struct CombinedAnimationDemo: View {
    @State private var scale = 1.0
    @State private var rotation = 0.0
    @State private var opacity = 1.0
    
    var body: some View {
        VStack {
            Button("组合动画") {
                withAnimation(.easeInOut(duration: 1.0)) {
                    scale = scale == 1.0 ? 1.2 : 1.0
                    rotation = rotation == 0 ? 45 : 0
                    opacity = opacity == 1.0 ? 0.5 : 1.0
                }
            }
            
            Rectangle()
                .fill(.green)
                .frame(width: 100, height: 100)
                .scaleEffect(scale)
                .rotationEffect(.degrees(rotation))
                .opacity(opacity)
        }
    }
}

过渡效果

过渡效果是在视图出现或消失时应用的动画,可以使用 .transition() 修饰符。

struct TransitionDemo: View {
    @State private var isVisible = false
    
    var body: some View {
        VStack {
            Button("切换视图") {
                withAnimation {
                    isVisible.toggle()
                }
            }
            
            if isVisible {
                Text("滑入视图")
                    .transition(.slide)
            }
        }
    }
}

SwiftUI 提供了多种内置过渡效果:

过渡效果 描述
.opacity 淡入淡出
.slide 从边缘滑入/滑出
.scale 缩放出现/消失
.move(edge:) 从指定方向移动
.asymmetric(insertion:removal:) 不对称过渡(出现和消失用不同效果)

动画曲线

动画曲线定义了动画的速度变化,可以使用不同的动画曲线来实现不同的视觉效果。

常用动画曲线

曲线 描述
.linear 线性动画,速度保持不变
.easeIn 缓入动画,开始慢,逐渐加快
.easeOut 缓出动画,开始快,逐渐减慢
.easeInOut 缓入缓出动画,开始慢,中间快,结束慢
.spring() 弹簧动画,有弹性效果
.interactiveSpring() 交互式弹簧动画,响应更灵敏

示例代码

// 线性动画
withAnimation(.linear(duration: 1.0)) {
    // 动画代码
}

// 弹簧动画
withAnimation(.spring(response: 0.6, dampingFraction: 0.8, blendDuration: 0)) {
    // 动画代码
}

// 可重复动画
withAnimation(.easeInOut(duration: 1.0).repeatCount(3, autoreverses: true)) {
    // 动画代码
}

实践示例:完整动画演示

以下是一个完整的动画演示示例,包含了各种动画类型和过渡效果:

import SwiftUI

struct AnimationAndTransitionDemo: View {
    // 状态管理
    @State private var isVisible = false
    @State private var scale = 1.0
    @State private var rotation = 0.0
    @State private var opacity = 1.0
    @State private var offset = CGSize.zero
    @State private var color = Color.blue
    @State private var selectedCurve = "linear"
    
    let curveOptions = ["linear", "easeIn", "easeOut", "easeInOut", "spring"]
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("动画与过渡")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 动画曲线选择
                VStack {
                    Text("动画曲线选择")
                        .font(.headline)
                    Picker("曲线", selection: $selectedCurve) {
                        ForEach(curveOptions, id: \.self) { option in
                            Text(option).tag(option)
                        }
                    }
                    .pickerStyle(.segmented)
                }
                
                // 淡入淡出动画
                VStack {
                    Text("1. 淡入淡出")
                        .font(.headline)
                    Button("显示/隐藏") {
                        withAnimation(getAnimation()) {
                            isVisible.toggle()
                        }
                    }
                    if isVisible {
                        Text("Hello, Animation!")
                            .padding()
                            .background(Color.orange)
                            .cornerRadius(8)
                            .transition(.opacity)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 缩放动画
                VStack {
                    Text("2. 缩放动画")
                        .font(.headline)
                    Button("缩放") {
                        withAnimation(getAnimation()) {
                            scale = scale == 1.0 ? 1.5 : 1.0
                        }
                    }
                    Circle()
                        .fill(.red)
                        .frame(width: 80, height: 80)
                        .scaleEffect(scale)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 旋转动画
                VStack {
                    Text("3. 旋转动画")
                        .font(.headline)
                    Button("旋转") {
                        withAnimation(getAnimation()) {
                            rotation += 360
                        }
                    }
                    Rectangle()
                        .fill(.yellow)
                        .frame(width: 80, height: 80)
                        .rotationEffect(.degrees(rotation))
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 位移动画
                VStack {
                    Text("4. 位移动画")
                        .font(.headline)
                    Button("移动") {
                        withAnimation(getAnimation()) {
                            offset = offset == .zero ? CGSize(width: 100, height: 50) : .zero
                        }
                    }
                    Rectangle()
                        .fill(.blue)
                        .frame(width: 80, height: 80)
                        .offset(offset)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 颜色动画
                VStack {
                    Text("5. 颜色动画")
                        .font(.headline)
                    Button("变色") {
                        withAnimation(getAnimation()) {
                            color = color == .blue ? .red : .blue
                        }
                    }
                    Rectangle()
                        .fill(color)
                        .frame(width: 150, height: 80)
                        .cornerRadius(10)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 组合动画
                VStack {
                    Text("6. 组合动画")
                        .font(.headline)
                    Button("组合动画") {
                        withAnimation(getAnimation()) {
                            scale = scale == 1.0 ? 1.2 : 1.0
                            rotation = rotation == 0 ? 45 : 0
                            opacity = opacity == 1.0 ? 0.5 : 1.0
                        }
                    }
                    Rectangle()
                        .fill(.green)
                        .frame(width: 80, height: 80)
                        .scaleEffect(scale)
                        .rotationEffect(.degrees(rotation))
                        .opacity(opacity)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 过渡效果
                VStack {
                    Text("7. 过渡效果(Slide)")
                        .font(.headline)
                    Button("切换视图") {
                        withAnimation(getAnimation()) {
                            isVisible.toggle()
                        }
                    }
                    if isVisible {
                        Text("滑入视图")
                            .padding()
                            .background(Color.purple)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                            .transition(.slide)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
    
    // 根据选择的曲线返回对应的动画
    private func getAnimation() -> Animation {
        switch selectedCurve {
        case "linear":
            return .linear(duration: 0.8)
        case "easeIn":
            return .easeIn(duration: 0.8)
        case "easeOut":
            return .easeOut(duration: 0.8)
        case "easeInOut":
            return .easeInOut(duration: 0.8)
        case "spring":
            return .spring(response: 0.6, dampingFraction: 0.7)
        default:
            return .easeInOut(duration: 0.8)
        }
    }
}

#Preview {
    AnimationAndTransitionDemo()
}

常见问题与解决方案

1. 动画不生效

问题:状态变化了,但没有动画效果。

解决方案:确保状态变化被包裹在 withAnimation 函数中。

// 错误 ❌
isVisible.toggle()

// 正确 ✅
withAnimation {
    isVisible.toggle()
}

2. 动画效果不符合预期

问题:动画效果不够流畅或不符合预期。

解决方案:尝试使用不同的动画曲线,如 .spring().easeInOut(),并调整动画时长。

// 使用弹簧动画获得更自然的弹性效果
withAnimation(.spring(response: 0.5, dampingFraction: 0.6)) {
    // 状态变化
}

3. 过渡效果不显示

问题:视图出现或消失时没有过渡效果。

解决方案:确保为视图添加了 .transition() 修饰符,并且状态变化在 withAnimation 中。

if isVisible {
    Text("Hello")
        .transition(.slide)  // 必须添加 transition
}

4. 动画卡顿或掉帧

问题:动画执行时界面卡顿。

解决方案

  • 避免在动画中同时改变过多属性
  • 对于复杂视图,考虑使用 .drawingGroup() 优化渲染
  • 确保动画中不执行耗时操作

总结

本章介绍了 SwiftUI 中的动画与过渡效果,包括:

  • 基本动画类型:淡入淡出、缩放、旋转、位移、颜色动画
  • 组合动画:同时应用多种动画效果
  • 过渡效果:视图出现/消失时的动画(.transition
  • 动画曲线:线性、缓入、缓出、弹簧等不同速度曲线
  • 实践示例:完整的动画演示应用

通过这些动画效果,可以使应用界面更加生动有趣,提升用户体验。在实际开发中,合理使用动画可以为应用增添活力,使界面交互更加自然流畅。


参考资料


本内容为《SwiftUI 高级教程》第二章,欢迎关注后续更新。

《SwiftUI 进阶学习第1章:高级视图组件》

Snip20260418_5.png

概述

本章介绍 SwiftUI 中的高级视图组件,包括日期选择器、时间选择器、分段控件、滑块、步进器、活动指示器、进度视图和列表分组等。这些组件可以帮助您构建更加丰富和交互性更强的用户界面。

学习目标

  • 掌握各种高级视图组件的使用方法
  • 了解如何配置和自定义这些组件
  • 能够在实际项目中应用这些组件

核心组件

1. 日期选择器 (DatePicker)

功能说明

  • 可以只显示日期部分
  • 可以同时显示日期和时间
  • 支持多种显示样式

代码示例

DatePicker(
    "选择日期",
    selection: $selectedDate,
    displayedComponents: .date
)
.padding()
.background(.gray.opacity(0.1))
.cornerRadius(10)

2. 时间选择器 (DatePicker)

功能说明

  • 可以只显示小时和分钟
  • 支持24小时和12小时制

代码示例

DatePicker(
    "选择时间",
    selection: $selectedTime,
    displayedComponents: .hourAndMinute
)
.padding()
.background(.blue.opacity(0.1))
.cornerRadius(10)

3. 分段控件 (Picker)

功能说明

  • 使用 .segmented 样式
  • 支持多个选项
  • 可以绑定到状态变量

代码示例

Picker("选择选项", selection: .constant(0)) {
    Text("选项1").tag(0)
    Text("选项2").tag(1)
    Text("选项3").tag(2)
}
.pickerStyle(.segmented)
.padding()
.background(.green.opacity(0.1))
.cornerRadius(10)

4. 滑块 (Slider)

功能说明

  • 可以设置最小值和最大值
  • 支持步长
  • 可以显示当前值

代码示例

HStack {
    Text("音量: \(Int(progress * 100))%")
    Slider(value: $progress, in: 0...1)
}
.padding()
.background(.yellow.opacity(0.1))
.cornerRadius(10)

5. 步进器 (Stepper)

功能说明

  • 可以设置最小值、最大值和步长
  • 可以显示当前值
  • 支持自定义标签

代码示例

Stepper(
    "数量: \(Int(progress * 10))",
    value: $progress,
    in: 0...1,
    step: 0.1
)
.padding()
.background(.purple.opacity(0.1))
.cornerRadius(10)

6. 活动指示器 (ProgressView)

功能说明

  • 可以显示文本
  • 可以设置样式
  • 适合在异步操作时使用

代码示例

if isPlaying {
    ProgressView("加载中...")
        .padding()
}

7. 进度视图 (ProgressView)

功能说明

  • 可以设置当前值和总值
  • 支持动画效果
  • 适合显示下载、上传等进度

代码示例

ProgressView(value: progress)
    .padding()

Button("更新进度") {
    withAnimation {
        progress = progress < 1.0 ? progress + 0.1 : 0.0
    }
}

8. 列表分组 (List)

功能说明

  • 支持多个分组
  • 可以添加分组标题
  • 适合显示分类数据

代码示例

List {
    Section(header: Text("水果")) {
        Text("苹果")
        Text("香蕉")
        Text("橙子")
    }
    
    Section(header: Text("蔬菜")) {
        Text("西红柿")
        Text("黄瓜")
        Text("土豆")
    }
}
.frame(height: 200)

综合示例

以下是一个完整的高级视图组件演示:

struct AdvancedViewsDemo: View {
    // 状态管理
    @State private var selectedDate = Date()
    @State private var selectedTime = Date()
    @State private var isPlaying = false
    @State private var progress = 0.0
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                // 标题
                Text("高级视图组件")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 日期选择器
                VStack {
                    Text("1. 日期选择器")
                        .font(.headline)
                        .fontWeight(.semibold)
                    
                    DatePicker(
                        "选择日期",
                        selection: $selectedDate,
                        displayedComponents: .date
                    )
                    .padding()
                    .background(.gray.opacity(0.1))
                    .cornerRadius(10)
                }
                
                // 其他组件...
            }
            .padding()
        }
    }
}

最佳实践

  1. 响应式设计:确保组件在不同屏幕尺寸上都能正常显示
  2. 用户体验:为组件添加适当的标签和提示
  3. 性能优化:对于复杂列表,考虑使用 ListForEach 的性能优化技巧
  4. 可访问性:确保组件支持 VoiceOver 等辅助功能
  5. 自定义样式:根据应用的设计风格自定义组件的外观

总结

本章介绍了 SwiftUI 中的各种高级视图组件,包括日期选择器、时间选择器、分段控件、滑块、步进器、活动指示器、进度视图和列表分组。这些组件是构建现代 iOS 应用界面的重要工具,掌握它们的使用方法对于开发高质量的 SwiftUI 应用至关重要。

通过本章的学习,您应该能够:

  • 熟练使用各种高级视图组件
  • 根据实际需求配置和自定义组件
  • 构建具有良好用户体验的界面
  • 应用最佳实践来提高应用质量

参考资料


本内容为《SwiftUI 进阶学习》第一章,欢迎关注后续更新。

第5章:基础状态管理

Snip20260416_2.png

示例代码都放这里啦,有需要的可以下载学习。swiftUIDemo

5.1 @State:本地视图状态

@State 介绍

@State 是 SwiftUI 中最基本的状态管理工具,用于管理视图的本地状态。它是一个属性包装器,允许我们在结构体中创建可变状态。

基本用法

import SwiftUI

struct CounterView: View {
    // 使用 @State 声明本地状态
    @State private var count = 0
    
    var body: some View {
        VStack(spacing: 20) {
            // 显示状态值
            Text("Count: \(count)")
                .font(.largeTitle)
                .fontWeight(.bold)
            
            // 修改状态
            Button("Increment") {
                count += 1  // 状态改变,UI 自动更新
            }
            .buttonStyle(.borderedProminent)
            
            Button("Reset") {
                count = 0  // 状态重置
            }
            .buttonStyle(.bordered)
        }
        .padding()
    }
}

#Preview {
    CounterView()
}

工作原理

@State 的工作原理:

  1. 当你使用 @State 标记一个属性时,SwiftUI 会在底层为这个属性创建一个独立的存储
  2. 这个存储不受结构体值类型特性的影响,即使结构体被重新创建,状态也会保持
  3. 当状态值改变时,SwiftUI 会自动重新计算视图的 body 属性
  4. 系统会对比新旧视图树,只更新发生变化的部分

最佳实践

  1. 标记为 private@State 应该只在当前视图内部使用,所以应该标记为 private
  2. 初始值:必须为 @State 属性提供初始值
  3. 避免在 body 中修改:不要在 body 计算属性中直接修改 @State
  4. 简单类型@State 适合存储简单类型(如 Bool、Int、String 等)

5.2 @Binding:父子视图双向绑定

@Binding 介绍

@Binding 用于在父子视图之间创建双向绑定,允许子视图修改父视图的状态。

基本用法

import SwiftUI

// 父视图
struct ParentView: View {
    // 父视图的状态
    @State private var isPlaying = false
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Parent View")
                .font(.headline)
            
            Text("Is Playing: \(isPlaying ? "Yes" : "No")")
            
            // 使用 $ 符号创建绑定并传递给子视图
            PlayButton(isPlaying: $isPlaying)
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

// 子视图
struct PlayButton: View {
    // 使用 @Binding 接收父视图的状态引用
    @Binding var isPlaying: Bool
    
    var body: some View {
        Button(isPlaying ? "Pause" : "Play") {
            // 修改绑定值,会同步更新父视图的状态
            isPlaying.toggle()
        }
        .buttonStyle(.borderedProminent)
        .tint(isPlaying ? .red : .green)
        .padding()
        .background(Color.gray.opacity(0.05))
        .cornerRadius(8)
    }
}

#Preview {
    ParentView()
}

工作原理

@Binding 的工作原理:

  1. 它不是存储状态,而是创建一个对现有状态的引用
  2. 当子视图修改绑定值时,实际上是修改了原始的 @State 状态
  3. 状态的所有权仍然在父视图中
  4. 这种机制确保了单一数据源(SSOT)原则

实际应用

// 表单输入示例
struct FormView: View {
    @State private var username = ""
    @State private var email = ""
    
    var body: some View {
        VStack(spacing: 16) {
            Text("User Form")
                .font(.headline)
            
            TextFieldView(
                title: "Username",
                text: $username,
                placeholder: "Enter your username"
            )
            
            TextFieldView(
                title: "Email",
                text: $email,
                placeholder: "Enter your email"
            )
            
            Text("Username: \(username)")
            Text("Email: \(email)")
        }
        .padding()
    }
}

struct TextFieldView: View {
    let title: String
    @Binding var text: String
    let placeholder: String
    
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(title)
                .font(.subheadline)
                .fontWeight(.medium)
            
            TextField(
                placeholder,
                text: $text
            )
            .textFieldStyle(.roundedBorder)
        }
    }
}

5.3 @StateObject:可观察对象状态

@StateObject 介绍

@StateObject 用于管理符合 ObservableObject 协议的对象,适用于需要在多个视图之间共享的复杂状态。

基本用法

import SwiftUI
import Combine

// 可观察对象模型
class UserViewModel: ObservableObject {
    // 使用 @Published 标记需要发布的属性
    @Published var username = ""
    @Published var email = ""
    @Published var isLoggedIn = false
    
    func login() {
        // 模拟登录操作
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.isLoggedIn = true
        }
    }
    
    func logout() {
        username = ""
        email = ""
        isLoggedIn = false
    }
}

struct UserView: View {
    // 使用 @StateObject 管理可观察对象
    @StateObject private var viewModel = UserViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            Text("User Profile")
                .font(.headline)
            
            if viewModel.isLoggedIn {
                Text("Welcome, \(viewModel.username)!")
                    .font(.title)
                Text("Email: \(viewModel.email)")
                
                Button("Logout") {
                    viewModel.logout()
                }
                .buttonStyle(.borderedProminent)
                .tint(.red)
            } else {
                TextField("Username", text: $viewModel.username)
                    .textFieldStyle(.roundedBorder)
                TextField("Email", text: $viewModel.email)
                    .textFieldStyle(.roundedBorder)
                
                Button("Login") {
                    viewModel.login()
                }
                .buttonStyle(.borderedProminent)
                .disabled(viewModel.username.isEmpty || viewModel.email.isEmpty)
            }
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

#Preview {
    UserView()
}

工作原理

@StateObject 的工作原理:

  1. 它会创建并拥有一个符合 ObservableObject 协议的对象
  2. 当对象的 @Published 属性改变时,所有使用该对象的视图都会自动更新
  3. 即使视图被重新创建,@StateObject 也会保持对象的生命周期
  4. 适用于需要在多个视图之间共享的复杂状态

最佳实践

  1. 用于复杂状态:适用于包含多个相关属性的复杂状态
  2. 单一数据源:作为状态的唯一来源
  3. 生命周期管理:由 SwiftUI 管理对象的生命周期
  4. 性能考虑:对于大型对象,考虑使用更细粒度的状态管理

5.4 @ObservedObject:观察外部对象

@ObservedObject 介绍

@ObservedObject 用于观察外部传入的符合 ObservableObject 协议的对象,适用于从父视图传递的可观察对象。

基本用法

import SwiftUI

// 父视图
struct ParentWithObservedObject: View {
    // 父视图拥有状态对象
    @StateObject private var userViewModel = UserViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Parent View")
                .font(.headline)
            
            // 传递给子视图
            ChildView(viewModel: userViewModel)
        }
        .padding()
    }
}

// 子视图
struct ChildView: View {
    // 使用 @ObservedObject 观察外部对象
    @ObservedObject var viewModel: UserViewModel
    
    var body: some View {
        VStack(spacing: 16) {
            Text("Child View")
                .font(.subheadline)
            
            TextField("Username", text: $viewModel.username)
                .textFieldStyle(.roundedBorder)
            
            TextField("Email", text: $viewModel.email)
                .textFieldStyle(.roundedBorder)
            
            Button("Login") {
                viewModel.login()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

#Preview {
    ParentWithObservedObject()
}

工作原理

@ObservedObject 的工作原理:

  1. 它不拥有对象,只是观察外部传入的对象
  2. 当对象的 @Published 属性改变时,视图会自动更新
  3. 对象的生命周期由其拥有者管理
  4. 适用于从父视图传递的可观察对象

与 @StateObject 的区别

特性 @StateObject @ObservedObject
所有权 拥有对象,管理生命周期 观察对象,不管理生命周期
初始化 在视图中直接初始化 从外部传入
适用场景 作为状态的唯一来源 观察父视图传递的对象
性能 更高效,避免重复创建 可能会因父视图重建而重复创建

5.5 @EnvironmentObject:全局环境对象

@EnvironmentObject 介绍

@EnvironmentObject 用于访问通过环境传递的全局可观察对象,适用于跨多个视图层级共享的状态。

基本用法

import SwiftUI

// 全局状态模型
class AppState: ObservableObject {
    @Published var isDarkMode = false
    @Published var userLanguage = "zh"
    
    func toggleDarkMode() {
        isDarkMode.toggle()
    }
    
    func changeLanguage(to language: String) {
        userLanguage = language
    }
}

// 主视图 - 设置环境对象
struct MainView: View {
    @StateObject private var appState = AppState()
    
    var body: some View {
        NavigationStack {
            VStack {
                Text("Main View")
                    .font(.headline)
                
                NavigationLink("Settings", destination: SettingsView())
                NavigationLink("Profile", destination: ProfileView())
            }
            .padding()
        }
        // 通过环境传递对象
        .environmentObject(appState)
    }
}

// 设置视图 - 访问环境对象
struct SettingsView: View {
    // 通过 @EnvironmentObject 访问全局对象
    @EnvironmentObject private var appState: AppState
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Settings")
                .font(.headline)
            
            Toggle("Dark Mode", isOn: $appState.isDarkMode)
            
            Picker("Language", selection: $appState.userLanguage) {
                Text("English").tag("en")
                Text("中文").tag("zh")
                Text("日本語").tag("ja")
            }
            .pickerStyle(.segmented)
        }
        .padding()
        .background(appState.isDarkMode ? Color.black : Color.white)
        .foregroundColor(appState.isDarkMode ? Color.white : Color.black)
    }
}

// 个人资料视图 - 访问环境对象
struct ProfileView: View {
    @EnvironmentObject private var appState: AppState
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Profile")
                .font(.headline)
            
            Text("Current Language: \(appState.userLanguage)")
            Text("Dark Mode: \(appState.isDarkMode ? "On" : "Off")")
        }
        .padding()
        .background(appState.isDarkMode ? Color.black : Color.white)
        .foregroundColor(appState.isDarkMode ? Color.white : Color.black)
    }
}

#Preview {
    MainView()
}

工作原理

@EnvironmentObject 的工作原理:

  1. 它从环境中查找指定类型的可观察对象
  2. 当对象的 @Published 属性改变时,所有使用该对象的视图都会自动更新
  3. 不需要手动传递对象,通过环境自动注入
  4. 适用于跨多个视图层级共享的全局状态

最佳实践

  1. 全局状态:用于应用级别的全局状态
  2. 依赖注入:通过环境进行依赖注入,避免层层传递
  3. 类型安全:基于类型查找,确保类型正确
  4. 错误处理:确保在使用前在环境中设置了对象

5.6 @Environment:环境值

@Environment 介绍

@Environment 用于访问 SwiftUI 环境中的系统值,如布局方向、颜色方案、字体大小等。

基本用法

import SwiftUI

struct EnvironmentValuesView: View {
    // 访问环境值
    @Environment(\.colorScheme) private var colorScheme
    @Environment(\.layoutDirection) private var layoutDirection
    @Environment(\.dynamicTypeSize) private var dynamicTypeSize
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    
    var body: some View {
        VStack(spacing: 16) {
            Text("Environment Values")
                .font(.headline)
            
            Text("Color Scheme: \(colorScheme == .dark ? "Dark" : "Light")")
            Text("Layout Direction: \(layoutDirection == .leftToRight ? "LTR" : "RTL")")
            Text("Dynamic Type Size: \(dynamicTypeSize.description)")
            Text("Horizontal Size Class: \(horizontalSizeClass == .regular ? "Regular" : "Compact")")
            
            // 根据环境值调整布局
            if horizontalSizeClass == .regular {
                HStack {
                    Text("Wide Layout")
                    Spacer()
                    Text("More Content")
                }
            } else {
                VStack {
                    Text("Narrow Layout")
                    Text("Content Below")
                }
            }
        }
        .padding()
        .background(colorScheme == .dark ? Color.black : Color.white)
        .foregroundColor(colorScheme == .dark ? Color.white : Color.black)
    }
}

#Preview {
    EnvironmentValuesView()
}

常用环境值

环境值 类型 描述
\.colorScheme ColorScheme 当前颜色方案(浅色/深色)
\.layoutDirection LayoutDirection 布局方向(LTR/RTL)
\.dynamicTypeSize DynamicTypeSize 动态字体大小
\.horizontalSizeClass UserInterfaceSizeClass? 水平尺寸类
\.verticalSizeClass UserInterfaceSizeClass? 垂直尺寸类
\.locale Locale 当前区域设置
\.calendar Calendar 当前日历
\.timeZone TimeZone 当前时区
\.accessibilityEnabled Bool 是否启用辅助功能
\.scenePhase ScenePhase 场景阶段(活跃/非活跃/背景)

工作原理

@Environment 的工作原理:

  1. 它从 SwiftUI 环境中读取指定的环境值
  2. 当环境值改变时,视图会自动更新
  3. 环境值由系统或父视图设置
  4. 适用于响应系统设置和环境变化

5.7 @SceneStorage:场景存储

@SceneStorage 介绍

@SceneStorage 用于在场景级别持久化存储简单数据,适用于保存用户偏好设置和状态。

基本用法

import SwiftUI

struct SceneStorageView: View {
    // 使用 @SceneStorage 存储数据
    @SceneStorage("username") private var username = ""
    @SceneStorage("isDarkMode") private var isDarkMode = false
    @SceneStorage("counter") private var counter = 0
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Scene Storage")
                .font(.headline)
            
            TextField("Username", text: $username)
                .textFieldStyle(.roundedBorder)
            
            Toggle("Dark Mode", isOn: $isDarkMode)
            
            VStack {
                Text("Counter: \(counter)")
                HStack {
                    Button("Increment") {
                        counter += 1
                    }
                    .buttonStyle(.bordered)
                    
                    Button("Reset") {
                        counter = 0
                    }
                    .buttonStyle(.bordered)
                }
            }
            
            Text("Note: Data persists across app restarts")
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .padding()
        .background(isDarkMode ? Color.black : Color.white)
        .foregroundColor(isDarkMode ? Color.white : Color.black)
    }
}

#Preview {
    SceneStorageView()
}

工作原理

@SceneStorage 的工作原理:

  1. 它将数据存储在场景的 UserDefaults 中
  2. 数据会在场景重启后保持
  3. 每个场景有自己的存储,不同场景之间数据隔离
  4. 适用于存储用户偏好设置和临时状态

最佳实践

  1. 简单数据:适合存储简单类型(String、Int、Bool 等)
  2. 场景隔离:每个场景有独立的存储
  3. 自动持久化:数据自动保存,无需手动管理
  4. 键名唯一性:使用唯一的键名避免冲突

5.8 @AppStorage:应用存储

@AppStorage 介绍

@AppStorage 用于在应用级别持久化存储简单数据,适用于保存全局用户偏好设置。

基本用法

import SwiftUI

struct AppStorageView: View {
    // 使用 @AppStorage 存储数据
    @AppStorage("userName") private var userName = "Guest"
    @AppStorage("appTheme") private var appTheme = "light"
    @AppStorage("notificationsEnabled") private var notificationsEnabled = true
    
    var body: some View {
        VStack(spacing: 20) {
            Text("App Storage")
                .font(.headline)
            
            TextField("User Name", text: $userName)
                .textFieldStyle(.roundedBorder)
            
            Picker("Theme", selection: $appTheme) {
                Text("Light").tag("light")
                Text("Dark").tag("dark")
                Text("Auto").tag("auto")
            }
            .pickerStyle(.segmented)
            
            Toggle("Enable Notifications", isOn: $notificationsEnabled)
            
            Text("Note: Data persists across app reinstalls")
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .padding()
        .background(getThemeColor())
        .foregroundColor(appTheme == "dark" ? Color.white : Color.black)
    }
    
    private func getThemeColor() -> Color {
        switch appTheme {
        case "dark":
            return Color.black
        case "light":
            return Color.white
        default:
            return Color.white
        }
    }
}

#Preview {
    AppStorageView()
}

工作原理

@AppStorage 的工作原理:

  1. 它将数据存储在应用的 UserDefaults 中
  2. 数据会在应用重启后保持
  3. 所有场景共享相同的存储
  4. 适用于存储全局用户偏好设置

与 @SceneStorage 的区别

特性 @AppStorage @SceneStorage
存储范围 应用级别,所有场景共享 场景级别,每个场景独立
持久化 持久化到 UserDefaults 持久化到场景的 UserDefaults
适用场景 全局偏好设置 场景特定状态
数据共享 跨场景共享 场景隔离

5.9 @FocusedValue:聚焦值

@FocusedValue 介绍

@FocusedValue 用于在视图层次结构中传递聚焦状态相关的值,适用于处理键盘焦点和上下文相关操作。

基本用法

import SwiftUI

// 定义聚焦值键
struct EditModeKey: FocusedValueKey {
    typealias Value = Bool
}

// 扩展 FocusedValues
extension FocusedValues {
    var isEditMode: EditModeKey.Value? {
        get { self[EditModeKey.self] }
        set { self[EditModeKey.self] = newValue }
    }
}

struct FocusedValueView: View {
    @State private var isEditMode = false
    @State private var text = "Hello, SwiftUI"
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Focused Value")
                .font(.headline)
            
            // 设置聚焦值
            TextField("Enter text", text: $text)
                .textFieldStyle(.roundedBorder)
                .focusedValue(\.isEditMode, true)
            
            Button("Toggle Edit Mode") {
                isEditMode.toggle()
            }
            .buttonStyle(.borderedProminent)
            
            // 子视图访问聚焦值
            FocusedChildView()
        }
        .padding()
        .environment(\.isEditMode, isEditMode)
    }
}

struct FocusedChildView: View {
    // 访问聚焦值
    @FocusedValue(\.isEditMode) private var isEditMode
    
    var body: some View {
        VStack {
            Text("Child View")
                .font(.subheadline)
            
            Text("Edit Mode: \(isEditMode ?? false ? "On" : "Off")")
            
            if isEditMode ?? false {
                Text("Editing is enabled!")
                    .foregroundColor(.green)
            }
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

#Preview {
    FocusedValueView()
}

工作原理

@FocusedValue 的工作原理:

  1. 它通过 FocusedValues 字典传递值
  2. 当焦点变化时,聚焦值会自动更新
  3. 适用于与焦点相关的上下文信息
  4. 可以自定义聚焦值键

适用场景

  1. 键盘焦点:跟踪当前聚焦的视图
  2. 上下文操作:根据聚焦状态显示不同的操作
  3. 编辑模式:在编辑模式下显示额外的控件
  4. 工具栏配置:根据当前聚焦的内容配置工具栏

5.10 状态驱动 UI 更新原理

核心原理

SwiftUI 的核心设计哲学是状态驱动

UI = f(State)

这意味着:

  1. UI 是状态的函数
  2. 当状态改变时,UI 会自动更新
  3. 给定相同的状态,总是渲染相同的 UI

更新流程

当状态改变时,SwiftUI 的更新流程如下:

  1. 状态改变:用户操作或其他因素导致状态值发生变化
  2. 检测变化:SwiftUI 检测到状态变化
  3. 重新计算 body:重新调用受影响视图的 body 计算属性
  4. 构建新视图树:生成新的视图层次结构
  5. 对比差异:对比新旧视图树,找出变化的部分
  6. 更新 UI:只更新发生变化的部分,保持其他部分不变

性能优化

SwiftUI 的状态驱动机制本身就很高效,因为:

  1. 增量更新:只更新变化的部分
  2. 值类型:视图是轻量级的值类型,创建成本低
  3. 智能对比:使用高效的差异算法
  4. 批处理:合并多个状态更新,减少渲染次数
  5. 懒加载:只渲染可见的部分

5.11 状态管理最佳实践

1. 选择合适的状态管理工具

状态类型 推荐工具 适用场景
本地简单状态 @State 单个视图的内部状态
父子视图共享 @Binding 子视图需要修改父视图状态
复杂对象状态 @StateObject 多个属性的复杂状态
外部对象引用 @ObservedObject 观察父视图传递的对象
全局共享状态 @EnvironmentObject 跨多个视图的全局状态
系统环境值 @Environment 访问系统设置和环境
场景级持久化 @SceneStorage 场景特定的持久状态
应用级持久化 @AppStorage 全局用户偏好设置
聚焦相关状态 @FocusedValue 与焦点相关的上下文信息

2. 状态管理原则

  1. 单一数据源:每个状态应该有唯一的来源
  2. 状态提升:将状态提升到需要访问它的所有视图的共同父视图
  3. 最小化状态:只存储必要的状态,避免冗余
  4. 状态隔离:将相关状态组织在一起,避免混乱
  5. 可测试性:状态管理应该易于测试
  6. 性能考虑:对于大型状态,考虑使用更细粒度的更新

3. 性能优化技巧

  1. 使用 Equatable:为模型实现 Equatable 协议,避免不必要的更新
  2. 视图分离:将复杂视图拆分为更小的子视图
  3. @State 用于简单类型@State 适合存储简单类型,复杂类型使用 @StateObject
  4. 避免在 body 中创建对象:不要在 body 计算属性中创建新对象
  5. 使用 .id() 强制更新:当需要强制视图更新时使用
  6. 考虑使用 Combine:对于复杂的异步操作,使用 Combine 框架

实战:创建一个完整的状态管理示例

需求分析

创建一个包含多种状态管理技术的应用,包括:

  1. 本地状态管理
  2. 父子视图绑定
  3. 可观察对象
  4. 环境对象
  5. 持久化存储

代码实现

import SwiftUI
import Combine

// 全局应用状态
class AppState: ObservableObject {
    @Published var isDarkMode = false
    @Published var currentUser: User? = nil
    
    func toggleTheme() {
        isDarkMode.toggle()
    }
    
    func login(user: User) {
        currentUser = user
    }
    
    func logout() {
        currentUser = nil
    }
}

// 用户模型
struct User: Identifiable, Equatable {
    let id = UUID()
    let name: String
    let email: String
}

// 主应用视图
struct StateManagementDemo: View {
    @StateObject private var appState = AppState()
    @AppStorage("lastLoggedInUser") private var lastLoggedInUser = ""
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                Text("状态管理演示")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                
                // 主题切换
                Toggle("深色模式", isOn: $appState.isDarkMode)
                
                // 用户登录状态
                if appState.currentUser != nil {
                    Text("欢迎, \(appState.currentUser?.name ?? "")!")
                    Button("退出登录") {
                        appState.logout()
                        lastLoggedInUser = ""
                    }
                    .buttonStyle(.borderedProminent)
                    .tint(.red)
                } else {
                    LoginView()
                }
                
                // 导航链接
                NavigationLink("计数器示例", destination: CounterView())
                NavigationLink("待办事项示例", destination: TodoApp())
                NavigationLink("环境对象示例", destination: EnvironmentObjectDemo())
            }
            .padding()
        }
        .environmentObject(appState)
        .preferredColorScheme(appState.isDarkMode ? .dark : .light)
    }
}

// 登录视图
struct LoginView: View {
    @State private var name = ""
    @State private var email = ""
    @EnvironmentObject private var appState: AppState
    @AppStorage("lastLoggedInUser") private var lastLoggedInUser = ""
    
    var body: some View {
        VStack(spacing: 16) {
            TextField("姓名", text: $name)
                .textFieldStyle(.roundedBorder)
            TextField("邮箱", text: $email)
                .textFieldStyle(.roundedBorder)
            Button("登录") {
                let user = User(name: name, email: email)
                appState.login(user: user)
                lastLoggedInUser = name
            }
            .buttonStyle(.borderedProminent)
            .disabled(name.isEmpty || email.isEmpty)
            
            if !lastLoggedInUser.isEmpty {
                Text("上次登录: \(lastLoggedInUser)")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

// 计数器视图
struct CounterView: View {
    @State private var count = 0
    @SceneStorage("counterValue") private var storedCount = 0
    
    var body: some View {
        VStack(spacing: 20) {
            Text("计数器")
                .font(.headline)
            Text("Count: \(count)")
                .font(.largeTitle)
                .fontWeight(.bold)
            HStack(spacing: 16) {
                Button("减1") {
                    count -= 1
                    storedCount = count
                }
                .buttonStyle(.bordered)
                Button("重置") {
                    count = 0
                    storedCount = 0
                }
                .buttonStyle(.bordered)
                Button("加1") {
                    count += 1
                    storedCount = count
                }
                .buttonStyle(.borderedProminent)
            }
            Text("场景存储值: \(storedCount)")
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .padding()
        .onAppear {
            // 从场景存储恢复
            count = storedCount
        }
    }
}

// 待办事项应用
struct TodoApp: View {
    @State private var todos: [TodoItem] = [
        TodoItem(title: "学习 SwiftUI 状态管理"),
        TodoItem(title: "完成本章练习"),
        TodoItem(title: "构建示例应用")
    ]
    @State private var newTodoTitle = ""
    
    var body: some View {
        VStack {
            HStack(spacing: 8) {
                TextField("输入新的待办事项", text: $newTodoTitle)
                    .textFieldStyle(.roundedBorder)
                Button("添加") {
                    addTodo()
                }
                .buttonStyle(.borderedProminent)
                .disabled(newTodoTitle.isEmpty)
            }
            .padding()
            List {
                ForEach($todos) { $todo in
                    HStack {
                        Button(action: {
                            todo.isCompleted.toggle()
                        }) {
                            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                .foregroundColor(todo.isCompleted ? .green : .gray)
                        }
                        .buttonStyle(.plain)
                        Text(todo.title)
                            .strikethrough(todo.isCompleted, color: .gray)
                            .foregroundColor(todo.isCompleted ? .secondary : .primary)
                        Spacer()
                        Button(action: {
                            deleteTodo(todo)
                        }) {
                            Image(systemName: "trash.fill")
                                .foregroundColor(.red)
                        }
                        .buttonStyle(.plain)
                    }
                }
            }
        }
        .navigationTitle("待办事项")
    }
    
    private func addTodo() {
        guard !newTodoTitle.isEmpty else { return }
        todos.append(TodoItem(title: newTodoTitle))
        newTodoTitle = ""
    }
    
    private func deleteTodo(_ todo: TodoItem) {
        if let index = todos.firstIndex(where: { $0.id == todo.id }) {
            todos.remove(at: index)
        }
    }
}

// 待办事项模型
struct TodoItem: Identifiable, Equatable {
    let id = UUID()
    var title: String
    var isCompleted = false
}

// 环境对象演示
struct EnvironmentObjectDemo: View {
    @EnvironmentObject private var appState: AppState
    
    var body: some View {
        VStack(spacing: 20) {
            Text("环境对象演示")
                .font(.headline)
            Text("当前主题: \(appState.isDarkMode ? "深色" : "浅色")")
            Text("登录状态: \(appState.currentUser != nil ? "已登录" : "未登录")")
            if let user = appState.currentUser {
                Text("用户: \(user.name)")
                Text("邮箱: \(user.email)")
            }
            Button("切换主题") {
                appState.toggleTheme()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

#Preview {
    StateManagementDemo()
}

代码解析

  1. AppState:使用 @StateObject 管理全局应用状态
  2. User:符合 IdentifiableEquatable 协议的用户模型
  3. StateManagementDemo:主应用视图,设置环境对象
  4. LoginView:使用 @State@AppStorage 管理登录状态
  5. CounterView:使用 @State@SceneStorage 管理计数器
  6. TodoApp:使用 @State 管理待办事项列表
  7. EnvironmentObjectDemo:使用 @EnvironmentObject 访问全局状态

小结

本章详细介绍了 SwiftUI 中的状态管理系统,包括:

  • @State:用于管理视图的本地状态
  • @Binding:用于父子视图之间的双向绑定
  • @StateObject:用于管理可观察对象的状态
  • @ObservedObject:用于观察外部传入的可观察对象
  • @EnvironmentObject:用于访问全局环境对象
  • @Environment:用于访问系统环境值
  • @SceneStorage:用于场景级别的持久化存储
  • @AppStorage:用于应用级别的持久化存储
  • @FocusedValue:用于传递聚焦相关的值
  • 状态驱动 UI 更新的原理
  • 状态管理的最佳实践
  • 一个完整的状态管理示例应用

通过本章的学习,你已经掌握了 SwiftUI 中所有的状态管理技术,能够根据不同的场景选择合适的状态管理工具,创建具有复杂交互功能的应用。


参考资料


本内容为《SwiftUI 基础教程》第五章,欢迎关注后续更新。

第4章:基础布局系统

Snip20260416_1.png

Snip20260416_2.png

示例代码都放这里啦,有需要的可以下载学习。swiftUIDemo

第4章:基础布局系统

4.1 垂直布局:VStack

VStack 介绍

VStack 是 SwiftUI 中用于垂直堆叠视图的容器,它会将子视图按垂直方向排列。VStack 是构建垂直布局的基础组件,适用于需要从上到下排列的界面元素。

基本用法


// 基本垂直栈

VStack {

    Text("第一行")

    Text("第二行")

    Text("第三行")

}

  


// 带间距和对齐的垂直栈

VStack(alignment: .leading, spacing: 20) {

    Text("左对齐")

    Text("第二行")

    Text("第三行")

}

.padding()

对齐方式

VStack 提供了三种主要的对齐方式:

  • .leading:左对齐

  • .center:居中对齐(默认)

  • .trailing:右对齐

  • .top.bottom:在嵌套布局中使用


// 不同对齐方式

VStack(alignment: .leading) {

    Text("左对齐")

    Text("这是一行更长的文本")

    Text("短文本")

}

.padding()

  


VStack(alignment: .center) {

    Text("居中对齐")

    Text("这是一行更长的文本")

    Text("短文本")

}

.padding()

  


VStack(alignment: .trailing) {

    Text("右对齐")

    Text("这是一行更长的文本")

    Text("短文本")

}

.padding()

嵌套 VStack

VStack 可以嵌套使用,创建更复杂的布局结构。


// 嵌套垂直栈

VStack(spacing: 10) {

    Text("标题")

        .font(.headline)

    

    VStack(alignment: .leading, spacing: 8) {

        Text("项目 1")

        Text("项目 2")

        Text("项目 3")

    }

    .padding()

    .background(Color.gray.opacity(0.1))

    .cornerRadius(8)

    

    Button("确认") {}

}

.padding()

适用场景

  • 表单布局:从上到下排列的输入字段

  • 列表项:垂直排列的内容块

  • 页面结构:标题、内容、按钮的垂直布局

  • 卡片式布局:垂直堆叠的信息卡片

性能考虑

  • VStack 会根据子视图的大小自动调整高度

  • 对于大量子视图,考虑使用 LazyVStack 来提高性能

  • 避免过深的嵌套,可能会影响渲染性能

4.2 水平布局:HStack

HStack 介绍

HStack 是 SwiftUI 中用于水平堆叠视图的容器,它会将子视图按水平方向排列。HStack 是构建水平布局的基础组件,适用于需要从左到右排列的界面元素。

基本用法


// 基本水平栈

HStack {

    Text("左侧")

    Text("中间")

    Text("右侧")

}

  


// 带间距和对齐的水平栈

HStack(alignment: .top, spacing: 20) {

    Text("顶部对齐")

    Text("这是一行\n多行文本")

    Text("短文本")

}

.padding()

对齐方式

HStack 提供了三种主要的对齐方式:

  • .top:顶部对齐

  • .center:居中对齐(默认)

  • .bottom:底部对齐

  • .leading.trailing:在嵌套布局中使用


// 不同对齐方式

HStack(alignment: .top) {

    Text("顶部对齐")

    Text("这是一行\n多行文本")

    Text("短文本")

}

.padding()

  


HStack(alignment: .center) {

    Text("居中对齐")

    Text("这是一行\n多行文本")

    Text("短文本")

}

.padding()

  


HStack(alignment: .bottom) {

    Text("底部对齐")

    Text("这是一行\n多行文本")

    Text("短文本")

}

.padding()

空间分配

HStack 可以使用 Spacer 来分配空间,实现更灵活的布局。


// 空间分配

HStack {

    Text("左侧")

    Spacer()  // 占据剩余空间

    Text("右侧")

}

.padding()

  


// 带比例的空间分配

HStack {

    Text("1/4 宽度")

        .frame(maxWidth: .infinity)

    Spacer()

    Text("1/4 宽度")

        .frame(maxWidth: .infinity)

    Spacer()

    Text("1/4 宽度")

        .frame(maxWidth: .infinity)

    Spacer()

    Text("1/4 宽度")

        .frame(maxWidth: .infinity)

}

.padding()

适用场景

  • 工具栏:水平排列的操作按钮

  • 列表项内容:左侧图标、中间文本、右侧箭头

  • 表单行:标签和输入框的水平排列

  • 导航栏:左侧返回按钮、中间标题、右侧操作按钮

性能考虑

  • HStack 会根据子视图的大小自动调整宽度

  • 对于大量子视图,考虑使用 LazyHStack 来提高性能

  • 注意水平空间不足时的布局行为,可能需要使用 ScrollView

4.3 层叠布局:ZStack

ZStack 介绍

ZStack 是 SwiftUI 中用于层叠视图的容器,它会将子视图按层叠方式排列,后面的视图会覆盖前面的视图。ZStack 是构建叠加效果的基础组件,适用于需要层级关系的界面元素。

基本用法


// 基本层叠

ZStack {

    Color.blue  // 背景

    Text("前景文本")

        .foregroundStyle(.white)

        .font(.largeTitle)

}

.frame(height: 200)

  


// 多层叠

ZStack {

    // 底层

    Rectangle()

        .fill(Color.yellow)

        .frame(width: 200, height: 200)

    

    // 中层

    Circle()

        .fill(Color.green)

        .frame(width: 150, height: 150)

    

    // 顶层

    Text("ZStack")

        .font(.headline)

}

对齐方式

ZStack 提供了多种对齐方式,可以精确控制子视图的位置:

  • .topLeading.top.topTrailing

  • .leading.center.trailing

  • .bottomLeading.bottom.bottomTrailing


// 不同对齐方式

ZStack(alignment: .topLeading) {

    Rectangle()

        .fill(Color.gray.opacity(0.2))

        .frame(width: 300, height: 200)

    

    Text("左上角")

        .padding(10)

}

  


ZStack(alignment: .center) {

    Rectangle()

        .fill(Color.gray.opacity(0.2))

        .frame(width: 300, height: 200)

    

    Text("居中")

}

  


ZStack(alignment: .bottomTrailing) {

    Rectangle()

        .fill(Color.gray.opacity(0.2))

        .frame(width: 300, height: 200)

    

    Text("右下角")

        .padding(10)

}

实际应用


// 带徽章的图标

ZStack(alignment: .topTrailing) {

    Image(systemName: "bell")

        .font(.system(size: 24))

    

    Circle()

        .fill(Color.red)

        .frame(width: 16, height: 16)

        .overlay {

            Text("3")

                .font(.system(size: 10))

                .foregroundStyle(.white)

        }

        .offset(x: 4, y: -4)

}

  


// 卡片覆盖效果

ZStack {

    RoundedRectangle(cornerRadius: 12)

        .fill(Color.white)

        .shadow(radius: 4)

        .frame(width: 300, height: 200)

    

    VStack {

        Text("卡片标题")

            .font(.headline)

        Text("卡片内容")

            .foregroundStyle(.secondary)

    }

    .padding()

    

    // 右上角标签

    Text("NEW")

        .font(.caption)

        .foregroundStyle(.white)

        .padding(4)

        .background(Color.blue)

        .cornerRadius(4)

        .offset(x: 45, y: -10)

}

适用场景

  • 带背景的文本:文本叠加在背景之上

  • 徽章效果:通知图标上的数字徽章

  • 卡片布局:带有覆盖元素的信息卡片

  • 复杂 UI 组件:需要多层叠加的自定义控件

  • 模态视图:半透明覆盖层

性能考虑

  • ZStack 会按照添加顺序渲染视图,后面的视图会覆盖前面的

  • 对于复杂的叠加效果,注意渲染性能

  • 考虑使用 offset 修饰符来微调子视图位置

4.4 间距与对齐

间距设置

间距是布局中的重要因素,它决定了视图之间的关系和视觉舒适度。


// VStack 间距

VStack(spacing: 16) {

    Text("项目 1")

    Text("项目 2")

    Text("项目 3")

}

  


// HStack 间距

HStack(spacing: 20) {

    Text("左")

    Text("中")

    Text("右")

}

  


// 嵌套栈的间距

VStack(spacing: 20) {

    Text("标题")

    

    HStack(spacing: 10) {

        Button("按钮 1") {}

        Button("按钮 2") {}

    }

    

    Text("底部文本")

}

对齐设置

对齐决定了视图在容器中的位置,影响整体布局的一致性。


// 垂直对齐

VStack(alignment: .leading) {

    Text("左对齐")

    Text("这是一行更长的文本")

}

  


// 水平对齐

HStack(alignment: .center) {

    Text("顶部")

        .font(.largeTitle)

    Text("底部")

        .font(.footnote)

}

  


// 层叠对齐

ZStack(alignment: .bottom) {

    Image(systemName: "photo")

        .resizable()

        .aspectRatio(contentMode: .fit)

        .frame(height: 200)

    

    Text("图片标题")

        .padding()

        .background(Color.black.opacity(0.5))

        .foregroundStyle(.white)

        .frame(maxWidth: .infinity, alignment: .center)

}

内边距与外边距

内边距(padding)和外边距是控制视图与其他元素之间空间的重要工具。


// 内边距

VStack {

    Text("带内边距的文本")

        .padding()  // 四周内边距

    

    Text("自定义内边距")

        .padding(.horizontal, 20// 水平内边距

        .padding(.vertical, 10)    // 垂直内边距

}

  


// 外边距

VStack {

    Text("带外边距的文本")

}

.padding()  // 给整个 VStack 添加内边距

  


// 组合使用

Text("文本")

    .padding(10// 内边距

    .background(Color.yellow)

    .padding(10// 外边距(看起来像内边距)

    .background(Color.blue)

适用场景

  • 表单设计:通过间距和对齐创建整齐的表单

  • 卡片布局:使用内边距和外边距创建视觉层次感

  • 响应式设计:根据不同屏幕尺寸调整间距

  • 可访问性:适当的间距提高内容的可读性

最佳实践

  • 保持一致的间距系统:使用统一的间距值(如 8、16、24 等)

  • 考虑内容的重要性:重要内容之间应有更大的间距

  • 响应式调整:在不同屏幕尺寸上调整间距

  • 测试不同设备:确保在各种设备上布局都美观

4.5 垫片:Spacer

Spacer 介绍

Spacer 是 SwiftUI 中用于占据剩余空间的视图,它会自动扩展以填充可用空间。Spacer 是实现灵活布局的重要工具,特别适用于需要将元素推到容器边缘的场景。

基本用法


// 水平布局中的 Spacer

HStack {

    Text("左侧")

    Spacer()  // 占据中间的所有空间

    Text("右侧")

}

.padding()

  


// 垂直布局中的 Spacer

VStack {

    Text("顶部")

    Spacer()  // 占据中间的所有空间

    Text("底部")

}

.frame(height: 200)

.padding()

灵活使用


// 顶部对齐

VStack {

    Text("标题")

    Spacer()

}

.frame(height: 200)

.padding()

  


// 底部对齐

VStack {

    Spacer()

    Text("底部文本")

}

.frame(height: 200)

.padding()

  


// 两端对齐

HStack {

    Text("左侧")

    Spacer()

    Text("中间")

    Spacer()

    Text("右侧")

}

.padding()

实际应用


// 工具栏布局

HStack {

    Button("返回") {

        print("返回")

    }

    

    Spacer()

    

    Text("页面标题")

    

    Spacer()

    

    Button("更多") {

        print("更多")

    }

}

.padding()

.background(Color(.systemBackground))

  


// 表单底部按钮

VStack {

    // 表单内容

    ForEach(0..<3) {

        Text("表单项 \($0 + 1)")

            .padding()

            .background(Color.gray.opacity(0.1))

            .cornerRadius(8)

            .padding(.horizontal)

    }

    

    Spacer()

    

    // 底部按钮

    Button("提交") {

        print("提交")

    }

    .buttonStyle(.borderedProminent)

    .padding()

}

适用场景

  • 工具栏:将标题居中,按钮放在两侧

  • 表单:将提交按钮固定在底部

  • 卡片:将内容推到顶部,操作按钮放在底部

  • 导航栏:创建平衡的布局

性能考虑

  • Spacer 是轻量级视图,对性能影响很小

  • 合理使用 Spacer 可以减少不必要的几何计算

  • 避免在不需要的地方使用 Spacer,可能会导致意外的布局行为

4.6 布局修饰符

框架修饰符

frame 修饰符用于控制视图的大小和对齐方式。


// 设置固定大小

Text("固定大小")

    .frame(width: 200, height: 100)

    .background(Color.yellow)

  


// 设置最大和最小大小

Text("灵活大小")

    .frame(minWidth: 100, maxWidth: 300, minHeight: 50, maxHeight: 150)

    .background(Color.blue)

  


// 填充父容器

Text("填充")

    .frame(maxWidth: .infinity, maxHeight: .infinity)

    .background(Color.green)

  


// 带对齐的框架

Text("右对齐")

    .frame(width: 200, alignment: .trailing)

    .background(Color.gray.opacity(0.2))

位置修饰符

positionoffset 修饰符用于调整视图的位置。


// 绝对位置

Text("绝对位置")

    .position(x: 100, y: 100)

  


// 相对偏移

Text("相对偏移")

    .offset(x: 50, y: 20)

  


// 组合使用

ZStack {

    Text("基础位置")

        .background(Color.yellow)

    

    Text("偏移位置")

        .offset(x: 50, y: 30)

        .background(Color.red)

}

布局优先级

layoutPriority 修饰符用于设置视图的布局优先级。


HStack {

    Text("短文本")

        .layoutPriority(1// 高优先级

        .background(Color.yellow)

    

    Text("这是一段非常长的文本,会被截断")

        .background(Color.blue)

}

.frame(width: 200)

适用场景

  • 响应式设计:根据屏幕尺寸调整视图大小

  • 自定义布局:精确控制视图位置

  • 复杂界面:处理不同优先级的内容

  • 动态布局:根据内容自动调整

4.7 网格布局:LazyVGrid 和 LazyHGrid

网格布局介绍

LazyVGridLazyHGrid 是 SwiftUI 中用于创建网格布局的容器,它们支持延迟加载,适用于大量数据。网格布局使用 GridItem 来定义列或行的大小和间距。

基本用法


// 定义网格列

let columns = [

    GridItem(.flexible()),

    GridItem(.flexible()),

    GridItem(.flexible())

]

  


// 垂直网格

ScrollView {

    LazyVGrid(columns: columns, spacing: 10) {

        ForEach(1..<10) { index in

            Rectangle()

                .fill(Color(hue: Double(index)/10, saturation: 0.8, brightness: 0.8))

                .frame(height: 80)

                .overlay(

                    Text("\(index)")

                        .foregroundColor(.white)

                        .font(.headline)

                )

        }

    }

    .padding()

}

  


// 水平网格

let rows = [

    GridItem(.flexible()),

    GridItem(.flexible())

]

  


ScrollView(.horizontal) {

    LazyHGrid(rows: rows, spacing: 10) {

        ForEach(1..<10) { index in

            Rectangle()

                .fill(Color(hue: Double(index)/10, saturation: 0.8, brightness: 0.8))

                .frame(width: 100)

                .overlay(

                    Text("\(index)")

                        .foregroundColor(.white)

                        .font(.headline)

                )

        }

    }

    .padding()

}

GridItem 配置

GridItem 支持多种大小配置:

  • .fixed:固定大小

  • .flexible:灵活大小(默认)

  • .adaptive:自适应大小,尽可能多的列


// 固定大小的列

let fixedColumns = [

    GridItem(.fixed(100)),

    GridItem(.fixed(100)),

    GridItem(.fixed(100))

]

  


// 自适应列数

let adaptiveColumns = [

    GridItem(.adaptive(minimum: 80, maximum: 120))

]

  


// 混合配置

let mixedColumns = [

    GridItem(.fixed(80)),

    GridItem(.flexible()),

    GridItem(.fixed(80))

]

适用场景

  • 图片网格:相册、产品展示

  • 图标网格:应用图标、功能入口

  • 数据网格:表格数据展示

  • 响应式布局:自适应不同屏幕尺寸

4.8 列表和表单:List 和 Form

List 列表

List 是 SwiftUI 中用于显示滚动列表的容器,自动处理单元格布局和数据展示。


// 基本列表

List {

    Text("项目 1")

    Text("项目 2")

    Text("项目 3")

}

  


// 带分组的列表

List {

    Section(header: Text("分组 1")) {

        Text("项目 1")

        Text("项目 2")

    }

    

    Section(header: Text("分组 2")) {

        Text("项目 3")

        Text("项目 4")

    }

}

  


// 动态列表

let items = ["苹果", "香蕉", "橙子", "葡萄"]

  


List(items, id: \.self) { item in

    HStack {

        Image(systemName: "fruit")

        Text(item)

    }

}

Form 表单

Form 是专门用于表单布局的容器,提供了预设的样式和间距。


// 基本表单

Form {

    TextField("用户名", text: .constant(""))

    SecureField("密码", text: .constant(""))

    Toggle("记住密码", isOn: .constant(true))

    Button("登录") {}

}

  


// 带分组的表单

Form {

    Section(header: Text("个人信息")) {

        TextField("姓名", text: .constant(""))

        TextField("邮箱", text: .constant(""))

    }

    

    Section(header: Text("偏好设置")) {

        Toggle("接收通知", isOn: .constant(true))

        Picker("主题", selection: .constant("浅色")) {

            Text("浅色").tag("浅色")

            Text("深色").tag("深色")

        }

    }

    

    Section {

        Button("保存设置") {}

    }

}

适用场景

  • List:显示结构化数据列表、设置项、联系人

  • Form:创建用户输入表单、设置页面、注册登录页面

4.9 几何读取器:GeometryReader

GeometryReader 介绍

GeometryReader 是一个特殊的容器,它可以读取父视图的几何信息(尺寸和位置),并根据这些信息来布局子视图。


// 基本用法

GeometryReader { geometry in

    VStack {

        Text("宽度: \(geometry.size.width)")

        Text("高度: \(geometry.size.height)")

        Text("安全区域: \(geometry.safeAreaInsets.top)")

    }

    .frame(width: geometry.size.width, height: geometry.size.height)

    .background(Color.gray.opacity(0.1))

}

  


// 根据父视图大小调整子视图

GeometryReader { geometry in

    HStack(spacing: 0) {

        Color.red

            .frame(width: geometry.size.width * 0.3)

        Color.green

            .frame(width: geometry.size.width * 0.3)

        Color.blue

            .frame(width: geometry.size.width * 0.4)

    }

}

.frame(height: 100)

  


// 自适应网格

GeometryReader { geometry in

    let columns = Int(geometry.size.width / 100)

    let gridItems = Array(repeating: GridItem(.flexible()), count: max(columns, 1))

    

    LazyVGrid(columns: gridItems, spacing: 10) {

        ForEach(1..<10) { index in

            Rectangle()

                .fill(Color(hue: Double(index)/10, saturation: 0.8, brightness: 0.8))

                .frame(height: 80)

                .overlay(

                    Text("\(index)")

                        .foregroundColor(.white)

                )

        }

    }

    .padding()

}

适用场景

  • 响应式布局:根据屏幕尺寸调整布局

  • 自定义布局:需要精确控制尺寸的场景

  • 动画效果:基于几何信息创建动画

  • 复杂布局:需要基于父视图尺寸的布局

4.10 其他重要布局组件

Divider 分隔线

Divider 用于在视图之间创建水平或垂直的分隔线。


// 水平分隔线

VStack {

    Text("顶部")

    Divider()

    Text("底部")

}

  


// 垂直分隔线

HStack {

    Text("左侧")

    Divider()

    Text("右侧")

}

.frame(height: 50)

Group 视图分组

Group 用于将多个视图组合在一起,作为一个整体应用修饰符。


// 分组应用修饰符

Group {

    Text("项目 1")

    Text("项目 2")

    Text("项目 3")

}

.foregroundColor(.blue)

.font(.headline)

  


// 条件渲染

Group {

    if true {

        Text("显示这个")

    } else {

        Text("显示那个")

    }

}

自定义布局(iOS 16+)

iOS 16 引入了 Layout 协议,允许创建完全自定义的布局。


// 简单的自定义布局

struct SimpleLayout: Layout {

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {

        // 计算布局尺寸

        return CGSize(width: proposal.width ?? 300, height: proposal.height ?? 200)

    }

    

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {

        // 放置子视图

        for (index, subview) in subviews.enumerated() {

            let x = bounds.minX + CGFloat(index) * 50

            let y = bounds.midY

            subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)

        }

    }

}

  


// 使用自定义布局

SimpleLayout {

    Text("1")

    Text("2")

    Text("3")

    Text("4")

}

.frame(height: 100)

.background(Color.gray.opacity(0.1))

实战:创建一个登录页面

需求分析

创建一个包含以下元素的登录页面:

  1. 应用图标和标题

  2. 用户名输入框

  3. 密码输入框(带可见性切换)

  4. 登录按钮

  5. 忘记密码链接

  6. 注册链接

代码实现


import SwiftUI

  


struct LoginView: View {

    // 状态变量

    @State private var username = ""

    @State private var password = ""

    @State private var showPassword = false

    

    var body: some View {

        ZStack {

            // 背景

            LinearGradient(

                colors: [.blue.opacity(0.1), .purple.opacity(0.1)],

                startPoint: .top,

                endPoint: .bottom

            )

            .ignoresSafeArea()

            

            VStack(spacing: 24) {

                // 应用图标和标题

                VStack(spacing: 12) {

                    Image(systemName: "lock.fill")

                        .resizable()

                        .aspectRatio(contentMode: .fit)

                        .frame(width: 80, height: 80)

                        .foregroundStyle(.blue)

                    

                    Text("欢迎回来")

                        .font(.largeTitle)

                        .fontWeight(.bold)

                    

                    Text("请登录以继续")

                        .foregroundStyle(.secondary)

                }

                

                // 输入区域

                VStack(spacing: 16) {

                    // 用户名输入框

                    TextField(

                        "用户名",

                        text: $username,

                        prompt: Text("请输入用户名")

                    )

                    .textFieldStyle(.roundedBorder)

                    .padding(.horizontal)

                    

                    // 密码输入框

                    ZStack(alignment: .trailing) {

                        if showPassword {

                            TextField(

                                "密码",

                                text: $password,

                                prompt: Text("请输入密码")

                            )

                        } else {

                            SecureField(

                                "密码",

                                text: $password,

                                prompt: Text("请输入密码")

                            )

                        }

                        

                        Button(action: {

                            showPassword.toggle()

                        }) {

                            Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")

                                .foregroundStyle(.secondary)

                                .padding(.trailing, 16)

                        }

                    }

                    .textFieldStyle(.roundedBorder)

                    .padding(.horizontal)

                    

                    // 忘记密码

                    HStack {

                        Spacer()

                        Button("忘记密码?") {

                            print("忘记密码")

                        }

                        .foregroundStyle(.blue)

                        .padding(.trailing)

                    }

                }

                

                // 登录按钮

                Button("登录") {

                    print("登录")

                }

                .buttonStyle(.borderedProminent)

                .tint(.blue)

                .padding(.horizontal)

                .frame(maxWidth: .infinity)

                

                // 注册链接

                HStack {

                    Text("还没有账号?")

                    Button("立即注册") {

                        print("注册")

                    }

                    .foregroundStyle(.blue)

                }

                

                Spacer()

            }

            .padding(.top, 60)

        }

    }

}

  


#Preview {

    LoginView()

}

代码解析

  1. ZStack:用于层叠背景和内容,创建深度感

  2. VStack:用于垂直排列各个部分,保持页面结构清晰

  3. HStack:用于水平排列忘记密码链接和注册链接

  4. Spacer:用于底部填充空间,将内容推到顶部

  5. TextField 和 SecureField:用于用户输入

  6. Button:用于操作按钮

  7. LinearGradient:用于创建美观的背景渐变

  8. ** @State**:用于管理视图状态

实战:创建一个产品详情页

需求分析

创建一个产品详情页,包含以下元素:

  1. 产品图片

  2. 产品标题和价格

  3. 产品描述

  4. 规格选择

  5. 购买按钮

代码实现


import SwiftUI

  


struct ProductDetailView: View {

    // 状态变量

    @State private var selectedColor = "红色"

    @State private var selectedSize = "M"

    @State private var quantity = 1

    

    // 产品数据

    let productName = "SwiftUI 高级教程"

    let productPrice = "¥99.00"

    let productDescription = "本教程涵盖了 SwiftUI 的高级特性,包括动画、手势、布局和性能优化等内容。通过实际项目案例,帮助你掌握 SwiftUI 的核心概念和最佳实践。"

    let colors = ["红色", "蓝色", "黑色"]

    let sizes = ["S", "M", "L", "XL"]

    

    var body: some View {

        ScrollView {

            VStack(spacing: 20) {

                // 产品图片

                ZStack {

                    Color.gray.opacity(0.1)

                        .frame(height: 300)

                    

                    Image(systemName: "book.fill")

                        .resizable()

                        .aspectRatio(contentMode: .fit)

                        .frame(width: 150, height: 150)

                        .foregroundStyle(.blue)

                }

                

                // 产品信息

                VStack(alignment: .leading, spacing: 12) {

                    HStack {

                        Text(productName)

                            .font(.title)

                            .fontWeight(.bold)

                        Spacer()

                        Text(productPrice)

                            .font(.title)

                            .fontWeight(.bold)

                            .foregroundStyle(.red)

                    }

                    

                    // 产品描述

                    Text(productDescription)

                        .foregroundStyle(.secondary)

                        .lineLimit(nil)

                    

                    // 颜色选择

                    Text("颜色")

                        .font(.headline)

                    HStack(spacing: 10) {

                        ForEach(colors, id: \.self) {

                            color in

                            Button(action: {

                                selectedColor = color

                            }) {

                                Text(color)

                                    .padding(8)

                                    .background(selectedColor == color ? Color.blue : Color.gray.opacity(0.1))

                                    .foregroundStyle(selectedColor == color ? .white : .primary)

                                    .cornerRadius(4)

                            }

                        }

                    }

                    

                    // 尺寸选择

                    Text("尺寸")

                        .font(.headline)

                    HStack(spacing: 10) {

                        ForEach(sizes, id: \.self) {

                            size in

                            Button(action: {

                                selectedSize = size

                            }) {

                                Text(size)

                                    .padding(8)

                                    .background(selectedSize == size ? Color.blue : Color.gray.opacity(0.1))

                                    .foregroundStyle(selectedSize == size ? .white : .primary)

                                    .cornerRadius(4)

                            }

                        }

                    }

                    

                    // 数量选择

                    Text("数量")

                        .font(.headline)

                    HStack {

                        Button(action: {

                            if quantity > 1 {

                                quantity -= 1

                            }

                        }) {

                            Image(systemName: "minus.circle")

                                .font(.system(size: 24))

                        }

                        

                        Text("\(quantity)")

                            .font(.headline)

                            .padding(.horizontal, 20)

                        

                        Button(action: {

                            quantity += 1

                        }) {

                            Image(systemName: "plus.circle")

                                .font(.system(size: 24))

                        }

                    }

                }

                .padding()

                

                // 购买按钮

                Button("加入购物车") {

                    print("加入购物车")

                }

                .buttonStyle(.borderedProminent)

                .tint(.blue)

                .padding(.horizontal)

                .frame(maxWidth: .infinity)

                .padding(.bottom, 30)

            }

        }

        .navigationTitle("产品详情")

        .navigationBarTitleDisplayMode(.inline)

    }

}

  


#Preview {

    ProductDetailView()

}

代码解析

  1. ScrollView:用于滚动显示产品详情

  2. ZStack:用于显示产品图片和背景

  3. VStack:用于垂直排列产品信息

  4. HStack:用于水平排列价格、颜色选择、尺寸选择和数量控制

  5. Button:用于选择颜色、尺寸和调整数量

  6. ** @State**:用于管理用户选择的状态

小结

本章详细介绍了 SwiftUI 中的基础布局系统,包括:

  • VStack:垂直堆叠视图,适用于从上到下的布局

  • HStack:水平堆叠视图,适用于从左到右的布局

  • ZStack:层叠视图,适用于需要层级关系的布局

  • 间距与对齐:控制视图之间的空间和位置关系

  • Spacer:占据剩余空间,实现灵活布局

  • 布局修饰符:控制视图的大小、位置和优先级

  • 网格布局:LazyVGrid、LazyHGrid,用于创建网格

  • 列表和表单:List、Form,用于显示列表和表单

  • 几何读取器:GeometryReader,用于获取父视图尺寸

  • 其他布局组件:Divider、Group、自定义布局

  • 实战案例:登录页面和产品详情页的完整实现

布局最佳实践

  1. 保持简洁:使用最少的容器实现所需布局

  2. 嵌套合理:避免过深的布局嵌套

  3. 响应式设计:考虑不同屏幕尺寸的布局适配

  4. 性能优化:对于大量数据使用 Lazy 容器

  5. 一致性:保持间距和对齐的一致性

  6. 可访问性:确保布局对所有用户都友好

通过本章的学习,你已经掌握了 SwiftUI 中最基本的布局技巧,能够创建各种常见的布局结构。在实际开发中,你可以根据具体需求选择合适的布局容器和技术,创建美观、响应式的用户界面。

参考资料

本内容为《SwiftUI 基础教程》第四章,欢迎关注后续更新。

第3章:基础视图组件

Snip20260416_1.png

示例代码都放这里啦,有需要的可以下载学习。swiftUIDemo

3.1 文本显示:Text

Text 组件介绍

Text 是 SwiftUI 中最基本的视图组件,用于显示文本内容。它支持富文本、字体样式、颜色等多种属性。

基本用法

// 基本文本
Text("Hello, SwiftUI!")

// 带样式的文本
Text("Hello, SwiftUI!")
    .font(.largeTitle)         // 设置字体大小
    .fontWeight(.bold)         // 设置字重
    .foregroundStyle(.blue)    // 设置文本颜色
    .italic()                  // 斜体
    .underline()               // 下划线
    .strikethrough()           // 删除线

富文本

// 富文本
Text("Hello, \(Text("SwiftUI").foregroundStyle(.blue).bold())!")

// 多行文本
Text("这是一段多行文本,\n可以通过反斜杠 n 来换行,\n或者直接在字符串中换行。")
    .multilineTextAlignment(.center)  // 多行文本对齐方式
    .lineLimit(3)                     // 限制行数
    .truncationMode(.tail)            // 截断方式

本地化

// 本地化文本
Text("welcome_message")  // 从 Localizable.strings 文件中读取

// 带参数的本地化
Text("greeting", comment: "欢迎语")

// 格式化文本
let name = "张三"
Text("欢迎 %@", name)

日期和数字格式化

// 日期格式化
let date = Date()
Text(date, style: .date)           // 仅日期
Text(date, style: .time)           // 仅时间
Text(date, style: .relative)       // 相对时间
Text(date, style: .offset)         // 时间偏移
Text(date, style: .timer)          // 计时器

// 数字格式化
let number = 123456.789
Text(number, format: .number)
Text(number, format: .currency(code: "CNY"))
Text(number, format: .percent)

3.2 图片显示:Image

Image 组件介绍

Image 用于显示图片,可以从系统图标、资源文件或网络加载图片。

基本用法

// 系统图标
Image(systemName: "star.fill")

// 资源文件图片
Image("avatar")

// 网络图片 (iOS 15+)
AsyncImage(url: URL(string: "https://example.com/image.jpg")) { phase in
    switch phase {
    case .empty:
        ProgressView()  // 加载中
    case .success(let image):
        image
            .resizable()
            .aspectRatio(contentMode: .fill)
    case .failure:
        Image(systemName: "photo")  // 加载失败
    @unknown default:
        EmptyView()
    }
}

图片修饰符

Image("avatar")
    .resizable()                 // 可调整大小
    .aspectRatio(contentMode: .fit)  // 内容模式
    .frame(width: 100, height: 100)  // 设置大小
    .clipShape(Circle())         // 裁剪形状
    .overlay(                    // 叠加内容
        Circle()
            .stroke(Color.blue, lineWidth: 2)
    )
    .shadow(radius: 5)           // 阴影
    .opacity(0.8)                // 透明度

系统图标

// 系统图标
Image(systemName: "heart.fill")
    .foregroundStyle(.red)
    .font(.system(size: 24))

// 多色图标 (iOS 15+)
Image(systemName: "person.fill.badge.plus")
    .symbolRenderingMode(.multicolor)

// 可变颜色图标
Image(systemName: "star")
    .foregroundStyle(.yellow)

3.3 按钮交互:Button

Button 组件介绍

Button 用于创建可点击的按钮,支持多种样式和交互方式。

基本用法

// 基本按钮
Button("点击我") {
    print("按钮被点击了")
}

// 带图标的按钮
Button {
    print("按钮被点击了")
} label: {
    HStack {
        Image(systemName: "star.fill")
        Text("喜欢")
    }
}

// 带角色的按钮
Button("删除", role: .destructive) {
    print("删除操作")
}

按钮样式

// 边框按钮
Button("边框按钮") {
    // 操作
}
.buttonStyle(.bordered)

// 突出显示的按钮
Button("突出按钮") {
    // 操作
}
.buttonStyle(.borderedProminent)
.tint(.blue)  // 按钮颜色

// 胶囊按钮
Button("胶囊按钮") {
    // 操作
}
.buttonStyle(.borderedProminent)
.tint(.green)
.cornerRadius(20)

// 文本按钮
Button("文本按钮") {
    // 操作
}
.buttonStyle(.plain)

禁用状态

@State private var isEnabled = false

Button("禁用按钮") {
    // 操作
}
.disabled(!isEnabled)
.opacity(isEnabled ? 1.0 : 0.5)

3.4 输入控件:TextField、SecureField、TextEditor

TextField 文本输入框

@State private var text = ""

TextField("请输入文本", text: $text)
    .textFieldStyle(.roundedBorder)  // 边框样式
    .padding()                      // 内边距
    .keyboardType(.default)         // 键盘类型
    .autocapitalization(.sentences) // 自动大写
    .autocorrectionDisabled(true)   // 禁用自动纠正

// 带提示的 TextField
TextField(
    "请输入用户名",
    text: $text,
    prompt: Text("用户名不能为空")
        .foregroundStyle(.secondary)
)
.textFieldStyle(.roundedBorder)

SecureField 安全输入框

@State private var password = ""

SecureField("请输入密码", text: $password)
    .textFieldStyle(.roundedBorder)
    .padding()

// 带可见性切换的密码输入
@State private var showPassword = false

ZStack(alignment: .trailing) {
    if showPassword {
        TextField("请输入密码", text: $password)
    } else {
        SecureField("请输入密码", text: $password)
    }
    Button(action: {
        showPassword.toggle()
    }) {
        Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
            .foregroundStyle(.secondary)
            .padding(.trailing, 8)
    }
}
.textFieldStyle(.roundedBorder)
.padding()

TextEditor 多行文本编辑器

@State private var message = ""

TextEditor(text: $message)
    .frame(height: 150)           // 设置高度
    .border(Color.gray.opacity(0.3), width: 1)  // 边框
    .cornerRadius(8)              // 圆角
    .padding()
    .foregroundStyle(.primary)    // 文本颜色

// 带占位符的 TextEditor
ZStack(alignment: .topLeading) {
    TextEditor(text: $message)
        .frame(height: 150)
        .padding(8)
    
    if message.isEmpty {
        Text("请输入消息...")
            .foregroundStyle(.secondary)
            .padding(10)
            .allowsHitTesting(false)  // 允许点击穿透
    }
}
.border(Color.gray.opacity(0.3), width: 1)
.cornerRadius(8)
.padding()

3.5 开关与选择:Toggle、Picker、Slider、Stepper

Toggle 开关

@State private var isEnabled = false

Toggle("启用功能", isOn: $isEnabled)
    .toggleStyle(.switch)  // 开关样式
    .padding()

// 带图标的 Toggle
Toggle(isOn: $isEnabled) {
    HStack {
        Image(systemName: "bell.fill")
        Text("接收通知")
    }
}
.toggleStyle(.switch)
.padding()

Picker 选择器

@State private var selectedOption = "选项1"
let options = ["选项1", "选项2", "选项3"]

// 分段控件样式
Picker("选择", selection: $selectedOption) {
    ForEach(options, id: \.self) {
        Text($0)
    }
}
.pickerStyle(.segmented)
.padding()

// 菜单样式
Picker("选择", selection: $selectedOption) {
    ForEach(options, id: \.self) {
        Text($0)
    }
}
.pickerStyle(.menu)
.padding()

// 轮盘样式(iOS 14+)
@State private var selectedColor = Color.red
let colors: [Color] = [.red, .green, .blue, .yellow]

Picker("颜色", selection: $selectedColor) {
    ForEach(colors, id: \.self) {
        ColorPickerView(color: $0)
    }
}
.pickerStyle(.wheel)
.frame(height: 200)
.padding()

// 辅助视图
struct ColorPickerView: View {
    let color: Color
    var body: some View {
        HStack {
            Rectangle()
                .fill(color)
                .frame(width: 20, height: 20)
                .cornerRadius(4)
            Text(String(describing: color))
        }
    }
}

Slider 滑块

@State private var value = 0.5

Slider(value: $value, in: 0...1)
    .padding()
    .tint(.blue)  // 滑块颜色

// 带标签的滑块
Slider(
    value: $value,
    in: 0...1,
    label: { Text("亮度") },
    minimumValueLabel: { Text("暗") },
    maximumValueLabel: { Text("亮") }
)
.padding()

// 整数滑块
@State private var intValue = 5

Slider(value: Binding(
    get: { Double(intValue) },
    set: { intValue = Int($0) }
), in: 0...10, step: 1)
.padding()
Text("值:\(intValue)")

Stepper 步进器

@State private var count = 0

Stepper("数量:\(count)", value: $count)
    .padding()

// 带范围的步进器
Stepper(
    "数量:\(count)",
    value: $count,
    in: 0...10,
    step: 2
)
.padding()

// 带标签的步进器
Stepper {
    Text("数量:\(count)")
} onIncrement: {
    count += 1
    print("增加到:\(count)")
} onDecrement: {
    count -= 1
    print("减少到:\(count)")
}
.padding()

3.6 进度指示:ProgressView

不确定进度

// 基本进度指示器
ProgressView()

// 带标签的进度指示器
ProgressView("加载中...")

// 带样式的进度指示器
ProgressView("处理中...")
    .progressViewStyle(.circular)
    .tint(.blue)
    .padding()

确定进度

@State private var progress = 0.0

ProgressView("下载进度", value: progress, total: 1.0)
    .padding()

// 带百分比的进度条
ProgressView(
    value: progress,
    total: 1.0,
    label: { Text("下载进度") },
    currentValueLabel: { Text("\(Int(progress * 100))%") }
)
.padding()

// 水平进度条样式
ProgressView(value: progress, total: 1.0)
    .progressViewStyle(.linear)
    .tint(.green)
    .frame(height: 10)
    .padding()

实战:创建一个用户设置页面

需求分析

创建一个包含以下元素的用户设置页面:

  1. 个人信息区域
  2. 通知设置(开关)
  3. 主题选择(选择器)
  4. 字体大小(滑块)
  5. 清除缓存按钮
  6. 退出登录按钮

代码实现

import SwiftUI

struct SettingsView: View {
    // 状态变量
    @State private var notificationsEnabled = true
    @State private var selectedTheme = "浅色"
    @State private var fontSize = 16.0
    @State private var cacheSize = "128 MB"
    
    // 主题选项
    let themes = ["浅色", "深色", "跟随系统"]
    
    var body: some View {
        NavigationStack {
            List {
                // 个人信息区域
                Section {
                    HStack {
                        Image(systemName: "person.circle.fill")
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: 60, height: 60)
                            .foregroundStyle(.blue)
                        
                        VStack(alignment: .leading, spacing: 4) {
                            Text("张三")
                                .font(.headline)
                            Text("zhangsan@example.com")
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                        Spacer()
                        Image(systemName: "chevron.right")
                            .foregroundStyle(.secondary)
                    }
                    .padding(.vertical, 8)
                }
                
                // 通知设置
                Section("通知设置") {
                    Toggle("接收推送通知", isOn: $notificationsEnabled)
                    Toggle("声音提醒", isOn: $notificationsEnabled)
                    Toggle("振动提醒", isOn: $notificationsEnabled)
                }
                
                // 外观设置
                Section("外观设置") {
                    Picker("主题", selection: $selectedTheme) {
                        ForEach(themes, id: \.self) {
                            Text($0)
                        }
                    }
                    .pickerStyle(.menu)
                    
                    VStack(alignment: .leading, spacing: 8) {
                        Text("字体大小:\(Int(fontSize))")
                        Slider(value: $fontSize, in: 12...24, step: 1)
                            .tint(.blue)
                    }
                }
                
                // 存储设置
                Section("存储设置") {
                    HStack {
                        Text("缓存大小")
                        Spacer()
                        Text(cacheSize)
                            .foregroundStyle(.secondary)
                    }
                    Button("清除缓存") {
                        // 清除缓存逻辑
                        print("清除缓存")
                    }
                    .foregroundStyle(.blue)
                }
                
                // 账户设置
                Section {
                    Button("关于我们") {
                        // 关于我们逻辑
                    }
                    Button("隐私政策") {
                        // 隐私政策逻辑
                    }
                    Button("退出登录", role: .destructive) {
                        // 退出登录逻辑
                    }
                }
            }
            .navigationTitle("设置")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

#Preview {
    SettingsView()
}

代码解析

  • List 和 Section:使用列表和分组组织设置项
  • NavigationStack:提供导航功能
  • Toggle:用于开关设置
  • Picker:用于主题选择
  • Slider:用于调整字体大小
  • Button:用于操作按钮
  • HStack 和 VStack:用于布局
  • @State:用于管理视图状态

小结

本章介绍了 SwiftUI 中的基础视图组件,包括:

  • Text:文本显示,支持富文本、本地化和格式化
  • Image:图片显示,支持系统图标、资源文件和网络图片
  • Button:按钮交互,支持多种样式和角色
  • 输入控件:TextFieldSecureFieldTextEditor
  • 选择控件:TogglePickerSliderStepper
  • 进度指示:ProgressView
  • 一个完整的用户设置页面实战

通过本章的学习,你已经掌握了 SwiftUI 中最常用的基础组件,能够创建各种常见的用户界面元素。


参考资料


本内容为《SwiftUI 基础教程》第三章,欢迎关注后续更新。

第2章:声明式 UI 基础

2.1 声明式 vs 命令式 UI 对比

命令式 UI(UIKit)

命令式编程是一种传统的编程范式,开发者需要明确告诉计算机“如何”完成任务。在 UIKit 中,你需要:

  1. 创建视图对象
  2. 配置视图属性
  3. 添加视图到视图层级
  4. 设置布局约束
  5. 手动更新视图状态

示例代码(UIKit)

import UIKit

class ProfileViewController: UIViewController {
    private let nameLabel = UILabel()
    private let avatarImageView = UIImageView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 1. 创建视图
        view.backgroundColor = .white
        
        // 2. 配置 nameLabel
        nameLabel.text = "张三"
        nameLabel.font = UIFont.systemFont(ofSize: 18, weight: .medium)
        nameLabel.textColor = .black
        nameLabel.textAlignment = .center
        
        // 3. 配置 avatarImageView
        avatarImageView.image = UIImage(named: "avatar")
        avatarImageView.contentMode = .scaleAspectFill
        avatarImageView.layer.cornerRadius = 40
        avatarImageView.clipsToBounds = true
        
        // 4. 添加到视图层级
        view.addSubview(avatarImageView)
        view.addSubview(nameLabel)
        
        // 5. 设置约束
        avatarImageView.translatesAutoresizingMaskIntoConstraints = false
        nameLabel.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            avatarImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            avatarImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 100),
            avatarImageView.widthAnchor.constraint(equalToConstant: 80),
            avatarImageView.heightAnchor.constraint(equalToConstant: 80),
            
            nameLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 20),
            nameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
    
    // 6. 更新数据时需要手动刷新 UI
    func updateProfile(name: String, avatar: String) {
        nameLabel.text = name
        avatarImageView.image = UIImage(named: avatar)
    }
}

声明式 UI(SwiftUI)

声明式编程是一种现代的编程范式,开发者只需要描述“是什么”,而不需要关心“如何”实现。在 SwiftUI 中,你只需要:

  1. 描述界面的结构
  2. 绑定状态
  3. 系统自动处理更新

示例代码(SwiftUI)

import SwiftUI

struct ProfileView: View {
    let name: String
    let avatar: String
    
    var body: some View {
        VStack(spacing: 20) {
            // 1. 头像
            Image(avatar)
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(width: 80, height: 80)
                .clipShape(Circle())
            
            // 2. 姓名
            Text(name)
                .font(.system(size: 18, weight: .medium))
                .foregroundStyle(.primary)
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color(.systemBackground))
    }
}

// 使用示例
ProfileView(name: "张三", avatar: "avatar")

核心差异

维度 命令式 UI(UIKit) 声明式 UI(SwiftUI)
代码量 多(需要手动管理所有细节) 少(只描述结果)
状态同步 手动更新 UI 自动同步状态
可读性 较低(逻辑分散) 高(逻辑集中)
维护性 较低(容易遗漏更新) 高(状态驱动自动更新)
错误率 较高(手动操作容易出错) 较低(框架保证一致性)
开发效率 低(重复代码多) 高(简洁明了)

2.2 View 协议与 body 计算属性

View 协议

在 SwiftUI 中,所有的视图都必须遵循 View 协议:

public protocol View {
    associatedtype Body : View
    @ViewBuilder var body: Self.Body { get }
}

核心概念

  • associatedtype Body:关联类型,表示视图的内容类型
  • body:计算属性,返回视图的内容
  • @ViewBuilder:属性包装器,允许使用声明式语法组合多个视图

body 计算属性

body 是 SwiftUI 视图的核心,它是一个计算属性,每次状态变化时都会重新计算。

重要特性

  1. 计算属性:不是存储属性,每次访问都会重新计算
  2. 轻量级:应该保持简洁,避免复杂计算
  3. 返回类型:必须返回一个遵循 View 协议的类型
  4. 自动合成:@ViewBuilder 允许使用简洁的语法组合多个视图

示例

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, SwiftUI!")
            Button("Tap Me") {}
        }
    }
}

理解 some View

some View 是一个不透明类型(Opaque Type),它表示:

  • 这个函数返回一个遵循 View 协议的类型
  • 但具体是什么类型,不需要暴露给调用者
  • 编译器可以进行更多的优化

2.3 结构体视图与值类型

视图是结构体

在 SwiftUI 中,视图是使用结构体(struct)实现的,这与 UIKit 中的类(class)不同。

结构体的优势

  1. 值类型:传递时会复制,避免引用计数问题
  2. 轻量级:分配在栈内存上,创建和销毁成本低
  3. 不可变:默认不可变,状态通过 @State 等包装器管理
  4. 线程安全:值类型天生线程安全

结构体的生命周期

SwiftUI 视图的生命周期与结构体的实例化无关:

  • 结构体可能被频繁创建和销毁
  • 但底层的真实视图对象由 SwiftUI 管理
  • 视图的身份(Identity)由其在视图树中的位置决定

示例

struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") {
                count += 1
            }
        }
    }
}

count 改变时:

  1. SwiftUI 检测到状态变化
  2. 重新创建 CounterView 结构体实例
  3. 调用 body 计算属性生成新的视图树
  4. 与旧视图树对比,只更新变化的部分

2.4 修饰符(Modifier)的使用

什么是修饰符?

修饰符是 SwiftUI 中用于修改视图属性和行为的方法。它们通常以点语法链式调用。

修饰符的工作原理

修饰符不是直接修改原视图,而是返回一个新的视图,这个新视图包含了原视图和应用的修改。

示例

Text("Hello")
    .font(.largeTitle)        // 返回一个新的 Text 视图,字体为 largeTitle
    .foregroundStyle(.blue)    // 返回一个新的视图,文本颜色为蓝色
    .padding()                // 返回一个新的视图,带有内边距

常用修饰符

布局修饰符

  • padding():添加内边距
  • frame():设置视图大小和对齐方式
  • background():设置背景
  • foregroundStyle():设置前景样式(颜色、渐变等)
  • clipShape():裁剪视图形状
  • overlay():在视图上叠加内容

排版修饰符

  • font():设置字体
  • bold():加粗文本
  • italic():斜体文本
  • multilineTextAlignment():多行文本对齐
  • lineLimit():限制文本行数

交互修饰符

  • onTapGesture():添加点击手势
  • disabled():禁用视图
  • accessibility():添加无障碍支持

动画修饰符

  • animation():添加动画
  • transition():添加转场动画

修饰符的顺序

修饰符的顺序很重要,因为每个修饰符都会作用于前一个修饰符返回的视图。

示例

// 先设置背景,再添加内边距
Text("Hello")
    .background(Color.blue)
    .padding()

// 先添加内边距,再设置背景
Text("Hello")
    .padding()
    .background(Color.blue)

这两种写法会产生不同的效果,第一种背景只覆盖文本区域,第二种背景会覆盖整个内边距区域。

实战:创建一个信息卡片

需求分析

创建一个包含以下元素的信息卡片:

  1. 标题
  2. 副标题
  3. 描述文本
  4. 图标
  5. 卡片样式(圆角、阴影)

代码实现

import SwiftUI

struct InfoCardView: View {
    // 卡片数据
    let title: String
    let subtitle: String
    let description: String
    let iconName: String
    let iconColor: Color
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // 图标和标题区域
            HStack(alignment: .center, spacing: 12) {
                // 图标
                Circle()
                    .fill(iconColor.opacity(0.1))
                    .frame(width: 48, height: 48)
                    .overlay {
                        Image(systemName: iconName)
                            .resizable()
                            .scaledToFit()
                            .frame(width: 24, height: 24)
                            .foregroundStyle(iconColor)
                    }
                
                // 标题和副标题
                VStack(alignment: .leading, spacing: 4) {
                    Text(title)
                        .font(.headline)
                        .fontWeight(.semibold)
                    Text(subtitle)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
            }
            
            // 描述文本
            Text(description)
                .font(.body)
                .foregroundStyle(.primary)
                .lineLimit(nil) // 不限制行数
        }
        .padding(16) // 卡片内边距
        .background(
            RoundedRectangle(cornerRadius: 12)
                .fill(Color(.systemBackground))
                .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
        )
        .padding(.horizontal, 16) // 卡片外边距
    }
}

// 使用示例
struct ContentView: View {
    var body: some View {
        VStack(spacing: 16) {
            InfoCardView(
                title: "SwiftUI 简介",
                subtitle: "现代 UI 框架",
                description: "SwiftUI 是一个声明式 UI 框架,允许开发者使用 Swift 语言创建跨 Apple 平台的用户界面。它提供了简洁、直观的语法,使 UI 开发变得更加高效。",
                iconName: "star.fill",
                iconColor: .yellow
            )
            
            InfoCardView(
                title: "声明式编程",
                subtitle: "现代编程范式",
                description: "声明式编程让开发者只需要描述界面的样子,而不需要关心如何实现。系统会自动处理视图的创建、更新和销毁。",
                iconName: "code",
                iconColor: .blue
            )
            
            InfoCardView(
                title: "跨平台",
                subtitle: "一次编写,多处运行",
                description: "SwiftUI 支持 iOS、iPadOS、macOS、watchOS 和 tvOS,让你的代码可以在所有 Apple 平台上运行。",
                iconName: "globe",
                iconColor: .green
            )
        }
        .padding(.vertical, 16)
        .background(Color(.systemGroupedBackground))
    }
}

#Preview {
    ContentView()
}

代码解析

  1. 结构体参数:通过参数传递卡片数据,使视图可复用
  2. VStack 和 HStack:使用栈布局组织视图
  3. Circle:创建圆形背景
  4. overlay:在圆形上叠加图标
  5. RoundedRectangle:创建圆角矩形背景
  6. shadow:添加阴影效果
  7. spacing:设置栈视图的间距
  8. alignment:设置栈视图的对齐方式

小结

本章介绍了声明式 UI 的基础概念,包括:

  • 声明式 vs 命令式 UI 的对比
  • View 协议与 body 计算属性
  • 结构体视图与值类型的特性
  • 修饰符的使用方法和顺序
  • 一个信息卡片的实战实现

通过本章的学习,你已经了解了 SwiftUI 的基本工作原理和核心概念,为后续的学习打下了坚实的基础。

参考资料

第1章:SwiftUI 与开发环境简介

距离上一次学习SwiftUI已经过去几年的时间了,好多知识点都些忘记了,最近刚好有有一些时间就好好的在从零回顾一下吧。


1.1 什么是 SwiftUI?

官方定义

根据 Apple 官方文档,SwiftUI 是一个现代的声明式 UI 框架,它允许开发者使用 Swift 语言创建跨 Apple 平台的用户界面。

SwiftUI 的核心优势

  1. 声明式语法:描述界面“是什么”而不是“怎么做”
  2. 跨平台:一次编写,在 iOS、iPadOS、macOS、watchOS 和 tvOS 上运行
  3. 实时预览:在 Xcode 中实时查看界面效果
  4. 与 Swift 语言深度集成:充分利用 Swift 的类型安全和现代特性
  5. 自动适配:自动处理不同尺寸设备的布局

与 UIKit 的对比

特性 SwiftUI UIKit
编程范式 声明式 命令式
代码风格 简洁、直观 冗长、命令式
布局系统 自动布局,基于栈 Auto Layout,需要手动设置约束
状态管理 自动状态同步 手动更新 UI
跨平台 支持所有 Apple 平台 主要针对 iOS/tvOS
开发效率 相对较低

1.2 Xcode 开发环境配置

系统要求

  • macOS:最新版本(推荐 macOS Sonoma 或更高)
  • Xcode:最新版本(推荐 Xcode 15 或更高)
  • Swift:Swift 5.7 或更高
  • iOS:iOS 15.0 或更高(如果需要支持旧版本,最低可到 iOS 13.0)

安装 Xcode

  1. 打开 App Store
  2. 搜索 “Xcode”
  3. 点击 “获取” 进行安装
  4. 安装完成后,打开 Xcode 并同意许可协议

安装额外组件

首次打开 Xcode 时,会提示安装额外的组件,包括:

  • 命令行工具
  • 模拟器运行时
  • 其他必要的开发工具

1.3 创建你的第一个 SwiftUI 项目

步骤 1:打开 Xcode

步骤 2:创建新项目

  1. 点击 “Create a new Xcode project”
  2. 选择 “iOS” 标签页
  3. 选择 “App” 模板
  4. 点击 “Next”

步骤 3:配置项目信息

  1. Product Name:输入项目名称,例如 “SwiftUIHelloWorld”
  2. Team:选择你的开发团队(如果没有,可以选择 “None”)
  3. Organization Identifier:输入你的组织标识符,例如 “com.yourname”
  4. Interface:选择 “SwiftUI”
  5. Language:选择 “Swift”
  6. Life Cycle:选择 “SwiftUI App”
  7. 取消勾选 “Use Core Data”(暂时不需要)
  8. 点击 “Next”

步骤 4:选择保存位置

选择一个合适的文件夹保存项目,然后点击 “Create”

步骤 5:项目结构介绍

创建完成后,你会看到以下文件结构:

  • SwiftUIHelloWorldApp.swift:应用程序入口
  • ContentView.swift:主视图
  • Assets.xcassets:资源文件
  • Info.plist:应用配置

1.4 认识 Xcode 预览功能

预览面板

Xcode 右侧的预览面板是 SwiftUI 最强大的特性之一,它允许你实时查看界面效果。

使用预览

  1. 打开 ContentView.swift 文件
  2. 确保右侧的预览面板可见(如果不可见,点击 Xcode 顶部的 “Editor” → “Canvas”)
  3. 你会看到 ContentView 的实时预览
  4. 修改代码,预览会自动更新

预览配置

你可以在预览代码中添加多个预览,例如:

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ContentView()
                .previewDisplayName("默认预览")
            
            ContentView()
                .previewDisplayName("暗黑模式")
                .preferredColorScheme(.dark)
            
            ContentView()
                .previewDisplayName("iPhone 15 Pro")
                .previewDevice(PreviewDevice(rawValue: "iPhone 15 Pro"))
        }
    }
}

预览快捷键

  • Option + Command + P:刷新预览
  • Command + K:清除构建

1.5 SwiftUI 项目结构解析

应用程序入口

SwiftUIHelloWorldApp.swift 是应用的入口点,它定义了应用的结构:

import SwiftUI

@main
struct SwiftUIHelloWorldApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

主视图

ContentView.swift 是应用的主视图:

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

关键概念解析

  1. @main:标记应用程序的入口点
  2. App 协议:定义应用的结构
  3. Scene:表示应用的一个场景
  4. WindowGroup:创建应用的窗口
  5. View 协议:所有 SwiftUI 视图必须遵循的协议
  6. body:计算属性,返回视图的内容
  7. #Preview:Xcode 15+ 的新语法,用于创建预览

资源管理

  • Assets.xcassets:管理应用的图片、颜色等资源
  • Info.plist:应用的配置信息,如应用名称、版本号等

实战:创建一个简单的欢迎页面

需求分析

创建一个包含以下元素的欢迎页面:

  1. 应用图标
  2. 应用名称
  3. 欢迎标语
  4. 开始按钮

代码实现

import SwiftUI

struct WelcomeView: View {
    // 状态变量,用于控制是否显示欢迎页面
    @State private var isWelcomeShown = true
    
    var body: some View {
        if isWelcomeShown {
            VStack(spacing: 20) {
                // 应用图标
                Image(systemName: "star.fill")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 100, height: 100)
                    .foregroundStyle(.yellow)
                
                // 应用名称
                Text("欢迎使用 SwiftUI")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                
                // 欢迎标语
                Text("一个现代、简洁的 UI 框架")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                
                // 开始按钮
                Button("开始探索") {
                    // 点击按钮后隐藏欢迎页面
                    isWelcomeShown = false
                }
                .buttonStyle(.borderedProminent)
                .tint(.blue)
            }
            .padding()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(
                LinearGradient(
                    colors: [.blue.opacity(0.1), .purple.opacity(0.1)],
                    startPoint: .top,
                    endPoint: .bottom
                )
            )
        } else {
            // 主内容页面
            VStack {
                Text("探索 SwiftUI 的世界!")
                    .font(.title)
                Text("这里是你的应用主界面")
                    .foregroundStyle(.secondary)
            }
            .padding()
        }
    }
}

#Preview {
    WelcomeView()
}

代码解析

  1. @State:用于创建视图的本地状态
  2. VStack:垂直堆叠视图
  3. Image:显示图片,使用系统图标
  4. Text:显示文本
  5. Button:创建按钮,带有点击动作
  6. LinearGradient:创建线性渐变背景
  7. 条件渲染:使用 if-else 控制显示不同的内容

小结

本章介绍了 SwiftUI 的基本概念和开发环境搭建,包括:

  • SwiftUI 的核心优势和与 UIKit 的对比
  • Xcode 的安装和配置
  • 创建第一个 SwiftUI 项目的步骤
  • Xcode 预览功能的使用
  • SwiftUI 项目的基本结构
  • 一个简单欢迎页面的实现

通过本章的学习,你已经了解了 SwiftUI 的基本概念和开发环境,为后续的学习打下了基础。

参考资料

深入剖析 SDAnimatedImageView:如何优雅地在 iOS 中实现高性能动态图渲染

在日常的 iOS 开发中,动态图(GIF、APNG、WebP)的展示几乎无处不在。然而,很多开发者在使用系统原生的 UIImageView 加载动态图时,往往会遭遇内存暴涨(OOM)或滑动卡顿的窘境。

作为 iOS 圈内最权威的图片处理框架,SDWebImage 为我们提供了一个非常好的解决方案——SDAnimatedImageView

本文将从系统痛点出发,结合 SDWebImage 最新源码,深度拆解 SDAnimatedImageView 的底层架构、核心属性机制,并分享在复杂业务场景下的避坑指南。

文中所涉及源码均基于 SDWebImage 5.x 版本,示例代码采用 Objective-C,Swift 开发者可参照类似逻辑使用。


一、系统原生方案的“三宗罪”

在了解 SDAnimatedImageView 之前,我们必须先明白系统原生方案到底差在哪里。

1. 内存爆炸

系统的 UIImage 在解析 GIF 时,采用“全量解码”策略。
一张体积仅为 2MB 的 GIF,如果包含 50 帧,系统会将其每一帧都解码成庞大的位图对象驻留在内存中。
解码后的位图大小 = 图片宽 × 高 × 4 字节(RGBA)。
假设宽高为 1000×1000,一帧就占约 4MB,50 帧就是 200MB,极易触发 OOM 崩溃。

2. 主线程阻塞

图片的解码过程默认在主线程同步进行,会导致明显的掉帧和卡顿。

3. 控制力极弱

系统几乎没有提供控制 GIF 播放进度、暂停、快进的 API。

SDAnimatedImageView 的诞生,正是为了彻底颠覆这种粗放的渲染模式。


二、核心架构:按需解码与帧缓冲池

SDAnimatedImageView 继承自 UIImageView,但它在内部重构了整个动态图渲染管线。其核心思想是:按需解码,以可控的内存开销换取极致的播放流畅度

1. 零内存的原始数据存储

它配合 SDAnimatedImage 使用。SDAnimatedImage 在初始化时,只保存动态图的原始文件数据(NSData),绝不提前解码任何一帧。此时,无论 GIF 有多少帧,内存占用几乎等于文件本身的大小。

// 从网络或本地获取 NSData
NSData *gifData = [NSData dataWithContentsOfFile:path];
// 创建 SDAnimatedImage,此时仅保留原始数据,不解码
SDAnimatedImage *animatedImage = [SDAnimatedImage imageWithData:gifData];

2. 智能帧缓冲池

当动画开始播放时,它不会一次性解码所有帧,而是维护一个滑动窗口式的缓冲池。在渲染当前帧的同时,后台异步线程会提前解码接下来的几帧放入内存;当某一帧不再处于缓冲窗口内时,其占用的内存会被立即释放。

3. VSync 级别的精准驱动机制

抛弃了传统的 NSTimer(容易受 RunLoop 阻塞影响导致掉帧),SDAnimatedImageView 底层采用了基于 VSync 信号的 CADisplayLink。它与屏幕刷新率完美同步,根据每一帧设定的 duration 精准计算渲染时机,保证动画如丝般顺滑,且在 App 退到后台时自动暂停,不浪费 CPU 资源。


三、源码级 API 解析(核心属性深挖)

很多开发者只把 SDAnimatedImageView 当作普通的 UIImageView 来用,这其实暴殄天物。以下几个核心属性,体现了框架设计的极致细节。

1. 性能调优:maxBufferSizeprefetchNumberOfFrames

@property (nonatomic, assign) NSUInteger maxBufferSize;
@property (nonatomic, assign) NSUInteger prefetchNumberOfFrames;
  • maxBufferSize:最大缓冲区大小(字节)。
    ⚠️ 重要纠正:很多人以为默认值 0 代表“不限制缓冲”,这是错误的!
    根据官方源码注释,0 代表 Auto(自动调整),框架会根据当前设备的内存压力动态计算缓冲上限。
    如果你需要极致的性能,可以设为 NSUIntegerMax(全缓冲,最高性能);如果内存极度吃紧,设为 1(代表无缓冲,最低内存)。

  • prefetchNumberOfFrames:预解码帧数,默认为 3~5 帧。
    增大它可以提高流畅度(尤其在高帧率动图中),但会增加内存;减小则会降低内存占用,但可能在复杂 GIF 时掉帧。
    这个值需要根据业务场景权衡。

2. 运行循环策略:runLoopMode

@property (nonatomic, strong) NSRunLoopMode runLoopMode;

⚠️ 源码纠正:普遍认为它的默认模式是 NSRunLoopCommonModes,但这并不完全准确。
官方源码的默认逻辑其实更智能:

// SDAnimatedImageView.m 中的 commonInit 片段
if ([[NSProcessInfo processInfo] processorCount] > 1) {
    _runLoopMode = NSRunLoopCommonModes;
} else {
    _runLoopMode = NSDefaultRunLoopMode;
}
  • 在多核设备上,默认为 NSRunLoopCommonModes,确保在 UIScrollView 滑动时,GIF 依然能流畅播放(因为滑动时 RunLoop 切换到了 UITrackingRunLoopMode)。
  • 在单核设备(老旧设备)上,默认降级为 NSDefaultRunLoopMode。目的是在滑动时主动暂停 GIF 播放,以节省宝贵的 CPU 资源用来保证列表滑动的流畅度。

3. 进阶播放控制(易被忽略的宝藏属性)

@property (nonatomic, assign) float playbackRate;                       // 播放速率,默认 1.0
@property (nonatomic, assign) BOOL clearBufferWhenStopped;              // 停止时是否清空缓冲池
@property (nonatomic, assign) BOOL shouldIncrementalLoad;               // 是否支持渐进式加载
  • playbackRate:支持 0.5 慢放、2.0 快进。这在实现类似“表情包编辑器”时非常有用。
  • clearBufferWhenStopped:停止动画时是否清空帧缓存(默认 NO)。
    实战意义极大:在复杂的 Feed 流中,当 Cell 滑出屏幕停止播放时,开启此属性可以立即释放掉该 GIF 占用的解码内存,大幅降低峰值内存。
  • shouldIncrementalLoad:是否支持渐进式加载(默认 YES)。
    配合网络下载,即使 GIF 只下载了 30%,它也能立刻播放已下载完成的那部分帧,带来“秒开”的体验。

四、实战:如何正确使用

1. 结合网络加载(最常用)

得益于 SDWebImage 的封装,日常开发中你甚至不需要手动创建 SDAnimatedImage,框架在下载完毕后会自动识别格式并适配。

步骤

  1. 在 Xib/Storyboard 中,将 UIImageViewCustom Class 改为 SDAnimatedImageView
    或纯代码创建:
    SDAnimatedImageView *imageView = [[SDAnimatedImageView alloc] init];
    
  2. 直接使用 sd_setImage 方法:
[self.imageView sd_setImageWithURL:[NSURL URLWithString:@"https://example.com/demo.gif"]
                  placeholderImage:[UIImage imageNamed:@"placeholder"]];

原理:SDWebImage 在下载完成后,会根据图片数据判断是否为动图(如检查 GIF 头部 GIF89a),如果是,会自动创建 SDAnimatedImage 实例并赋值给 animatedImage 属性,从而触发按需解码机制。

2. 本地动态图加载

如果是加载 Bundle 或沙盒中的本地数据,必须手动包装为 SDAnimatedImage 才能触发低内存机制:

// 从 Bundle 中获取 GIF 数据
NSString *path = [[NSBundle mainBundle] pathForResource:@"demo" ofType:@"gif"];
NSData *gifData = [NSData dataWithContentsOfFile:path];

// 关键步骤:转换为 SDAnimatedImage,保留原始 NSData
SDAnimatedImage *animatedImage = [SDAnimatedImage imageWithData:gifData];

// 赋值给 SDAnimatedImageView
self.imageView.animatedImage = animatedImage;  // 自动开始播放(若 autoPlayAnimatedImage 为 YES)

3. 手动控制播放

如果不想自动播放,可以设置 autoPlayAnimatedImage = NO,然后手动调用:

self.imageView.autoPlayAnimatedImage = NO;
self.imageView.animatedImage = animatedImage;
// 在合适的时机手动开始
[self.imageView startAnimating];

也可以获取当前播放状态:

NSUInteger currentFrame = self.imageView.currentFrameIndex;
NSUInteger currentLoop = self.imageView.currentLoopCount;

五、生产环境“避坑指南”

在将 SDAnimatedImageView 推向线上后,我们踩过几个深坑,这里分享给大家。

坑 1:XIB/Storyboard 忘记改 Class

这是排名第一的线上低级错误。视觉上看不出区别,GIF 也能播放,但内存监控会报警。
只要没有把 Custom Class 改为 SDAnimatedImageView,它底层就会退化为原生的全量解码模式
对策:在创建 ImageView 时,务必确认类型。

坑 2:缓存降级导致的“静态图”Bug

场景:首页用 SDAnimatedImageView 加载并缓存了一个 GIF。进入详情页,由于某些原因使用了原生的 UIImageView 加载同一个 URL。
现象:详情页的 GIF 变成了一张静态图。
原因:SDWebImage 的磁盘缓存中,为了保留 SDAnimatedImage 的特性,存储的是经过优化的特殊格式数据。普通的 UIImageView 从缓存读取后,由于不具备解码动态图的能力,只能显示第一帧。
对策:在项目架构层面,统一动态图加载组件,严禁混用原生 UIImageViewSDAnimatedImageView 加载同一个动态图 URL。

坑 3:WebP 动图不支持

SDAnimatedImageView 默认支持 GIF 和 APNG,但不支持 WebP 动图。如果你需要播放 WebP,必须引入独立的解码器。
正确集成方式(在 Podfile 中):

pod 'SDWebImageWebPCoder'

然后在 App 启动时注册:

#import <SDWebImageWebPCoder/SDImageWebPCoder.h>

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [SDImageCodersManager.sharedManager addCoder:SDImageWebPCoder.sharedCoder];
    return YES;
}

坑 4:长列表内存优化组合拳

在包含大量 GIF 的朋友圈或微博 Feed 流中,建议在 UITableViewCellprepareForReuse 中配合以下设置:

- (void)prepareForReuse {
    [super prepareForReuse];
    
    // 取消正在进行的图片加载
    [self.gifImageView sd_cancelCurrentImageLoad];
    
    // 停止播放并清空缓冲,极大缓解长列表内存压力
    self.gifImageView.clearBufferWhenStopped = YES;
    [self.gifImageView stopAnimating];
}

为什么这样做?

  • sd_cancelCurrentImageLoad 避免复用 Cell 时旧图片加载回调错乱。
  • clearBufferWhenStopped = YES 确保 Cell 离开屏幕后立即释放解码内存。
  • stopAnimating 停止 CADisplayLink 回调,节约 CPU。

坑 5:动画不播放的排查思路

如果 GIF 设置了但不播放,可以按以下顺序检查:

  1. 确认 animatedImage 属性不为 nil(如果是网络加载,检查 sd_setImage 的回调中是否成功)。
  2. 确认 autoPlayAnimatedImage 是否为 YES,或手动调用了 startAnimating
  3. 确认 runLoopMode 是否在当前 RunLoop 模式下被允许(常见于滑动时,若设置为了 NSDefaultRunLoopMode 则滑动时会暂停)。
  4. 确认图片数据是否完整(可尝试用 SDAnimatedImageimages 属性查看帧数)。

六、总结

SDAnimatedImageView 绝不仅仅是一个“能播 GIF 的 ImageView”。它通过 按需解码动态帧缓冲VSync 驱动 以及 设备自适应策略,在内存与性能之间找到了最优解。

理解并善用它的进阶属性(如 maxBufferSize 的 Auto 机制、clearBufferWhenStopped 等),不仅能让你的 App 告别动态图引发的 OOM 崩溃,更能体现出一名 iOS 开发者对底层渲染机制的深刻理解。在动态图渲染这一块,SDAnimatedImageView 依然是当前业界当之无愧的标杆。


互动时间:你在项目中遇到过哪些动态图相关的“奇葩”问题?欢迎在评论区留言,我们一起探讨最佳实践!

❌