WWDC Quick Look 💓 By SwiftGGTeam
What's new in SwiftUI

What's new in SwiftUI

观看原视频

Highlight

SwiftUI 在 2021 年新增了 AsyncImage、refreshable、task、Table、searchable、Canvas、Materials、FocusState 等数十个 API,覆盖列表交互、图形绘制、文本处理、按钮样式和键盘导航,让开发者用更少的代码实现更丰富的跨平台体验。

核心内容

做 iOS 开发,加载网络图片是基本功。以前你要么手写 URLSession + UIImageView,要么引入第三方库。图片加载中的占位图、加载失败的处理、列表下拉刷新,每个环节都要自己搭。

SwiftUI 今年直接内置了 AsyncImage,给一个 URL 就能自动下载并显示图片,还自带默认占位图。配合 refreshabletask 修饰符,下拉刷新和页面加载时的异步数据获取也变成了声明式的一行代码。

列表交互方面,以前想在 List 的每一行里放一个可编辑的 TextField,你需要用索引去取 binding,这会导致整个列表重绘。今年 SwiftUI 支持直接把 binding 数组传给 List,闭包里直接拿到每个元素的 binding,代码简洁且性能更好。同时新增了 swipeActions,左右滑动触发自定义操作,用 Button 就能配置。

macOS 上终于有 Table 了。用 TableColumn 声明多列表格,支持单选、多选、排序,几行代码就能搭出 Finder 风格的文件列表。@FetchRequest 也新增了对排序描述符的 binding,Core Data 驱动的表格可以直接用。

搜索不再需要自己拼 UI。searchable 修饰符加在 NavigationView 上,SwiftUI 自动根据平台把搜索框放在正确位置,从 Apple TV 到 Apple Watch 都能用同一套代码。

图形方面,SF Symbols 新增了 Hierarchical 和 Palette 两种渲染模式,Tab Bar 的符号变体也自动适配平台。Canvas 视图支持即时模式绘制,配合 TimelineView 可以做动画效果。Material 效果直接通过 .ultraThinMaterial 等修饰符使用,自动处理 vibrancy 和 Dark Mode。

文本处理迎来了 Markdown 原生支持,Text("**Hello**, World!") 直接渲染加粗。AttributedString 提供了类型安全的属性操作。TextField 支持 format styles 和 prompt,键盘工具栏可以自定义,FocusState 让你精确控制焦点在表单中的移动。

按钮样式也大幅扩展。.bordered 提供标准边框按钮,.controlSize(.large).controlProminence(.increased) 控制尺寸和视觉层级。Menu 支持 primary action,点击执行默认操作,长按展开菜单。Toggle 可以用 .toggleStyle(.button) 变成按钮样式。ControlGroup 把相关按钮组合在一起。

详细内容

AsyncImage:一行代码加载网络图片

03:22

以前加载远程图片,你需要管理 URLSession 任务、处理缓存、写占位图逻辑。SwiftUI 今年直接提供了 AsyncImage 视图。

struct ContentView: View {
  @StateObject private var photoStore = PhotoStore()

  var body: some View {
    NavigationView {
      ScrollView {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) {
          ForEach(photoStore.photos) { photo in
            AsyncImage(url: photo.url)
              .frame(width: 400, height: 266)
              .mask(RoundedRectangle(cornerRadius: 16))
          }
        }
        .padding()
      }
      .navigationTitle("Superhero Recruits")
    }
    .navigationViewStyle(.stack)
  }
}

class PhotoStore: ObservableObject {
  @Published var photos: [Photo] = []
}

struct Photo: Identifiable {
  var id: URL { url }
  var url: URL
}

关键点:

  • AsyncImage(url:) 接收一个 URL,自动发起网络请求并显示图片
  • 默认提供灰色占位图,加载完成后自动切换
  • 配合 LazyVGrid 使用,图片在滚动到可视区域时才开始加载
  • 全平台可用,包括 iOS、macOS、watchOS 和 tvOS

想要自定义占位图和加载动画?用完整闭包形式:

AsyncImage(url: photo.url, transaction: .init(animation: .spring())) { phase in
  switch phase {
  case .empty:
    randomPlaceholderColor()
      .opacity(0.2)
      .transition(.opacity.combined(with: .scale))
  case .success(let image):
    image
      .resizable()
      .aspectRatio(contentMode: .fill)
      .transition(.opacity.combined(with: .scale))
  case .failure(let error):
    ErrorView(error)
  @unknown default:
    ErrorView()
  }
}

