How do you create a SwiftUI view that takes an optional secondary View argument?
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.
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") })
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
}