构建复杂 UI

List

import SwiftUI

struct ElapsedTime: View {
    @State var fruits = ["Apple", "Banana", "Orange", "Orange", "Apple"]

    var body: some View {
        List(fruits, id: \.self) { fruit in
            Text(fruit)
        }
        
        Button("Remove"){
            fruits.remove(at: 3)
        }
        
        Divider()
        
        Button("Insert"){
            fruits.insert("Watermelon", at: 2)
        }
    }
}

#Preview {
    ElapsedTime()
}

List 与 ScrollView 的区别

ScrollView {
    VStack {
        ...
    }
}
ScrollView List
自己负责布局 系统负责布局
不会自动复用 会自动复用 Cell
没有滑动删除 支持删除、移动
没有 Section 支持 Section
更自由 更适合数据列表

List SwiftUI 只会创建 屏幕可见区域 附近的 Row。

List 与 ForEach

List {
    ForEach(0..<100) { i in
        Text("\(i)")
    }
}

支持混合内容

List {
    Text("Header")
    ForEach(["a", "b", "c", "d", "e"], id: \.self) { ch in
        Text("\(ch)")
    }
    Text("Footer")
}

List Row 的声明周期

并不是 0-99 一次全部执行,只有真正出现在屏幕上的Row才会调用 .onAppear

import SwiftUI

struct Row: View {
    
    let id: Int
    
    var body: some View {
        Text("\(id)")
            .onAppear {
                print("Appear", id)
            }
    }
}

struct ElapsedTime: View {
    var body: some View {
        List(0..<100) {
            Row(id: $0)
        }
    }
}

#Preview {
    ElapsedTime()
}

.onAppear 与 .onDisappear

这是所有 View 都有的 Modifier

import SwiftUI

struct Row: View {
    
    let id: Int
    
    var body: some View {
        Text("\(id)")
            .onAppear {
                print("Appear", id)
            }
            .onDisappear {
                print("Disappear", id)
            }
    }
}

struct ElapsedTime: View {
    var body: some View {
        List(0..<100) {
            Row(id: $0)
        }
        Text("Hello")
            .onAppear {
                print("出现")
            }
    }
}

#Preview {
    ElapsedTime()
}

SwiftUI 是 View 出现在 View Tree 中就可能触发,不是 页面显示一次。每次离开屏幕再进入 每次都会执行,所以 .onAppear 不是只执行一次。

无限滚动分页

List(viewModel.items) { item in
    Text(item.name)
        .onAppear {
            if item == viewModel.items.last {
                viewModel.loadMore()
            }
        }
}

流程,SwiftUI中实现分页加载最常见的方式之一

用户滑动 -> 最后一个 Cell 出现 ->
onAppear -> loadMore -> 追加数据
-> List 更新

.task

View 出现
->
启动异步 Task
->
SwiftUI 管理声明周期

当 View 出现时,自动启动一个异步任务(async Task),并在 View 消失时自动管理这个任务。

可以理解成,但是实际长比这种写法更智能

.onAppear {
    Task {
        await loadData()
    }
}

为什么有 .task,假设你需要请求网络,在 Swift Concurrency 出现之前 通常会写

.onAppear {
    loadData()
}

如果现在 loadData 是异步函数

func loadData() async {
    ...
}
// 你不能直接写
.onAppear {
    await loadData()   // ❌ 编译错误
}
// .onAppear 的闭包不是 async
// 只能这样
.onAppear {
    Task {
        await loadData()
    }
}
// 于是 SwiftUI 提供了
.task {
    await loadData()
}

.listStyle

import SwiftUI

struct ElapsedTime: View {
    var body: some View {
        List {
            Text("Apple")
            Text("Banana")
            Text("Orange")
        }.listStyle(.plain)
    }
}

#Preview {
    ElapsedTime()
}

ListStyle 可以决定

List {
    ...
}
.listStyle(.automatic)
import SwiftUI

struct ElapsedTime: View {
    var body: some View {
        List {
            Text("Apple")
            Text("Banana")
            Text("Orange")
        }.listStyle(.plain)
        
        List {
            Section("Fruit") {
                Text("Apple")
                Text("Banana")
            }

            Section("Drink") {
                Text("Tea")
            }
        }
        .listStyle(.grouped)
    }
}

#Preview {
    ElapsedTime()
}

.listRowSeparator

.listRowSeparator(_:) 是 SwiftUI 中控制 List 每一行分割线(Separator)是否显示以及显示位置 的 Modifier

.listRowSeparator(.visible)
// 或
.listRowSeparator(.hidden)

例子

List {
    Text("Apple")
        .listRowSeparator(.hidden)
    Text("Banana")
        .listRowSeparator(.hidden)
    Text("Orange")
        .listRowSeparator(.hidden)
}

.listRowBackground

List Item 背景设置

import SwiftUI