关键点:

  • transaction 参数传入动画配置,控制图片加载时的过渡效果
  • phase 枚举包含 .empty.success.failure 三种状态
  • 可以在每种状态下返回不同的视图,实现自定义占位图和错误处理

refreshable 与 task:异步操作声明化

04:23

下拉刷新是列表的常见需求。以前你需要集成 UIRefreshControl,处理 begin/endRefreshing 的状态。现在用 refreshable 修饰符:

List {
  ForEach(photoStore.photos) { photo in
    AsyncImage(url: photo.url)
      .frame(minHeight: 200)
      .listRowSeparator(.hidden)
  }
}
.refreshable {
  await photoStore.update()
}

关键点:

  • refreshable 配置刷新动作,通过 environment 向下传递
  • iOS 和 iPadOS 上的 List 会自动显示下拉刷新控件
  • await 表示异步操作,UI 不会被阻塞

页面首次加载数据用 task 修饰符:

List {
  // ...
}
.refreshable {
  await photoStore.update()
}
.task {
  await photoStore.update()
}

关键点:

  • task 在视图出现时启动异步任务,视图消失时自动取消
  • 适合页面初始化加载、监听实时数据等场景
  • 配合 AsyncSequence 可以持续接收数据更新:
.task {
  for await photo in photoStore.newestPhotos {
    photoStore.push(photo)
  }
}

List 的 binding 元素与交互增强

08:08

List 的每一行需要编辑数据时,以前的做法是用索引取 binding,这会导致 SwiftUI 重绘整个列表。今年可以直接把 binding 数组传给 List:

List($directions) { $direction in
  Label {
    TextField("Instructions", text: $direction.text)
  } icon: {
    DirectionsIcon(direction)
  }
}

关键点:

  • $directions 把数组变成 binding,闭包里的 $direction 就是每个元素的 binding
  • 只读代码不需要改动,需要编辑的地方直接用 $direction.text
  • 这个语法是 Swift 语言特性,可以 back-deploy 到之前支持 SwiftUI 的版本
  • ForEach 中同样适用

列表分割线也可以自定义了:

.listRowSeparatorTint(direction.color)  // 修改分割线颜色
.listRowSeparator(.hidden)              // 隐藏分割线

Swipe actions 让列表交互更灵活:

ForEach(characters) { $character in
  CharacterProfile(character)
    .swipeActions(edge: .leading) {
      Button {
        togglePinned(for: $character)
      } label: {
        Label(character.isPinned ? "Unpin" : "Pin", systemImage: character.isPinned ? "pin.slash" : "pin")
      }
      .tint(.yellow)
    }
    .swipeActions(edge: .trailing) {
      Button(role: .destructive) {
        delete(character)
      } label: {
        Label("Delete", systemImage: "trash")
      }
    }
}

关键点:

  • swipeActions 用 Button 定义操作,通过 tint 设置颜色
  • edge 参数控制出现在左侧还是右侧
  • 可以同时添加 leading 和 trailing 两组操作
  • role: .destructive 自动使用红色,表示删除操作

macOS 专属:Table 多列表格

12:13

macOS 应用经常需要展示表格数据。SwiftUI 今年新增了 Table 视图:

Table(characters) {
  TableColumn("") { CharacterIcon($0) }
    .width(20)
  TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") }
    .width(40)
  TableColumn("Name", value: \.name)
  TableColumn("Powers", value: \.powers)
}

关键点:

  • Table 接收一个数据集合,内部用 TableColumn 定义每一列
  • value: \.name 是简写形式,直接用 key path 显示文本
  • 也可以传闭包自定义列内容

Table 支持单选和多选:

@State private var singleSelection: StoryCharacter.ID?
@State private var multipleSelection: Set<StoryCharacter.ID>()

Table(characters, selection: $singleSelection) {
  // ...columns
}

还支持点击表头排序:

@State private var sortOrder = [KeyPathComparator(\StoryCharacter.name)]
@State private var sorted: [StoryCharacter]?

Table(sorted ?? characters, selection: $selection, sortOrder: $sortOrder) {
  // ...columns
}
.onChange(of: characters) { sorted = $0.sorted(using: sortOrder) }
.onChange(of: sortOrder) { sorted = characters.sorted(using: $0) }

关键点:

  • sortOrder 是 binding,用户点击表头时自动更新
  • KeyPathComparator 用 key path 指定排序字段
  • 配合 @FetchRequest 的 sort descriptor binding,Core Data 驱动的表格只需几行代码

