Swift UI 初探

1,216 阅读7分钟

前言

image.png

SwiftUI

编写更少的代码,打造更出色的 app。

SwiftUI 是一种创新、简洁的编程方式,通过 Swift 的强大功能,在所有 Apple 平台上构建用户界面。借助它,您只需一套工具和 API,即可创建面向任何 Apple 设备的用户界面。SwiftUI 采用简单易懂、编写方式自然的声明式 Swift 语法,可无缝支持新的 Xcode 设计工具,让您的代码与设计保持高度同步。 SwiftUI 原生支持“动态字体”、“深色模式”、本地化和辅助功能——第一行您写出的 SwiftUI 代码,就已经是您编写过的、功能最强大的 UI 代码。 developer.apple.com/xcode/swift…

Creating and Combining Views

关于 some View

新建一个 SwiftUI 的新项目,会出现如下代码:一个Text展示在body中。

struct ContentView: View {
    var body: some View {
        Text("Hello World")
    }
}

可能会对some比较陌生,所以我们先从View说起。

文档内可以看到ViewSwiftUI 一个协议,这个协议里含有一个associatedtype

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol View {

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required `body` property.
    associatedtype Body : View

    /// The content and behavior of the view.
    @ViewBuilder var body: Self.Body { get }
}

带有这种修饰的协议不能作为类型来使用,只能当作约束来使用。通过some View的修饰,会向编译器保证:每次闭包中返回的一定是一个确定,而且遵守View协议的类型,不要去关心到底是哪种类型。

// Error
func createView() -> View {
}

// Correct
func createView<T: View>() -> T {
}

这种写法使用了 Swift 5.1Opaque return types特性。这样的设计,为开发者提供了一个灵活的开发模式,抹掉了具体的类型,不需要修改公共API来确定每次闭包的返回类型,也降低了代码书写难度。

Preview SwiftUI

SwiftUI 含有苹果对标 React NativeFlutterHot Reloading 工具,Xcode 将对代码进行静态分析,找到所有遵守PreviewProvider协议的类型进行预览渲染。经过尝试,Preview SwiftUI 不需要运行 app 就能查看实时预览,不像其他 Hot Reloading 可能还需要重启 app 或进入对应界面,调整数据进行调试。

快捷键:Option + Command + P

关于 ViewBuilder

我们先来看一个最简单的 UI:

image.png 我们创建了一个横向布局组件Hstack,里面有两个文字组件Text。问题是这两个Text不是以数组的形式放进content的闭包里,那这种写法为什么还能成立?我们来看Hstack的初始化方法:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct HStack<Content> : View where Content : View {

    /// Creates a horizontal stack with the given spacing and vertical alignment.
    ///
    /// - Parameters:
    ///   - alignment: The guide for aligning the subviews in this stack. This
    ///     guide has the same vertical screen coordinate for every child view.
    ///   - spacing: The distance between adjacent subviews, or `nil` if you
    ///     want the stack to choose a default distance for each pair of
    ///     subviews.
    ///   - content: A view builder that creates the content of this stack.
    @inlinable public init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required `body` property.
    public typealias Body = Never
}

看最后一个参数content,只返回了一个() -> Content类型,但我们在创建的时候只是列举了两个Text,并没有返回一个可用的Content

这里使用的就是 Swift 5.1 的另一个特性 Function Builder,同时注意到content前面有一个@ViewBuilder标记。

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@_functionBuilder public struct ViewBuilder {

    /// Builds an empty view from a block containing no statements.
    public static func buildBlock() -> EmptyView

    /// Passes a single view written as a child view through unmodified.
    ///
    /// An example of a single view written as a child view is
    /// `{ Text("Hello") }`.
    public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
}

@_functionBuilder标记的类型可以被用来对其他类型进行标记,所以在这个结构体中,@ViewBuilder可以对content进行标记,所以被标记的content会按照ViewBuilder中合适的buildBlock进行 build 后再使用。