struct ElapsedTime: View {
    var body: some View {
        List {
            Text("Apple")
                .listRowSeparator(.hidden)
                .listRowBackground(Color.red)
            Text("Banana")
                .listRowSeparator(.hidden)
            Text("Orange")
                .listRowSeparator(.hidden)
        }
    }
}

#Preview {
    ElapsedTime()
}
.listRowBackground(RoundedRectangle(cornerRadius: 10).foregroundStyle(.yellow).padding(5))

Section

Section 是 SwiftUI 中 用于将内容分组(Grouping) 的容器,最常见于 List 和 Form 中。

一个 List 可以包含多个 Section,每个 Section 又包含若干个 View。

import SwiftUI

struct ElapsedTime: View {
    var body: some View {
        List {
            Section {
                Text("Apple")
                Text("Banana")
            }
            
            Section("动物") {
                Text("Cat")
                Text("Dog")
            }
            Section(
                header: Text("国家"),
                footer: Text("即将支持更多国家")
            ) {
                Text("USA")
                Text("China")
            }
            
            // Header 可以是任何 View
            Section {
                Text("Apple")
                Text("Banana")
            } header: {
                HStack {
                    Image(systemName: "leaf.fill")
                    Text("水果")
                }
            }
        }
    }
}

#Preview {
    ElapsedTime()
}

Section 主要有三个用途:

DisclosureGroup

虽然 Section 本身不会折叠,DisclosureGroup 像可折叠的 Section

List {
    DisclosureGroup("水果") {
        Text("Apple")
        Text("Banana")
    }
}

State + Section 自己维护 类似的

import SwiftUI

struct ElapsedTime: View {
    @State private var expanded = true

    var body: some View {
        List {
            Section {
                if expanded {
                    Text("Apple")
                    Text("Banana")
                }
            } header: {
                Button("水果") {
                    expanded.toggle()
                }
            }
        }
    }
}

#Preview {
    ElapsedTime()
}

NavigationStack 是 SwiftUI 新一代导航器,而 NavigationLink 是触发导航的控件。

import SwiftUI

struct DetailView: View {
    let content: String
    var body: some View {
        Text("\(self.content)")
    }
}

struct ElapsedTime: View {
    var body: some View {
        NavigationStack {
            NavigationLink {
                Text("进入详情1")
            } label: {
                DetailView(content: "页面详情1")
            }
            Divider()
            NavigationStack {
                NavigationLink("进入详情2") {
                    DetailView(content: "页面详情2")
                }
            }
        }
    }
}

#Preview {
    ElapsedTime()
}

最经典

NavigationLink("下一页") {
    DetailView()
}

等价于

NavigationLink(destination: DetailView()) {
    Text("下一页")
}

label 可以放任何 View

NavigationLink {
    DetailView()
} label: {
    HStack {
        Image(systemName: "star.fill")
        Text("详情")
    }
}

导航多个页面

NavigationStack {
    List {
        NavigationLink("第一页") {
            Page1()
        }

        NavigationLink("第二页") {
            Page2()
        }

        NavigationLink("第三页") {
            Page3()
        }
    }
}

点击哪个,就 push 哪个。

NavigationStack 不只是 push View。可以 push 数据。

struct ContentView: View {

    @State private var path = NavigationPath()

    var body: some View {

        NavigationStack(path: $path) {

            Button("进入详情") {
                path.append("Apple")
            }
            .navigationDestination(for: String.self) { value in
                DetailView(name: value)
            }
        }
    }
}

这里不是 push 一个 View,而是 push 一个 String,SwiftUI 根据 .navigationDestination(for: String.self) 自动生成页面。

这就是 数据驱动导航 Data-driven Navigation。

配合 NavigationPath,NavigationLink 也可以不直接指定目标页面。

import SwiftUI

struct DetailView: View {
    let content: String
    var body: some View {
        Text("\(self.content)")
    }
}

struct ElapsedTime: View {

    @State private var path = NavigationPath()

    var body: some View {

        NavigationStack(path: $path) {

            NavigationLink("点击查看详情", value: "详情页面内容")
            .navigationDestination(for: String.self) { value in
                DetailView(content: value)
            }
        }
    }
}

#Preview {
    ElapsedTime()
}

编程式导航

除了点击,还可以代码控制

import SwiftUI

struct DetailView: View {
    let content: String
    @Binding var path:NavigationPath
    var body: some View {
        Text("\(self.content)")
        
        Button("进一步详情"){
            path.append("\(content)o")
        }

        Button("返回"){
            path.removeLast()
        }
        Button("返回首页"){
            // clear Stack
            path.removeLast(path.count)
        }
    }
}

struct ElapsedTime: View {

    @State private var path:NavigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            NavigationLink("点击查看详情", value: "o")
            .navigationDestination(for: String.self) { value in
                DetailView(content: value, path: $path)
            }
        }
    }
}

#Preview {
    ElapsedTime()
}

总结