searchable:跨平台搜索

15:20

搜索是跨平台需求,从 Apple TV 到 Apple Watch 都需要。SwiftUI 提供了统一的 searchable 修饰符:

NavigationView {
  List {
    if characters.filterText.isEmpty {
      Section("Pinned") { sectionContent(for: characters.pinned) }
      Section("Heroes & Villains") { sectionContent(for: characters.unpinned) }
    } else {
      sectionContent(for: characters.filtered)
    }
  }
  .listStyle(.sidebar)
  .searchable(text: $characters.filterText)
  .navigationTitle("Characters")
}

关键点:

  • searchable(text:) 接收一个 binding,自动在各平台放置搜索框
  • iOS 放在导航栏下方,macOS 放在工具栏,Apple TV 放在顶部
  • 可以配合搜索建议实现更复杂的搜索体验

Canvas 与 TimelineView:高性能自定义绘制

21:31

需要绘制大量图形元素时,SwiftUI 的常规视图开销太大。Canvas 提供了即时模式绘制,类似 UIKit 的 drawRect

Canvas { context, size in
  let metrics = gridMetrics(in: size)
  for (index, symbol) in symbols.enumerated() {
    let rect = metrics[index]
    let image = context.resolve(symbol.image)
    context.draw(image, in: rect.fit(image.size))
  }
}

关键点:

  • Canvas 接收一个绘制闭包,context 提供绘图接口
  • context.resolve() 把 SwiftUI 的 Image 转成可绘制的图形对象
  • 适合绘制大量不需要单独跟踪状态的几何图形

Canvas 可以附加手势和无障碍支持:

Canvas { context, size in
  // 绘制代码...
}
.gesture(DragGesture(minimumDistance: 0).updating($focalPoint) { value, focalPoint, _ in
  focalPoint = value.location
})
.accessibilityLabel("Symbol Browser")
.accessibilityChildren {
  List(symbols) { Text($0.name) }
}

配合 TimelineView 实现动画:

TimelineView(.animation) { timeline in
  let time = timeline.date.timeIntervalSince1970
  Canvas { context, size in
    // 用 time 更新绘制参数...
  }
}

关键点:

  • TimelineView 按 schedule 触发重绘,.animation 表示每帧更新
  • 适合 tvOS 屏保、watchOS Always On 显示等场景
  • privacySensitive(true) 可以让敏感内容在 Always On 状态下自动隐藏

Materials 与 safeAreaInset

25:03

Material 效果以前只在系统控件中可用,今年开放给开发者:

VStack {
  Text("Symbol Browser")
    .font(.largeTitle.bold())
  Text("\(symbols.count) symbols")
    .foregroundStyle(.secondary)
}
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))

关键点:

  • .ultraThinMaterial.thinMaterial.regularMaterial.thickMaterial 等选项控制模糊程度
  • 自动处理 vibrancy,.primary.secondary.tertiary.quaternary 前景样式在 material 上有正确表现
  • emoji 自动排除在 vibrancy 处理之外

safeAreaInset 让浮动工具栏不遮挡内容:

ScrollView {
  symbolGrid
}
.safeAreaInset(edge: .bottom, spacing: 0) {
  VStack(spacing: 0) {
    Divider()
    Text("\(newSymbols.count) new symbols")
      .foregroundStyle(.primary)
      .font(.body.bold())
  }
  .padding()
  .background(.regularMaterial)
}

关键点:

  • safeAreaInset 在滚动视图边缘插入内容,同时让滚动内容自动避开该区域
  • 适合底部工具栏、悬浮按钮等场景

文本与格式化

27:15

Text 现在支持 Markdown:

Text("**Hello**, World!")
Text("""
Have a *happy* [WWDC](https://developer.apple.com/wwdc21/)!
""")

AttributedString 提供类型安全的属性操作:

var formattedDate: AttributedString {
  var result = Date().formatted(
    Date.FormatStyle()
      .day()
      .month(.wide)
      .weekday(.wide)
      .attributed
  )

  let weekday = AttributeContainer.dateField(.weekday)
  let color = AttributeContainer.foregroundColor(.orange)
  result.replaceAttributes(weekday, with: color)

  return result
}

关键点:

  • .attributed 获取带属性的格式化结果
  • replaceAttributes 按属性容器批量替换样式
  • 支持自定义属性,可以在 Markdown 语法中使用

文本选择:

Text(activity.info)
  .textSelection(.enabled)

