Reputation: 4833
I have a container view defined like this (according to https://www.swiftbysundell.com/tips/creating-custom-swiftui-container-views/):
import Foundation
import SwiftUI
let stackItemDefaultBackground: Color = Color(UIColor(white: 1, alpha: 0.05))
/// The goal is to make a container view which arranges passed content and adds passed background
struct SampleBgContainer<ContentView: View, BackgroundView: View>: View {
var alignment: HorizontalAlignment
var padding: CGFloat
var cornerRadius: CGFloat
var content: () -> ContentView
var background: () -> BackgroundView
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
@ViewBuilder content: @escaping () -> ContentView,
/// uncommenting the default value here leads to error
@ViewBuilder background: @escaping () -> BackgroundView // = { stackItemDefaultBackground }
) {
self.alignment = alignment
self.padding = padding
self.cornerRadius = cornerRadius
self.content = content
self.background = background
}
var body: some View {
VStack(alignment: alignment) {
content()
}
.padding(EdgeInsets(all: self.padding))
.frame(idealWidth: .infinity, maxWidth: .infinity)
.background(background())
.clipShape(RoundedRectangle(cornerRadius: self.cornerRadius))
}
}
struct SampleBgContainer_Previews: PreviewProvider {
static var previews: some View {
Group {
ScrollView {
VStack {
SampleBgContainer(
content: {
Text("Hello")
Text("world")
},
background: { stackItemDefaultBackground }
)
SampleBgContainer(
content: { Text("Hello world") },
background: { stackItemDefaultBackground }
)
} /// vstack
} /// scrollview
} /// group
}
}
The problem is that I would like to provide a default value for background, but I don't know how.
Uncommenting the default value above leads to Cannot convert value of type 'Color' to closure result type 'BackgroundView'
error -- while using a same value (background: { stackItemDefaultBackground }
) in instantiation of view is OK and works.
Any ideas how to do this (how to convert color to View, which seems to happen implicitly when called in preview)?
Thank you!
Edit:
There seems to be two different problems:
A default value of an argument cannot be dynamically generated, but for SwiftUI we need this (as different views have to have different instances of background). We can solve this with Asperi
's approach above, as it creates a fresh instance in every call.
Converting color to View. This got me half way, but the problem is that once I do this, the compiler cannot infer the type of BackgroundView
:
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
title: String? = nil,
@ViewBuilder content: @escaping () -> ContentView
) {
self.init<ContentView, Rectangle>(
alignment: alignment,
padding: padding,
cornerRadius: cornerRadius,
title: title,
content: content,
/// default value
background: { Rectangle().background(VisualStyle.stackItemDefaultBackground) as! BackgroundView }
)
}
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
title: String? = nil,
@ViewBuilder content: @escaping () -> ContentView,
/// uncommenting the default value here leads to error
@ViewBuilder background: @escaping () -> BackgroundView // = { stackItemDefaultBackground }
) {
self.alignment = alignment
self.padding = padding
self.cornerRadius = cornerRadius
self.title = title
self.content = content()
self.background = background()
}
Calling it with explicit types at least got it to compile, but it is ugly, as this leaks the internal Rectangle
type into an interface:
SampleBgContainer<Text, Rectangle> {
Text("...")
}
However, even when doing this, the program still crashes at Rectangle().background(VisualStyle.stackItemDefaultBackground) as! BackgroundView
line.
So, still no help.
Upvotes: 1
Views: 982
Reputation: 385600
You said “when I'd needed n optional arguments, I'd have to make n^2 initializers ...” It's worse than that, because it's 2^n (exponential), not n^2 (quadratic). But, n here is the number of generic arguments for which you want to provide defaults, which means n is usually quite small (1 for your SampleBgContainer
).
You also said “I can't imagine this is how SwiftUI is implemented”, but yes, that's exactly how SwiftUI does it when SwiftUI wants to provide a default for a generic argument. For example, SwiftUI provides a Toggle
initializer that lets you use a string instead of a View
for the label. It converts the string into a Text
for you, and it's declared like this:
extension Toggle where Label == Text {
public init(_ titleKey: LocalizedStringKey, isOn: Binding<Swift.Bool>)
}
Anyway, there is a solution to the exponential explosion of init
overloads, and we can find it by looking at SwiftUI's Text
.
You can tweak the appearance of a Text
in lots of ways. For example, you can make it bold or italic, you can change the kerning or the tracking, and you can change the text color. But you don't pass any of these settings to Text
's initializer. You do them all by calling modifiers on the Text
. Some of modifiers (like foregroundColor
) apply to any View
, but others (like bold
, italic
, kerning
, and tracking
) are only available on Text
directly.
You can use the same system: custom modifiers that work only on your type, instead of init
arguments. This interface style lets you avoid the 2^n overload explosion.
First, omit all of the non-generic, defaultable arguments from init
:
struct SampleContainer<Content: View, Background: View>: View {
var alignment: HorizontalAlignment = .leading
var padding: CGFloat = VisualStyle.stackItemPadding
var cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius
var content: Content
var background: Background
init(
@ViewBuilder content: () -> Content,
@ViewBuilder background: () -> Background
) {
self.content = content()
self.background = background()
}
var body: some View {
VStack(alignment: alignment) {
content
}
.padding(.all, self.padding)
.frame(idealWidth: .infinity, maxWidth: .infinity)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: self.cornerRadius))
}
}
Second, provide a single init
overload that constrains all of the defaultable generic arguments to their default types. In your case, there's only one such argument:
extension SampleContainer where Background == Color {
init(
@ViewBuilder content: () -> Content
) {
self.content = content()
self.background = Color.white.opacity(0.05)
}
}
Finally, provide modifiers for changing the properties, including the generic arguments:
private func with(_ mutate: (inout Self) -> ()) -> Self {
var copy = self
mutate(©)
return copy
}
func stackAlignment(_ alignment: HorizontalAlignment) -> Self {
return self.with { $0.alignment = alignment }
}
func stackPadding(_ padding: CGFloat) -> Self {
return self.with { $0.padding = padding }
}
func stackRadius(_ radius: CGFloat) -> Self {
return self.with { $0.cornerRadius = radius }
}
func stackBackground<New: View>(@ViewBuilder _ background: () -> New) -> SampleContainer<Content, New> {
return .init(content: { content }, background: background)
}
}
Use it like this for default settings:
SampleContainer {
Text("default settings only")
Text("hello world")
}
Or use the modifiers to customize it:
SampleContainer {
Text("custom settings only")
Text("hello world")
}
.stackAlignment(.trailing)
.stackPadding(2)
.stackRadius(20)
.stackBackground {
LinearGradient(
colors: [
Color.red,
Color.blue,
],
startPoint: .top,
endPoint: .bottom
)
}
Upvotes: 4
Reputation: 257711
Here are possible variants of initializers
extension SampleBgContainer where BackgroundView == Color {
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
@ViewBuilder content: @escaping () -> ContentView,
@ViewBuilder background: @escaping () -> BackgroundView = { stackItemDefaultBackground }
) {
self.alignment = alignment
self.padding = padding
self.cornerRadius = cornerRadius
self.content = content
self.background = background
}
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
@ViewBuilder content: @escaping () -> ContentView
) {
self.init(alignment: alignment, padding: padding, content: content, background: { stackItemDefaultBackground })
}
}
Tested with Xcode 13 / iOS 15
Upvotes: 2