Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do you create a SwiftUI view that takes an optional secondary View argument?

Tags:

ios

swift

swiftui

I am trying to create a custom SwiftUI view that acts like the default views where I can add extra content to a view with a method or optional initializer argument.

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
}

How can I modify this view struct to behave like the above example?

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
        }
    }
}

Ideally I'd have two different body "templates" that I could switch between depending on if the sideContent method was called or sideContent parameter was set. For example,

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

// or

var otherBody: some View {
    HStack {
        VStack {
            Text(title)
            content
        }
        sideContent
    }
}
like image 650
jcdl Avatar asked Oct 14 '19 22:10

jcdl


Video Answer


3 Answers

A pattern I've followed for container views is to use conditional extension conformance to support initializers for the different variations.

Here's an example of a simple Panel view with an optional Footer.

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
    }
}

I believe this is similar to what Apple does to support all the variations of the built-in types. For example, here's a snippet of the headers for a 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
}
like image 184
Joey C. Avatar answered Oct 16 '22 08:10

Joey C.


November 2021 update (Works in Xcode 11.x, 12.x, and 13.x)

After some thought and a bit of trial and error, I figured it out. It seems a bit obvious in hindsight.

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
        }
    }
}

It works with or without the method call.

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

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

Previous code with subtle bug: body should be self

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

Thank you Jordan Smith for pointing this out a long time ago.

like image 33
jcdl Avatar answered Oct 16 '22 08:10

jcdl


I would suggest using a ViewModifyer instead of custom Views. Those work like the follwing:

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?()
        }
    }
}

This may be used as SomeView().modifier(SideContent(title: "asdasd") { Text("asdasd")}), however, if you omit the side, you still need to specify its type SomeView().modifier(SideContent<EmptyView>(title: "asdasd"))

UPDATE

Removing the title it simplifies, as you mentioned.

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()
        }
    }
}

Also, you can make a modifier for Title.

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") })
like image 3
gujci Avatar answered Oct 16 '22 09:10

gujci