有趣的是我们发现了文档中有这么多buildBlock

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {

    public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {

    public static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {

    public static func buildBlock<C0, C1, C2, C3>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> TupleView<(C0, C1, C2, C3)> where C0 : View, C1 : View, C2 : View, C3 : View
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {

    public static func buildBlock<C0, C1, C2, C3, C4>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4) -> TupleView<(C0, C1, C2, C3, C4)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {

    public static func buildBlock<C0, C1, C2, C3, C4, C5>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5) -> TupleView<(C0, C1, C2, C3, C4, C5)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6) -> TupleView<(C0, C1, C2, C3, C4, C5, C6)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View, C9 : View
}

而事实上,刚才我所写的示例代码也有如下写法:

image.png 而基于 function builder 的构造方式是有一定限制的,文档里规定了最多 10 个参数。

Building Lists and Navigation

List

最简单的办法是创建一个静态List,如下:

image.png 这里的ListHStackVStack很相似,接受一个 view builder 并采用 View DSL 的方式列举了几个 Row。这种方式构建了对应着 UITableView 的静态 cell 的组织方式。

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension List where SelectionValue == Never {

    /// Creates a list with the given content.
    ///
    /// - Parameter content: The content of the list.
    public init(@ViewBuilder content: () -> Content)
}

同样我们去查看List的初始化方法,还有其他一些动态初始化方法:

    public init<RowContent>(_ data: Range<Int>, @ViewBuilder rowContent: @escaping (Int) -> RowContent) where Content == ForEach<Range<Int>, Int, HStack<RowContent>>, RowContent : View

image.png

NavigationView

可以通过NavigationView来进行页面跳转:

image.png

Handling User Input

@State

在前端界面上,数据同步及时刷新比较重要。一般都是数据源数据更新了,界面 UI 同时更新。在 SwiftUI 里面,视图中声明的任何状态、内容和布局,源头一旦发生改变,会自动更新视图,因此,只需要一次布局。在属性前面加上@State关键词,即可实现每次数据改动,UI 动态更新的效果。

image.png 这个示例通过@State管理私有状态value并驱动展示。点击不同的按钮分别会 + 1 和 - 1,显示的数字也会跟着改变。

接下来看一下文档中的State

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen @propertyWrapper public struct State<Value> : DynamicProperty {
    public init(wrappedValue value: Value)
    public init(initialValue value: Value)
    public var wrappedValue: Value { get nonmutating set }
    public var projectedValue: Binding<Value> { get }
}

注意到State是一个结构体,并且由@propertyWrapper包装的。@propertyWrapper是属性包装器,@State Int不等同于Intproperty wrapper 做的事情大体如下:

  1. 为底层的存储变量State<Int>自动提供一组 gettersetter 方法,结构体内保存了Int的具体数值;
  2. 在 body 首次求值前,将State<Int>关联到当前View上,为它在堆中对应当前View分配一个存储位置。
  3. @State修饰的变量设置观察,当值改变时,触发新一次的body求值,并刷新 UI。

State内部

State内部大致如下:

struct State<Value> : DynamicProperty {
    var _value: Value
    var _location: StoredLocation<Value>?
    
    var _graph: ViewGraph?
    
    var wrappedValue: Value {
        get { _value }
        set {
            updateValue(newValue)
        }
    }
    
    // 发生在 init 后,body 求值前。
    func _linkToGraph(graph: ViewGraph) {
        if _location == nil {
            _location = graph.getLocation(self)
        }
        if _location == nil {
            _location = graph.createAndStore(self)
        }
        _graph = graph
    }
    
    func _renderView(_ value: Value) {
        if let graph = _graph {
            // 有效的 State 值
            _value = value
            graph.triggerRender(self)
        }
    }
}

对于@State的声明,会在当前View中带来一个自动生成的私有存储属性,来存储真实的State struct值。如下:

struct DetailView: View {
    @State private var value: Int?
    private var _value: State<Int?> // 自动生成
    // ...
}

所以如果声明@State var value: Int无法编译,因为Int?的声明在初始化时会默认赋值为nil,让_value完成初始化 (它的值为 State<Optional<Int>>(_value: nil, _location: nil));而非Optionalvalue则需要明确的初始化值,否则在调用self.value的时候,底层_value是没有完成初始化的。对于@State的设置,只有在 View 被添加到 graph 中以后 (也就是首次 body 被求值前) 才有效。

State, Binding, StateObject, ObservedObject

@StateObject的情况和@State很类似:View都拥有对这个状态的所有权,它们不会随着新的View init而重新初始化。这个行为和Binding以及ObservedObject是正好相反的:使用BindingObservedObject的话,意味着View不会负责底层的存储,开发者需要自行决定和维护“非所有”状态的声明周期。

所以有这么个场景:subView 想使用 contentView 的值,并将这个值及时反馈回 contentView 中,使用@Binding是毫无疑问的:

image.png

未完待续……