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

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

3 Answers

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.

4

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

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") })
2

Your Answer

Sign up or log in

Sign up using Google Sign up using Facebook Sign up using Email and Password

Post as a guest

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

You Might Also Like