Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Optional @ViewBuilder closure

Tags:

ios

swift

swiftui

Is it possible in SwiftUI to have an optional @ViewBuilder closure? For example, let's say I want to develop a custom view that takes two view builder closures like this:

import SwiftUI

struct TopAndBottomView<Content>: View where Content: View {
    let topContent: () -> Content
    let bottomContent: () -> Content

    init(@ViewBuilder topContent: @escaping () -> Content, @ViewBuilder bottomContent: @escaping () -> Content) {
        self.topContent = topContent
        self.bottomContent = bottomContent
    }

    var body: some View {
        VStack {
            topContent()
            Spacer()
            bottomContent()
        }
    }
}

struct TopAndBottomView_Previews: PreviewProvider {
    static var previews: some View {
        TopAndBottomView(topContent: {
            Text("TOP")
        }, bottomContent: {
            Text("BOTTOM")
        })
    }
}

But I'd like the bottom view to be optional. I tried with:

struct TopAndBottomView<Content>: View where Content: View {
    let topContent: () -> Content
    let bottomContent: (() -> Content)?

    init(@ViewBuilder topContent: @escaping () -> Content, @ViewBuilder bottomContent: (() -> Content)? = nil) {
        self.topContent = topContent
        self.bottomContent = bottomContent
    }

    var body: some View {
        VStack {
            topContent()
            Spacer()
            if bottomContent != nil {
                bottomContent!()
            }
        }
    }
}

but I get this error:

Function builder attribute 'ViewBuilder' can only be applied to a parameter of function type.

Thanks.

like image 733
matteopuc Avatar asked Mar 14 '20 22:03

matteopuc


3 Answers

@JoeBayLD asked:

How would you do this if the topContent and bottomContent are different view types? I made a new generic property but when using the default 'nil' argument, any callers can't infer the content type

You can make both ViewBuilder parameters non-optional, and then handle the "no bottom content" case by making an extension where BottomContent == EmptyView:

struct TopAndBottomView<TopContent: View, BottomContent: View>: View {
    let topContent: TopContent
    let bottomContent: BottomContent

    init(@ViewBuilder topContent: () -> TopContent,
         @ViewBuilder bottomContent: () -> BottomContent) {
        self.topContent = topContent()
        self.bottomContent = bottomContent()
    }

    var body: some View {
        VStack {
            topContent
            Spacer()
            bottomContent
        }
    }
}

extension TopAndBottomView where BottomContent == EmptyView {
    init(@ViewBuilder topContent: () -> TopContent) {
        self.init(topContent: topContent, bottomContent: { EmptyView() })
    }
}

// usage

TopAndBottomView(topContent: { Text("hello") })

TopAndBottomView(topContent: { Text("hello") }, bottomContent: { Text("world") })
like image 198
Matthew Avatar answered Nov 13 '22 15:11

Matthew


Taking into account buildIf feature of ViewBuilder the following approach is possible that allows to keep ViewBuilder in init (that is preferable)

Tested & works with Xcode 11.2 / iOS 13.2

struct TopAndBottomView<Content>: View where Content: View {
    let topContent: () -> Content
    let bottomContent: () -> Content?

    init(@ViewBuilder topContent: @escaping () -> Content, 
         @ViewBuilder bottomContent: @escaping () -> Content? = { nil }) {
        self.topContent = topContent
        self.bottomContent = bottomContent
    }

    var body: some View {
        VStack {
            topContent()
            Spacer()
            bottomContent()
        }
    }
}

So works as this one

struct TopAndBottomView_Previews: PreviewProvider {
    static var previews: some View {
        TopAndBottomView(topContent: {
            Text("TOP")
        }, bottomContent: {
            Text("BOTTOM")
        })
    }
}

and this one

struct TopAndBottomView_Previews: PreviewProvider {
    static var previews: some View {
        TopAndBottomView(topContent: {
            Text("TOP")
        })
    }
}
like image 41
Asperi Avatar answered Nov 13 '22 16:11

Asperi


In this fantastic post from Sundell, he suggests that we build a custom struct Unwrap to unwrap an optional value and turn it into a View, the following code is what he did in that post:

import SwiftUI

/// # Unwrap
/// unwraps a value (of type `Value`) and turns it 
/// into `some View` (== `Optional<Content>`).
struct Unwrap<Value, Content: View>: View {
    
    private let value  : Value?               // value to be unwrapped
    private let content: (Value) -> Content   // closure: turn `Value` into `Content`
    
    init(
        _ value: Value?,
         @ViewBuilder content: @escaping (Value) -> Content  // ⭐️ @ViewBuilder
    ) {
        self.value   = value
        self.content = content
    }
    
    var body: some View {   
        // map: (by the closure `content`)
        // nil (Optional<Value>.none)  -> nil (Optional<Content>.none)
        // Optional<Value>.some(Value) -> Optional<Content>.some(Content)
        value.map(content)  // Optional<Content>
    }
}

And then I wrote some code to demonstrate how we could use Unwrap to construct our views:

import SwiftUI

// MyView
struct MyView: View {
    
    @State private var isValue1Nil = false
    @State private var isValue2Nil = false
    
    var value1: Int? { isValue1Nil ? nil : 1}
    var value2: Int? { isValue2Nil ? nil : 2}
    
    var body: some View {
        VStack {
            
            // stack of `Unwrap`s
            VStack {
                // ⭐️ `Unwrap` used here.
                Unwrap(value1) {
                    Color.red.overlay(Text("\($0)"))
                }
                Unwrap(value2) {
                    Color.orange.overlay(Text("\($0)"))
                }
            }.border(Color.blue, width: 3)
                
            // toggles
            HStack {
                Toggle(isOn: $isValue1Nil) {
                    Text("value1 is nil")
                }
                Toggle(isOn: $isValue2Nil) {
                    Text("value2 is nil")
                }
                Spacer()
            }
                .padding()
                .overlay(Rectangle().stroke(Color.gray, style: .init(dash: [6])))
                
        } // VStack (container)
            .padding()
            .border(Color.gray, width: 3)
    }
}

And the result is as follows:

optional views

----[ edited ]----

Or alternatively, we can make a View extension to do the job:

// view.ifLet(_:then:)
extension View {
    @ViewBuilder func ifLet<Value, Content: View>(
        _ value: Value?, 
        @ViewBuilder then modifySelfWithValue: (Self, Value) -> Content 
    ) -> some View {
        if value != nil {
            modifySelfWithValue(self, value!)
        } else { self }
    }
}

The following is another demo on how to use this extension:

struct ContentView: View {
    
    @State private var isNil = false
    var value: Int? { isNil ? nil : 2 }
    
    var body: some View {
        VStack {
            
            Color.red.overlay(Text("1"))
                // ⭐️ view.ifLet(_:then:)
                .ifLet(value) { (thisView, value) in
                    // construct new view with `thisView` and `value`
                    VStack {
                        thisView
                        Color.orange.overlay(Text("\(value)"))
                    }
            } // view modified by `ifLet`
                .border(Color.blue, width: 3)
                
            // toggles
            Toggle(isOn: $isNil) { Text("value is nil") }
                .padding()
                .overlay(Rectangle().stroke(Color.gray, style: .init(dash: [6])))
            
        } // VStack (container)
            .padding()
            .border(Color.gray, width: 3).frame(height: 300)
    }
}

and the result is:

optional view

like image 3
lochiwei Avatar answered Nov 13 '22 15:11

lochiwei