关键点:

  • textSelection(.enabled) 让不可编辑的文本可以被选中复制
  • 可以应用在任意视图层级上,内部所有 Text 都会生效
  • iOS 上长按弹出复制菜单,macOS 上直接拖拽选择

Format styles 让格式化变得类型安全:

Text(activity.people.map(\.nameComponents).formatted(
  .list(memberStyle: .name(style: .short), type: .and)
))

关键点:

  • .list 把数组格式化成 “A, B and C” 的形式
  • .name(style:) 格式化人名
  • 自动处理本地化,不同语言使用正确的连接词

TextField 也支持 format styles:

@State private var newAttendee = PersonNameComponents()

TextField("New Person", value: $newAttendee, format: .name(style: .medium))

关键点:

  • value 绑定到 PersonNameComponents 类型,不是 String
  • format style 自动处理输入解析和输出格式化
  • 类型安全,编译器会检查绑定类型和 format style 是否匹配

键盘与焦点

31:38

onSubmitsubmitLabel 控制键盘提交行为:

TextField("New Person", value: $newAttendee, format: .name(style: .medium))
  .onSubmit {
    activity.append(Person(newAttendee))
    newAttendee = PersonNameComponents()
  }
  .submitLabel(.done)

关键点:

  • onSubmit 在用户按下 Return 键时触发
  • submitLabel(.done) 把键盘上的 Return 键改成 “Done”
  • 可以应用在单个 TextField 或整个 Form 上

键盘工具栏:

Form {
  TextField("Name", text: $activity.name, prompt: Text("New Activity"))
  TextField("Location", text: $activity.location)
  DatePicker("Date", selection: $activity.date)
}
.toolbar {
  ToolbarItemGroup(placement: .keyboard) {
    Button(action: selectPreviousField) {
      Label("Previous", systemImage: "chevron.up")
    }
    .disabled(!hasPreviousField)

    Button(action: selectNextField) {
      Label("Next", systemImage: "chevron.down")
    }
    .disabled(!hasNextField)
  }
}

关键点:

  • placement: .keyboard 把工具栏放在键盘上方(iOS)或 Touch Bar(macOS)
  • 适合表单导航,用户不用收起键盘就能跳到下一个字段

FocusState 精确控制焦点:

private enum Field: Int, Hashable, CaseIterable {
  case name, location, date, addAttendee
}

struct ContentView: View {
  @State private var activity: Activity = .sample
  @FocusState private var focusedField: Field?

  var body: some View {
    VStack {
      Form {
        TextField("Name:", text: $activity.name, prompt: Text("New Activity"))
          .focused($focusedField, equals: .name)
        TextField("Location:", text: $activity.location)
          .focused($focusedField, equals: .location)
        DatePicker("Date:", selection: $activity.date)
          .focused($focusedField, equals: .date)
      }

      TextField("New Person", value: $newAttendee, format: .name(style: .medium))
        .focused($focusedField, equals: .addAttendee)

      Button {
        focusedField = .addAttendee
      } label: {
        Label("Add Attendee", systemImage: "plus")
      }
    }
  }
}

关键点:

  • FocusState 用 property wrapper 追踪焦点状态
  • 可以绑定到 Bool(简单场景)或任意 Hashable 类型(多字段场景)
  • .focused($focusedField, equals: .name) 把字段和枚举值关联
  • 通过赋值 focusedField = .addAttendee 主动移动焦点
  • focusedField = nil 可以收起键盘

按钮新样式

34:55

标准边框按钮:

Button("Add") { }
  .buttonStyle(.bordered)

关键点:

  • .buttonStyle(.bordered) 给按钮添加标准边框样式
  • 可以应用在容器视图上,自动应用到所有子按钮
  • 自动处理 pressed、disabled、Dark Mode 状态

控制尺寸和视觉层级:

HStack {
  ForEach(entry.tags) { tag in
    Button(tag.name) { }
      .tint(tag.color)
  }
}
.buttonStyle(.bordered)
.controlSize(.small)
.controlProminence(.increased)

关键点:

  • .controlSize(.small / .regular / .large) 控制按钮尺寸
  • .controlProminence(.increased) 让按钮使用高对比度的强调色填充
  • 适合标记主要操作,但同一屏幕不宜过多

大型按钮:

VStack {
  Button(action: addToJar) {
    Text("Add to Jar").frame(maxWidth: 300)
  }
  .controlProminence(.increased)
  .keyboardShortcut(.defaultAction)

  Button(action: addToWatchlist) {
    Text("Add to Watchlist").frame(maxWidth: 300)
  }
  .tint(.accentColor)
}
.buttonStyle(.bordered)
.controlSize(.large)

