swiftswiftuiviewbuilder

How can I have a secondary optional ViewBuilder in a SwiftUI view?


This is the code that I currently have. Basically I want to have an optional ViewBuilder to add a conditional view, lets say an accessory view. In a kind of .overlay syntax, where you can either have or don't have an overlay. But at the time of inserting the view that I'm getting this error Cannot convert value of type '' to closure result type 'Content' 

public struct MyView<Content: View>: View {

    @State var isHidden: Bool = false
    // stuff here
    let content: (Int) -> Content
    var additionalView: (() -> Content)?
    
    public init(@ViewBuilder content: @escaping (Int) -> Content, additionalView: (() -> Content)? = nil) {
        self.content = content
        self.additionalView = additionalView
    }
    
    public var body: some View {
        VStack(spacing: 0) {
            ZStack {
                content(selection)
            }
            // more stuff
            
            if !isHidden {
                if let additionalView {
                    additionalView()
                }
                // Another view
            }
        }
    }
    
    public func accessoryView(@ViewBuilder content: @escaping () -> Content) -> some View {
        var view = self
        view.additionalView = content
        return view
    }
}

Finally I want to call it this way.

struct MyMainView<Content: View>: View {
    @StateObject var appInfo: AppInfo = AppInfo()
    @ViewBuilder let content: (Int) -> Content

    var body: some View {
        VStack(spacing: 0) {
            MyView(content: content)
                .accessoryView {
                    AnyKindOfView()
                // This is where it fails with "Cannot convert value of type 'AnyKindOfView' to closure result type 'Content'"
                }
                .onAppear {
                    //code
                }
        }
        .environmentObject(appInfo)
    }
}

What am I missing here? I have tried to create an additional, <AdditionalContent: View> for it but it doesn't seem to work.


Solution

  • You should indeed use an extra type parameter to identify the type of the accessory view. The accessory view will not necessarily be the same as Content after all. This is how some of the built-in SwiftUI views are designed, like how Label has 2 type parameters representing the title and the icon.

    public struct MyView<Content: View, Accessory: View>: View {
        // ...
        var additionalView: (() -> Accessory)?
    
        // ...
        public init(@ViewBuilder content: @escaping (Int) -> Content, additionalView: (() -> Accessory)?) {
            // ...
        }
    }
    
    extension MyView where Accessory == Never {
    
        public init(@ViewBuilder content: @escaping (Int) -> Content) {
            self.init(content: content, additionalView: nil)
        }
    }
    

    Note that the optional parameter makes Swift have a hard time inferring the Accessory type, so I removed it, and added a one-parameter init in an extension where Accessory == Never. Again, this is similar to how SwiftUI's built-in views with optional view builders work. For example, the Button initialisers that does not take a label view builder are declared in an extension where Label == Text.

    For the accessoryView modifier, it needs to be generic too. It should return a new MyView, with a different Accessory type parameter.

    public func accessoryView<NewAccessory: View>(@ViewBuilder content: @escaping () -> NewAccessory) -> some View {
        MyView<Content, NewAccessory>(content: self.content, additionalView: content)
    }
    

    Though I don't think this is necessary. Why not just pass in the view directly into the initialiser's additionalView argument?


    Another way is to use AnyView as the type of the accessory view. This involves making the inits generic as well.

    public struct MyView<Content: View>: View {
        // ...
        var additionalView: (() -> AnyView)?
    
        public init<Accessory: View>(@ViewBuilder content: @escaping (Int) -> Content, additionalView: (() -> Accessory)?) {
            self.content = content
            if let additionalView {
                self.additionalView = { AnyView(additionalView()) }
            }
        }
    
        // ...
    
        public func accessoryView<Accessory: View>(@ViewBuilder content: @escaping () -> Accessory) -> some View {
            var view = self
            view.additionalView = { AnyView(content()) }
            return view
        }
    }
    
    extension MyView {
    
        public init(@ViewBuilder content: @escaping (Int) -> Content) {
            self.init(content: content, additionalView: nil as (() -> Never)?)
        }
    }