【译】WWDC 2023 带来的 SwiftUI 部分新特性

10,792 阅读5分钟

这是一篇来自 Majid文章的翻译,并且增加了部分内容,便于理解。

本文介绍了目前已知的部分 SwiftUI 新特性。由于今天是 WWDC 的第一天,目前官方只放出了 Platforms State of the Union 这一支技术视频,因此本文只覆盖到了视频内提到的所有新特性。后续新特性会在之后几天中陆续介绍(如果有时间的话)。

本文中出现的新 API 需要搭配 Xcode 15 beta 使用,并且部分 API 还没有实装。

WWDC 23如期而至,很多内容也随之更新并被添加到 SwiftUI 框架中。阅读本文,你可以了解到 SwiftUI 框架第5次迭代中新增的几个最重要的特性。

数据流

与 UIKit 不同,SwiftUI 使用新的观察框架(Observation framework)作为其数据流。观察框架提供了一个 Observable 协议,我们通过它来订阅更改和更新 SwiftUI 视图。不过之前 SwiftUI 的数据流 API 比较复杂,需要了解的细节很多,上手成本颇高。

不过随着 Swift 5.9 引入了宏特性,Observation framework 利用宏优化了大量 API ,精简和隐藏了部分逻辑。

@Observable

现在不需要让类(ViewModel)遵循 Observable 协议,直接使用 @Observer 宏标记就行,这个宏会自动让该类遵循 Observable 协议。现在也不需要给属性前加 @Published,因为 SwiftUI 视图会自动跟踪任何可观察类型的可用属性的更改。

// before
final class Store: ObservableObject {
    @Published var products: [String] = []
    @Published var favorites: [String] = []
    
    func fetch() async {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        // load products
        products = [
            "Product 1",
            "Product 2"
        ]
    }
}

// now
@Observable
final class Store {
    var products: [String] = []
    var favorites: [String] = []
    
    func fetch() async {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        // load products
        products = [
            "Product 1",
            "Product 2"
        ]
    }
}

此处开发人员还提到:

Observable lets SwiftUI track access at a per-field level, so your view's body is only re-evaluated when the specific properties used by your view change. If you modify a field not used by your view, no invalidation will happen at all.

新的监听方式会让 UI 刷新的范围限定在更改过的属性上,因此不会出现之前一个 ViewModel 中的某一个属性更改,所有引用到 ViewModel 的 View 都会刷新的问题,算是大大提升了优化效率。

@State

此前我们有很多属性包装器,比如 StateStateObjectObservedObjectEnvironmentObject,开发者需要明确在什么时候使用什么类型的包装器。现在,状态管理变得更简单了。对于值类型(StringInt 等)以及符合 Observable 协议的引用类型(如 ViewModel ),无脑使用 @State 标记就行了。

struct ProductsView: View {
    // @State private var text = ""
    // @StateObject private var store = Store()
    let store = Store()
    
    var body: some View {
        List(store.products, id: .self) { product in
            Text(verbatim: product)
        }
        .task {
            if store.products.isEmpty {
                await store.fetch()
            }
        }
    }
}

由于 @State 完全抹平了 StateStateObjectObservedObjectEnvironmentObject 的差异,因此 @State private var store = Store() 这段代码定义的 store 不仅仅是一个 StateObject,也是 EnvironmentObject。简单来讲,之前 Apple 把复杂的内部实现暴露出来的,但是从新版 SwiftUI 开始,你不需要再了解这些细节,只需要了解一种状态类型 State 即可。既然如此,我们可以玩一个小技巧。下面的代码, store 不仅仅是 ProductsView 内部的状态类,也被 .environment(store) 直接变为了 EnvironmentObject 注入到了所有子视图了。EnvironmentViewExample 就可以通过 Environment API 拿到 store 实例。

struct EnvironmentViewExample: View {
    @Environment(Store.self) private var store
    
    var body: some View {
        Button("Fetch") {
            Task {
                await store.fetch()
            }
        }
    }
}

struct ProductsView: View {
    @State private var store = Store()
    
    var body: some View {
        List(store.products, id: .self) { product in
            Text(verbatim: product)
        }
        .task {
            if store.products.isEmpty {
                await store.fetch()
            }
        }
        .toolbar {
            NavigationLink {
                EnvironmentViewExample()
            } label: {
                Text(verbatim: "Environment")
            }
        }
        .environment(store)
    }
}

@ObservedObject

这个例子中,我们有一个视图,它有一个从外部传进来的参数 store。在此之前,我们需要使用 @ObservedObject 属性包装器来标记它,以订阅它的更改。现在不需要了,因为 SwiftUI 视图会自动跟踪符合 Observable 协议的类型的更改。

struct FavoriteProductsView: View {
    // @ObservedObject let store: Store
    let store: Store
    
    var body: some View {
        List(store.favorites, id: .self) { product in
            Text(verbatim: product)
        }
    }
}

@Bindable

struct BindanbleViewExample: View {
    @Bindable var store: Store
    
    var body: some View {
        List($store.products, id: .self) { $product in
            TextField(text: $product) {
                Text(verbatim: product)
            }
        }
    }
}

对于双向绑定(Binding),之前只能将基础类型包装成 Binding 传递给子视图,现在新增了一个 @Bindable,可以用来标记引用类型,这样就能直接把 ViewModel 传递给子视图了。

动画

