创建自定义的SwiftUI容器视图的方法

567 阅读3分钟

尽管SwiftUI提供了相当多的内置容器视图,如VStackHStackList ,但有时我们也可能想定义我们自己的自定义容器。

例如,假设我们正在开发一个具有类似旋转木马的组件的应用程序,它可以让我们的用户滚动浏览一个水平的项目列表。这个组件目前是这样实现的:

struct Carousel<Content: View>: View {
    var content: () -> Content

    var body: some View {
        ScrollView(.horizontal) {
            HStack(content: content).padding()
        }
    }
}

这是一个良好的开端,但与SwiftUI的内置容器相比,我们目前的实现确实有一个相当大的限制--我们目前只能向它传递一个单一的Content 视图。

只要我们使用像ForEach (因为这将在我们的旋转木马的content 闭包中给我们一个单一的返回值),这可能工作得很好,但如果我们试图做以下事情,那么我们会得到一个编译器错误:

struct OnboardingCarousel: View {
    var body: some View {
        Carousel {
            WelcomeCard()
            GettingStartedCard()
            ExploreCard()
        }
    }
}

这有点遗憾,因为上述方法是声明Carousel 实例的一种非常自然的方式,因为它完全模仿了内置容器如HStackVStack 的使用方式。

尽管我们可以通过将所有的子视图包裹在一个Group 中来解决上述问题(这将再次在我们的闭包中给我们一个单一的返回值),但在每个调用点都要这样做将是非常不方便的。不过值得庆幸的是,有一个更好的方法--那就是用SwiftUI的ViewBuilder 属性来注释我们的content 闭包,就像这样:

struct Carousel<Content: View>: View {
    var content: () -> Content

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }

    var body: some View {
        ScrollView(.horizontal) {
            HStack(content: content).padding()
        }
    }
}

ViewBuilder 属性添加到一个闭包中,就有可能在其中使用SwiftUI 的 DSL 的全部功能,这意味着我们现在能够完全按照我们最初的意图来定义我们的OnboardingCarousel --真的很好

然而,如果我们的代码库包括多种容器视图,那么总是要重复上述初始化器声明就会变得有点重复,特别是因为所需的语法相当复杂。

因此,让我们看看是否可以通过一点面向协议的编程来改善情况。如果我们假设我们的每个容器视图都将使用与上述Carousel 视图相同的模式(即它有一个通用的Content 类型,并接受一个content 闭包),那么我们可以使用下面的协议来模拟这些能力:

protocol ContainerView: View {
    associatedtype Content
    init(content: @escaping () -> Content)
}

我们让我们的新协议扩展SwiftUI的View 协议,以继承其所有的要求。要了解该技术的更多信息,请查看《Swift中的专用协议》。

接下来,让我们用一个方便的初始化器来扩展我们新的ContainerView 协议,这个初始化器添加了我们之前必须手动添加的ViewBuilder 属性--像这样:

extension ContainerView {
    init(@ViewBuilder _ content: @escaping () -> Content) {
        self.init(content: content)
    }
}

注意我们在方便初始化器的参数标签前添加了下划线。这是为了避免在符合要求的类型没有真正声明我们所需的初始化器的情况下陷入无限循环,因为现在这两个初始化器将有不同的签名。

如果我们现在让我们所有的自定义容器视图都符合ContainerView ,而不是直接使用View ,那么我们就可以像原来那样简单地声明我们的content 闭包,同时还可以获得完整的ViewBuilder 能力:

struct Carousel<Content: View>: ContainerView {
    var content: () -> Content

    var body: some View {
        ScrollView(.horizontal) {
            HStack(content: content).padding()
        }
    }
}

我们的OnboardingCarousel 现在的工作方式与以前完全一样,只是现在我们更容易定义Carousel 和任何其他我们现在或将来可能想要建立的容器视图。

值得注意的是,这个特殊的实现只支持不接受任何额外参数的容器视图,尽管我们可以随时添加支持,如果我们最终需要的话。