Tomáš Kafka
Tomáš Kafka

Reputation: 4833

SwiftUI container view - how to provide default value for a property?

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:

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

  2. 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

Answers (2)

rob mayoff
rob mayoff

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(&copy)
    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

Asperi
Asperi

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

Related Questions