动画是 SwiftUI 框架中最重要的部分,在 SwiftUI 中给任何东西添加动画都是轻而易举的。此次更新为 SwiftUI 动画增加了一些新特性。

withAnimation completion

正如例子中看到的,withAnimation API 新增了 completion 回调,方便我们对动画进行控制。

struct AnimationExample: View {
    @State private var value = false
    
    var body: some View {
        Text(verbatim: "Hello")
            .scaleEffect(value ? 2 : 1)
            .onTapGesture {
                withAnimation {
                    value.toggle()
                } completion: {
                    print("Animation have finished")
                }
            }
    }
}

PhaseAnimator

SwiftUI 框架引入了新的 PhaseAnimator 类,允许为每个阶段提供不同的动画,并在阶段更改时更新内容。这个其实属于一个工具类,方便我们做一些拥有不同状态或阶段的动画。没有这个类也能做,就是稍微麻烦点。

enum Phase: CaseIterable {
    case start
    case loading
    case finish
}

struct PhasedAnimationExample: View {
    @State private var value = false
    
    var body: some View {
        PhaseAnimator(Phase.allCases, trigger: value) { phase in
            switch phase {
            case .start:
                StartPhaseView()
                    .onTapGesture {
                        value.toggle()
                    }
            case .loading:
                LoadingPhaseView()
            case .finish:
                FinishPhaseView()
            }
        } animation: { phase in
            switch phase {
            case .start: .easeIn(duration: 0.3)
            case .loading: .easeInOut(duration: 0.5)
            case .finish: .easeOut(duration: 0.1)
            }
        }
    }
}

KeyframeAnimator

今年 SwiftUI 也带来了关键帧动画,应该能在动画自由度上再上一层楼。但是目前没有看到相关 API,官方演示的 demo 是 MapKit 中的使用案例。 Screenshot 2023-06-06 at 10.14.45 PM.png

ScrollView

ScrollView 今年有很好的补充。首先,我们可以使用 scrollPosition 视图修饰符来控制和观察内容偏移。

struct ContentView: View {
    @State private var scrollPosition: Int? = 0
    
    var body: some View {
        ScrollView {
            Button("Scroll") {
                scrollPosition = 80
            }
            
            ForEach(1..<100, id: .self) { number in
                Text(verbatim: number.formatted())
            }
            .scrollTargetLayout()
        }
        .scrollPosition(id: $scrollPosition)
    }
}

scrollTargetBehavior

新增 scrollTargetBehavior, 支持分页模式。

struct ContentView: View {
    var body: some View {
        ScrollView {
            ForEach(1..<100, id: .self) { number in
                Text(verbatim: number.formatted())
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.paging)
    }
}

搜索

现在可以使用 searchable 视图修饰符的 isPresent 参数来显示/隐藏搜索字段。还可以使用 searchScope 来限定搜索范围。

struct ProductsView: View {
    @State private var store = Store()
    @State private var query = ""
    @State private var scope: Scope = .default
    
    var body: some View {
        List(store.products, id: .self) { product in
            Text(verbatim: product)
        }
        .task {
            if store.products.isEmpty {
                await store.fetch()
            }
        }
        .searchable(text: $query, isPresented: .constant(true), prompt: "Query")
        .searchScopes($scope, activation: .onTextEntry) {
            Text(verbatim: scope.rawValue)
        }
    }
}

手势

新增 RotateGestureMagnifyGesture, 允许我们跟踪视图的旋转和放大。

struct RotateGestureView: View {
    @State private var angle = Angle(degrees: 0.0)

    var rotation: some Gesture {
        RotateGesture()
            .onChanged { value in
                angle = value.rotation
            }
    }

    var body: some View {
        Rectangle()
            .frame(width: 200, height: 200, alignment: .center)
            .rotationEffect(angle)
            .gesture(rotation)
    }
}

其他改进

ContentUnavailableView

新增 ContentUnailableView 视图,可以在需要的时候显示空视图,展示一个图片和一行文字。算是一个很小的改进吧。

struct ProductsView: View {
    ContentUnavailableView("Products list is empty", systemImage: "list.dash")
}

#Preview

新增 #Preview 宏,替代原来复杂的 preview 代码。不过 preview 模版代码基本上都是新建 View 的时候模板自动生成的,影响不大。

// before
struct PlayerView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

// now
#Preview {
    ContentView()
}

其他

新增一对新的视图修饰符,允许我们调整列表中的间距。您可以使用 listRowSpaceslistSectionSpaces 视图修饰符来设置列表中所需的间距。

EnvironmentValues 结构包含一系列与最新平台更新相关的新属性,比如 isActivityFullscreen 和 showsWidgetContainerBack。

Swift Charts 支持滚动。

SF Symbols 增加动画效果。

总结

每一年的 SwiftUI 迭代都会让我感觉,这些新特性明明应该早就有才对。像是 @State 这样的能力,如果一开始就有,会大大降低 SwiftUI 学习的门槛。不过这次 API 上的改动很多都依赖于 Swift 宏,而宏直到 5.9 才正式发布,也算是可以理解吧。不过按照这样的节奏下去,不知道何年马月才能真正大面积用上 SwiftUI 。据说今年 Platforms State of the Union 上总共就提了 3 次 UIKit,其中一次还是讲的 #Preview 支持预览 UIKit 😂。看来我们将会在很长一段时间内卡在两代技术中间。或者,投入另一个新技术:realityOS?