组件 作用 类比 UIKit
NavigationStack 管理整个导航栈,保存页面历史 UINavigationController
NavigationLink 用户点击后触发导航 pushViewController 的入口
NavigationPath 保存导航路径(数据而非视图) 导航控制器维护的控制器栈(但以数据建模)
.navigationDestination(for:) 根据数据类型生成目标页面 集中式路由/目标控制器映射
NavigationLink(value:) 通过值驱动导航 将导航意图交给 NavigationStack 解析

Binding 与遍历

List 样例

import SwiftUI

struct Todo: Identifiable {
    let id = UUID()
    var title: String
    var done: Bool
}

struct ElapsedTime: View {

    @State private var todos = [
        Todo(title: "Apple", done: false),
        Todo(title: "Orange", done: true)
    ]

    var body: some View {
        List($todos) { $todo in
            Toggle("\(todo.title) \(String(todo.done))", isOn: $todo.done)
        }
    }
}

#Preview {
    ElapsedTime()
}

ForEach 同样支持

List {
    ForEach($todos) { $todo in
        Toggle(todo.title, isOn: $todo.done)
    }
}

@Previewable

#Preview 中创建State变量

import SwiftUI

struct Todo: Identifiable {
    let id = UUID()
    var title: String
    var done: Bool
}

struct ElapsedTime: View {
    @Binding var todos:[Todo]
    
    var body: some View {
        List($todos) { $todo in
            Toggle("\(todo.title) \(String(todo.done))", isOn: $todo.done)
        }
    }
}

#Preview {
    @Previewable @State var todos = [
        Todo(title: "Apple", done: false),
        Todo(title: "Orange", done: true)
    ]
    
    ElapsedTime(todos: $todos)
}

List editActions

SwiftUI 自动完成

import SwiftUI

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


struct ElapsedTime: View {
    @State private var items = [
        Item(name: "Apple"),
        Item(name: "Banana"),
        Item(name: "Orange")
    ]

    var body: some View {
        NavigationStack {
            List(
                $items,
                editActions: [.delete, .move]
            ) { $item in

                TextField("Name", text: $item.name)

            }
            .toolbar {
                EditButton()
            }
        }
    }
}

#Preview {
    ElapsedTime()
}

.toolbar

.toolbar 是 SwiftUI 用来给当前界面添加工具栏(Toolbar)内容的修饰器。它统一了导航栏按钮、底部工具栏、键盘工具栏等各种工具栏。

基本语法

.toolbar {
    ToolbarItem {
        Button("编辑") {
            print("编辑")
        }
    }
}

通常配合 NavigationStack

import SwiftUI

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


struct ElapsedTime: View {
    @State private var items = [
        Item(name: "Apple"),
        Item(name: "Banana"),
        Item(name: "Orange")
    ]

    var body: some View {
        NavigationStack {
            List(
                $items,
                editActions: [.delete, .move]
            ) { $item in
                TextField("Name", text: $item.name)
            }
            .navigationTitle("List Demo")
            .toolbar {
                ToolbarItem {
                    EditButton()
                }
                ToolbarItem {
                    Button("测试按钮") {
                        print("测试按钮被点击")
                    }
                }
            }
        }
    }
}

#Preview {
    ElapsedTime()
}

指定位置 placement

import SwiftUI

struct ElapsedTime: View {
    @State private var count = 0
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                Text("Count: \(count)")
                    .font(.largeTitle)
            }
            .navigationTitle("Toolbar Demo")
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("重置") {
                        count = 0
                    }
                }
                
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        count += 1
                    } label: {
                        Image(systemName: "plus")
                    }
                }
                
                ToolbarItem(placement: .bottomBar) {
                    Button("减少") {
                        count -= 1
                    }
                }
                
                ToolbarItem(placement: .status) {
                    Text("当前值:\(count)")
                }
            }
        }
    }
}

#Preview {
    ElapsedTime()
}

ToolbarItemGroup

多个按钮可以放一起

.toolbar {
    ToolbarItemGroup(placement: .topBarTrailing) {

        Button {
        } label: {
            Image(systemName: "square.and.arrow.up")
        }

        Button {
        } label: {
            Image(systemName: "plus")
        }

    }
}

@Observable

class 的 Model 用 @Observable

@Observable class CodeBreaker {
    ...
}

作为 State 传递时,就不用像 struct 一样 $ Binding 了。因为 class 实例是引用类型。

List ForEach .onDelete .onMove

import SwiftUI

struct ElapsedTime: View {
    @State private var fruits = [
        "Apple",
        "Banana",
        "Orange",
        "Pear"
    ]

    var body: some View {
        NavigationStack {
            List {
                ForEach(fruits, id: \.self) { fruit in
                    Text(fruit)
                }
                .onDelete(perform: delete)
                .onMove(perform: move)
            }
            .toolbar {
                EditButton()
            }
        }
    }

    func delete(at offsets: IndexSet) {
        fruits.remove(atOffsets: offsets)
    }

    func move(from source: IndexSet, to destination: Int) {
        fruits.move(fromOffsets: source, toOffset: destination)
    }
}

#Preview {
    ElapsedTime()
}