关键点:

  • .controlSize(.large) 产生圆角矩形大按钮
  • .keyboardShortcut(.defaultAction) 让 Return 键触发该按钮
  • 次要按钮可以用 .tint(.accentColor) 添加颜色但不填充

破坏性按钮和确认对话框:

.contextMenu {
  Button("Open") { }
  Button("Delete...", role: .destructive) {
    showConfirmation = true
  }
}
.confirmationDialog(
  "Are you sure you want to delete \(entry.name)?",
  isPresented: $showConfirmation
) {
  Button("Delete", role: .destructive) { }
} message: {
  Text("Deleting \(entry.name) will remove it from all of your jars.")
}

关键点:

  • role: .destructive 自动使用红色,表示危险操作
  • confirmationDialog 在 iOS 显示为 action sheet,iPad 为 popover,macOS 为 alert
  • SwiftUI 自动按平台规范处理呈现方式

Menu 按钮支持 primary action:

Menu("Add") {
  ForEach(jarStore.allJars) { jar in
    Button("Add to \(jar.name)") {
      jarStore.add(buttonEntry, to: jar)
    }
  }
} primaryAction: {
  jarStore.addToDefaultJar(buttonEntry)
}
.menuStyle(BorderedButtonMenuStyle())
.menuIndicator(.hidden)

关键点:

  • primaryAction 定义点击时的默认操作
  • .menuIndicator(.hidden) 隐藏下拉箭头
  • macOS 上显示为分段按钮,左侧触发 primary action,右侧展开菜单
  • iOS 上单击触发 primary action,长按展开菜单

Toggle 按钮样式和 ControlGroup:

Toggle(isOn: $showOnlyNew) {
  Label("Show New Buttons", systemImage: "sparkles")
}
.toggleStyle(.button)

ControlGroup {
  Button(action: archive) {
    Label("Archive", systemImage: "archiveBox")
  }
  Button(action: delete) {
    Label("Delete", systemImage: "trash")
  }
}

关键点:

  • .toggleStyle(.button) 把 Toggle 变成可点击的按钮样式
  • ControlGroup 把相关按钮组合在一起,iOS 上紧凑排列,macOS 上有视觉分组
  • 可以嵌套使用,比如 ControlGroup 里放两个带 primaryAction 的 Menu

核心启发

1. 用 AsyncImage + refreshable + task 重构你的图片列表

做什么:把现有的图片列表换成 SwiftUI 原生方案。 为什么值得做:删除第三方依赖,代码从几十行减到几行,自动获得占位图、动画过渡、错误处理。 怎么开始:把 Image 换成 AsyncImage(url:),List 上加 .refreshable { await load() },视图上加 .task { await load() }

2. 给 macOS 应用加上 Table 视图

做什么:在 macOS 版本的数据管理界面中使用多列表格。 为什么值得做:Table 让 macOS 应用立刻有了原生 Finder 风格,支持排序、多选,代码比 AppKit 的 NSTableView 少一个数量级。 怎么开始:用 Table(data) { TableColumn(...) } 替换现有的 List,加上 selectionsortOrder binding。

3. 用 Canvas 做高性能数据可视化

做什么:用 Canvas 绘制大量几何图形,比如图表、网格、粒子效果。 为什么值得做:Canvas 避免了创建成千上万个 SwiftUI 视图的开销,配合 TimelineView 可以轻松做动画。 怎么开始:把 ZStack { ForEach(...) } 换成 Canvas { context, size in ... },用 context.draw() 直接绘制。

4. 用 FocusState 优化表单体验

做什么:给包含多个输入框的表单添加键盘导航和焦点控制。 为什么值得做:用户不用手动点击每个字段,按键盘上的上/下按钮就能在表单间跳转,移动端体验大幅提升。 怎么开始:定义一个 Field 枚举,用 @FocusState private var focusedField: Field?,给每个输入框加 .focused($focusedField, equals: .fieldName)

5. 用 searchable 统一全平台搜索

做什么:给列表页面添加搜索功能,一套代码覆盖 iPhone、iPad、Mac、Apple TV、Apple Watch。 为什么值得做:不用为每个平台写不同的搜索 UI,SwiftUI 自动把搜索框放在正确位置。 怎么开始:在 NavigationView 上加 .searchable(text: $searchText),根据 searchText 过滤列表数据。

关联 Session

评论

GitHub Issues · utterances