阅读 731

Swift UI 初探

前言

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

未完待续……

文章分类
iOS
文章标签