如何创建一个 SwiftUI 视图,该视图接受一个可选的次要视图参数?

22

我正在尝试创建一个自定义的SwiftUI视图,它像默认视图一样运作,我可以使用方法或可选的初始化参数添加额外的内容到视图中。

SomeCustomView(title: "string argument") {
    // some view
}

SomeCustomView(title: "hello") {
    // some view
}.sideContent {
    // another view
}

// This style is acceptable too
SomeCustomView(title: "hello", sideContent: { /* another view */ }) {
    // some view
}

我该如何修改这个视图结构体,使其表现类似于上面的示例?

struct SomeCustomView<Content>: View where Content: View {
    let title: String
    let content: Content

    init(title: String, @ViewBuilder content: () -> Content) {
        self.title = title
        self.content = content()
    }

    var body: some View {
        VStack {
            Text(title)
            content
        }
    }
}

理想情况下,我希望有两个不同的正文 "模板",根据是否调用 sideContent 方法或设置了 sideContent 参数来切换。例如,

var body: some View {
    VStack {
        Text(title)
        content
    }
}

// or

var otherBody: some View {
    HStack {
        VStack {
            Text(title)
            content
        }
        sideContent
    }
}

为什么不创建一个组,这样你就可以根据某些属性只需使用 if 语句并包含一个侧视图或不包含? - matt
@matt 我尝试过类似的东西,但我无法弄清楚如何接受一个任意可选视图,它可以是 nil 或者可能是 EmptyView - jcdl
我明白你的意思。 - matt
3个回答

24

2021年11月更新(适用于Xcode 11.x,12.x和13.x)

经过一些思考和一些试错,我弄清楚了。回想起来,这似乎有点显而易见。

struct SomeCustomView<Content>: View where Content: View {
    let title: String
    let content: Content

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

    // returns a new View that includes the View defined in 'body'
    func sideContent<SideContent: View>(@ViewBuilder side: @escaping () -> SideContent) -> some View {
        HStack {
            self     // self is SomeCustomView
            side() 
        }
    }

    var body: some View {
        VStack {
            Text(title)
            content
        }
    }
}

它可以在有或没有方法调用的情况下工作。

SomeCustomView(title: "string argument") {
    // some view
}

SomeCustomView(title: "hello") {
    // some view
}.sideContent {
    // another view
}

之前的代码存在一个微妙的错误:body 应该是 self

    func sideContent<SideContent: View>(@ViewBuilder side: @escaping () -> SideContent) -> some View {
        HStack {
            body // <--- subtle bug, updates to the main View are not propagated 
            side() 
        }
    }

感谢Jordan Smith很久以前指出这一点。


2
优秀的解决方案。像魔法般地运行,并且非常符合SwiftUI范例。 - Wolf McNally
这在Xcode 11和Swift UI 1.0上不起作用。如果您尝试在没有sideContent闭包参数的情况下调用它,它会说“无法推断通用参数'Content'”。 - Anton Shevtsov
@AntonShevtsov 试试在函数中添加<SideContent: View>?在我的Xcode 11.6中有效。 - jcdl
3
这里有一个微妙但重要的错误:通过使用“body”,我们正在当前时刻运行主体视图生成器,并省略创建主体的视图。对于像这里显示的静态视图,这是可以的 - 但对于任何更具动态性的内容(例如,您可能有一些@State变量或首选项键),返回的视图将不再按预期工作,因为它不再封装与视图相关联的任何状态。建议不要使用body,而是使用视图本身,这样可以保留任何状态等信息。 - Jordan Smith

18

我在容器视图中遵循的一种模式是使用条件扩展一致性来支持不同变体的初始化程序。

这里有一个简单的面板视图示例,带有可选的页脚。

struct Panel<Content: View, Footer: View>: View {
    
    let content: Content
    let footer: Footer?
    
    init(@ViewBuilder content: () -> Content, footer: (() -> Footer)? = nil) {
        self.content = content()
        self.footer = footer?()
    }
    
    var body: some View {
        VStack(spacing: 0) {
            content

            // Conditionally check if footer has a value, if desirable.
            footer
        }
    }
}

// Support optional footer
extension Panel where Footer == EmptyView {
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
        self.footer = nil
    }
}

我相信这与苹果为支持内置类型的所有变体所做的类似。例如,以下是Button头文件的代码片段。

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension Button where Label == PrimitiveButtonStyleConfiguration.Label {

    /// Creates an instance representing the configuration of a
    /// `PrimitiveButtonStyle`.
    @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
    public init(_ configuration: PrimitiveButtonStyleConfiguration)
}

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension Button where Label == Text {

    /// Creates an instance with a `Text` label generated from a localized title
    /// string.
    ///
    /// - Parameters:
    ///     - titleKey: The key for the localized title of `self`, describing
    ///       its purpose.
    ///     - action: The action to perform when `self` is triggered.
    public init(_ titleKey: LocalizedStringKey, action: @escaping () -> Void)

    /// Creates an instance with a `Text` label generated from a title string.
    ///
    /// - Parameters:
    ///     - title: The title of `self`, describing its purpose.
    ///     - action: The action to perform when `self` is triggered.
    public init<S>(_ title: S, action: @escaping () -> Void) where S : StringProtocol
}

2
这是一个很好的方法来传递子内容并将其他内容作为修饰符,感谢分享! - stanlemon

3

我建议使用ViewModifyer而不是自定义视图。它们的工作方式如下:

struct SideContent<SideContent: View>: ViewModifier {

    var title: String
    var sideContent: (() -> SideContent)?

    init(title: String) {
         self.title = title
    }

    init(title: String, @ViewBuilder sideContent: @escaping () -> SideContent) {
         self.title = title
         self.sideContent = sideContent
    }

    func body(content: Content) -> some View {
        HStack {
          VStack {
             Text(title)
             content
           }
           sideContent?()
        }
    }
}

这可以作为SomeView().modifier(SideContent(title: "asdasd") { Text("asdasd")})使用,但是,如果你省略了side,你仍然需要指定其类型SomeView().modifier(SideContent<EmptyView>(title: "asdasd"))

更新

如果您删除标题,它会更简单,如您所提到的。

struct SideContent<SideContent: View>: ViewModifier {

    var sideContent: (() -> SideContent)

    init(@ViewBuilder sideContent: @escaping () -> SideContent) {
        self.sideContent = sideContent
    }

    func body(content: Content) -> some View {
        HStack {
            content
            sideContent()
        }
    }
}

此外,您可以为标题创建修饰符。

struct Titled: ViewModifier {

    var title: String

    func body(content: Content) -> some View {
        VStack {
            Text(title)
            content
        }
    }
}


SomeView()
   .modifier(Titled(title: "Title"))
   .modifier(SideContent { Text("Side") })

如果你想使用“模板”,要么使用Group,要么在表达式末尾转换为AnyView,以使类型匹配。 - gujci
这个技巧是可行的,但有点丑陋。您可以将SomeView中的title参数保留下来,避免使用SideContent<EmptyView> - jcdl

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接