从Section的API看SwiftUI的设计

622 阅读5分钟

Section在SwiftUI中是一种特殊容器视图,本身并不会显示任何的内容,通常与List或From视图一起使用。Section提供了丰富构造方法,用于支持不同的场景。通过浏览其API的声明,发现这么一个简单的视图,却充分表现了Swift中的泛型、扩展和协议的使用,这让SwiftUI的表现力更加的丰富,下面让我们来看看这些API。

1. 定义数据结构

public struct Section<Parent, Content, Footer> {

}

首先,从数据结构的定义开始。在Section结构体声明时,定义了3个泛型类型,分别对应页头、内容和页脚的类型。注意:这里仅定义了数据结构,并没有实现SwiftUI的View协议。

2. 通过扩展实现View协议

extension Section : View where Parent : View, Content : View, Footer : 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 ``View/body-swift.property`` property.
    public typealias Body = Never
}

通过扩展为Section实现View的协议,使用where条件子句声明了对3个泛型类型的约束,即:这些类型也必须实现View协议。然后这里声明了Body类型的声明是Never,这个我们后面再谈。

为了实现View协议,其内部肯定实现了body的计算属性。

var body: some View

3. 为不同的泛型类型提供不同的初始化方法

extension Section where Parent : View, Content : View, Footer : View {

    /// Creates a section with a header, footer, and the provided section
    /// content.
    ///
    /// - Parameters:
    ///   - content: The section's content.
    ///   - header: A view to use as the section's header.
    ///   - footer: A view to use as the section's footer.
    public init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Parent, @ViewBuilder footer: () -> Footer)
}

通过扩展为Section添加初始化方法,依然使用where条件子句声明了对3个泛型类型的约束,提供3个ViewBuilder分别对应不同的泛型类型。

在使用Section时,有时候并不需要设置全部的视图。因此Section针对不同的泛型约束情况,提供了不同的初始化方法。

extension Section where Parent == EmptyView, Content : View, Footer : View {

    /// Creates a section with a footer and the provided section content.
    /// - Parameters:
    ///   - content: The section's content.
    ///   - footer: A view to use as the section's footer.
    public init(@ViewBuilder content: () -> Content, @ViewBuilder footer: () -> Footer)
}

通过使用where条件子句声明Parent为EmptyView类型,在提供的初始化方法中只需要传入Content和Footer。可见在这个初始化方法内部会设置Parent为EmptyView。

extension Section where Parent == Text, Content : View, Footer == EmptyView {

    /// Creates a section with the provided section content.
    /// - Parameters:
    ///   - titleKey: The key for the section's localized title, which describes
    ///     the contents of the section.
    ///   - content: The section's content.
    public init(_ titleKey: LocalizedStringKey, @ViewBuilder content: () -> Content)

    /// Creates a section with the provided section content.
    /// - Parameters:
    ///   - title: A string that describes the contents of the section.
    ///   - content: The section's content.
    public init<S>(_ title: S, @ViewBuilder content: () -> Content) where S : StringProtocol
}

另外一个例子是仅通过字符串设置Header,这里对Parent约束是Text类型。这里提供了2个初始化方法,分别用于支持LocalizedStringKey和StringProtocol类型,这2个类型也是Text所支持的初始化类型。

还有许多针对不同的泛型提供的初始化方法,这里就不一一列举了。

4. 关于Body = Never

之前我们看到Section的关联类型Body声明的是Never,这是什么意思?为了搞清楚这个问题,我们需要先来了解View这个协议。

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

    associatedtype Body : View

    @ViewBuilder @MainActor var body: Self.Body { get }
}

View协议非常简单,声明了关联类型Body和带有@ViewBuilder body的计算属性,而特殊的地方就在于这个body的计算属性返回的也是一个符合View协议的类型,这样就形成了一个树状结构。

@ViewBuilder支撑了SwiftUI最核心的功能,用于构建视图的层次结构。根据不同的声明方式,body可能会返回:

  1. EmptyView 空视图
  2. 具体View 单个视图
  3. TupleView 多视图容器
  4. _ConditionalContent 条件判断视图

由于这种树状结构,必须能够在某个节点终止,故在这里通过Never类型来表示这种情况。而Never类型本身也实现了View协议,并且它的body也是Never。

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

    public typealias Body = Never

    public var body: Never { get }
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension Never : View {
}

自定义的视图是无法返回Never类型的,虽然编译没有问题,但在运行时会直接报异常。所以,只有SwiftUI自带的类型才会使用Never。

5. iOS 18

在iOS18中,苹果开放了部分自定义容器的接口。通过这些接口的适配,我们可以大致了解Section在List内部是如何工作的。详细内容可查看:WWDC2004 Demystify SwiftUI containers

下面有一个代码片段,基本展示了自定义容器如何使用Section的例子:

@ViewBuilder var content: Content

var body: some View {
  HStack(spacing: 80) {
    ForEach(sections: content) { section in
      VStack(spacing: 20) {
        if !section.header.isEmpty {
          DisplayBoardSectionHeaderCard { section.header }
        } 
        DisplayBoardSectionContent {
          section.content
        }
        .background { BoardSectionBackgroundView() }
      }
    }
  }
  .background { BoardBackgroundView() }
}

ForEach可以从Content中解出Section的结构信息,并将SectionConfiguration传给ViewBuilder的闭包。这样来看Section实质上更像是个数据结构,持有了3个泛型类型的View。但通过实现View协议,使其成为视图树的一部分。

视频演示中的API有所更新,最新的API:developer.apple.com/documentati…:)

6. 自定义视图示例

当我们在设计自定义视图时也可以采用这些模式,来更好的组织我们的代码。假定我们设计一个通用的卡片视图,允许自定义内容及页脚。

struct Card<Content, Footer> {

    private let contentView: Content

    private let footerView: Footer

}

extension Card where Content : View, Footer : View {

    init(@ViewBuilder content: () -> Content, @ViewBuilder footer: () -> Footer) {
        contentView = content()

        footerView = footer()
    }

}

extension Card : View where Content : View, Footer : View {

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            contentView
            
            if Footer.self != EmptyView.self {
                Divider()
                
                footerView
            }
    }
    
}

extension Card where Content : View, Footer == EmptyView {

    init(@ViewBuilder content: () -> Content) {
        contentView = content()

        footerView = EmptyView()
    }

}

7. 总结

在Section API中,我们可以看到SwiftUI中是如何应用泛型、扩展和协议这些语言特性的。通过泛型为Section提供了不同类型的支持;利用扩展将不同的处理代码分离开;使用协议来定义清晰的接口。

使用这些特性,让我们可以非常优雅的表现和组织代码,Section的API也给我们提供了非常好的示例。

参考内容: