101

I'm implementing a very custom NavigationLink called MenuItem and would like to reuse it across the project. It's a struct that conforms to View and implements var body : some View which contains a NavigationLink. I need to somehow store the view that shall be presented by NavigationLink in the body of MenuItem but have yet failed to do so.

I have defined destinationView in MenuItem's body as some View and tried two initializers:

This seemed too easy:

struct MenuItem: View {
    private var destinationView: some View

    init(destinationView: View) {
        self.destinationView = destinationView
    }

    var body : some View {
        // Here I'm passing destinationView to NavigationLink...
    }
}

--> Error: Protocol 'View' can only be used as a generic constraint because it has Self or associated type requirements.

2nd try:

struct MenuItem: View {
    private var destinationView: some View

    init<V>(destinationView: V) where V: View {
        self.destinationView = destinationView
    }

    var body : some View {
        // Here I'm passing destinationView to NavigationLink...
    }
}

--> Error: Cannot assign value of type 'V' to type 'some View'.

Final try:

struct MenuItem: View {
    private var destinationView: some View

    init<V>(destinationView: V) where V: View {
        self.destinationView = destinationView as View
    }

    var body : some View {
        // Here I'm passing destinationView to NavigationLink...
    }
}

--> Error: Cannot assign value of type 'View' to type 'some View'.

I hope someone can help me. There must be a way if NavigationLink can accept some View as an argument. Thanks ;D

Alex
  • 1,023
  • 2
  • 7
  • 9
  • I'm having a hard time "visualizing" your issue. Let me know where I'm wrong. You have one view called `MenuItem`... it's part of another View that is the destination of a `NavigationLink`? Is that all? If so, why not just make a `MainMenu` view that has `MenuItem` views and is the destination of your `NavigationLink`? EDIT: Could you give a "concrete" example of what you are trying to do in words? I'm think what's confusing for me is that? (Good question by the way. I just don't think I understand what you actually want for output.) –  Jul 08 '19 at 16:20
  • Hey @dfd! Thanks for replying ;D I've updated the first paragraph to better reflect what I was trying to do which is creating an alternative to `NavigationLink` called `MenuItem`. @rraphael gave the right answer and now everything's working as expected. Generics is the important keyword to look up further. – Alex Jul 08 '19 at 18:57

10 Answers10

130

To sum up everything I read here and the solution which worked for me:

struct ContainerView<Content: View>: View {
    @ViewBuilder let content: Content
    
    var body: some View {
        content
    }
}

This not only allows you to put simple Views inside, but also, thanks to @ViewBuilder, use if-else and switch-case blocks:

struct SimpleView: View {
    var body: some View {
        ContainerView {
            Text("SimpleView Text")
        }
    }
}

struct IfElseView: View {
    var flag = true
    
    var body: some View {
        ContainerView {
            if flag {
                Text("True text")
            } else {
                Text("False text")
            }
        }
    }
}

struct SwitchCaseView: View {
    var condition = 1
    
    var body: some View {
        ContainerView {
            switch condition {
            case 1:
                Text("One")
            case 2:
                Text("Two")
            default:
                Text("Default")
            }
        }
    }
}

Bonus: If you want a greedy container, which will claim all the possible space (in contrary to the container above which claims only the space needed for its subviews) here it is:

struct GreedyContainerView<Content: View>: View {
    @ViewBuilder let content: Content
    
    var body: some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

If you need an initializer in your view then you can use @ViewBuilder for the parameter too. Even for multiple parameters if you will:

init(@ViewBuilder content: () -> Content) {…}
Hollycene
  • 287
  • 1
  • 2
  • 12
ramzesenok
  • 5,469
  • 4
  • 30
  • 41
36

The way Apple does it is using function builders. There is a predefined one called ViewBuilder. Make it the last argument, or only argument, of your init method for MenuItem, like so:

..., @ViewBuilder builder: @escaping () -> Content)

Assign it to a property defined something like this:

let viewBuilder: () -> Content

Then, where you want to diplay your passed-in views, just call the function like this:

HStack {
    viewBuilder()
}

You will be able to use your new view like this:

MenuItem {
   Image("myImage")
   Text("My Text")
}

This will let you pass up to 10 views and use if conditions etc. though if you want it to be more restrictive you will have to define your own function builder. I haven't done that so you will have to google that.

Cloud
  • 938
  • 1
  • 8
  • 24
Nathan Day
  • 5,981
  • 2
  • 24
  • 40
  • I have a bug with this, there is a ForEach within the MenuItem driven by an ObservedObject's array. When the array updates in the ObservedObject, the ForEach doesn't re-render its contents. Whereas the same code does when not placed within the MenuItem Content. Any ideas? It's the same with all content within the MenuItem, none of it gets re-rendered when the ObservedObject content updates. – Othyn Feb 09 '20 at 22:52
  • The Contents of the MenuItem is a closure and so the array within it is a copy, you could try changing the definition of your MenuItem so it takes your ObservedObject array as an argument and then pass it when you call your viewBuilder. – Nathan Day Feb 11 '20 at 05:41
  • 1
    In order for this to work you need to add Content as a generic type, see rraphael's answer. struct MenuItem: View { } – Brett Mar 11 '20 at 23:41
28

You should make the generic parameter part of MenuItem:

struct MenuItem<Content: View>: View {
    private var destinationView: Content

    init(destinationView: Content) {
        self.destinationView = destinationView
    }

    var body : some View {
        // ...
    }
}
rraphael
  • 10,041
  • 2
  • 25
  • 33
  • 4
    Thanks a lot! I'm still new to generics, so it's nice to see that it's in fact not that hard. So we're basically saying "Hey, there's `MenuItem` that contains an object conforming to `View`. We'll call its type `Content`. `destinationView` is of this type and we need it during `init()`". Note for anyone reading this later: You may remove the init() since its synthesised for you ;D – Alex Jul 08 '19 at 18:48
  • 1
    The only thing with this is the destinationView doesn't appear to update when the Content gets refreshed by an ObservedObject. Any ideas? – Othyn Feb 09 '20 at 22:53
14

You can create your custom view like this:

struct ENavigationView<Content: View>: View {

    let viewBuilder: () -> Content

    var body: some View {
        NavigationView {
            VStack {
                viewBuilder()
                    .navigationBarTitle("My App")
            }
        }
    }

}

struct ENavigationView_Previews: PreviewProvider {
    static var previews: some View {
        ENavigationView {
            Text("Preview")
        }
    }
}

Using:

struct ContentView: View {

    var body: some View {
        ENavigationView {
            Text("My Text")
        }
    }

}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Mickael Belhassen
  • 2,970
  • 1
  • 25
  • 46
  • Within ContentView, I have an ObservedObject that is dynamically updating a ForEach's array contents. But, when I place that foreach within the ENavigationView Content within ContentView, that ForEach no longer re-renders when the ObservedObject updates. Any ideas? This is the same for all Content within the ENavigationView within the ContentView. – Othyn Feb 09 '20 at 22:55
  • Thank you, this is a straightforward example. Bindings passed in to the view constructed by the view builder are updated as expected. – Rocket Garden Apr 30 '20 at 07:04
11

You can pass a NavigationLink (or any other view widget) as a variable to a subview as follows:

import SwiftUI

struct ParentView: View {
    var body: some View {
        NavigationView{

            VStack(spacing: 8){

                ChildView(destinationView: Text("View1"), title: "1st")
                ChildView(destinationView: Text("View2"), title: "2nd")
                ChildView(destinationView: ThirdView(), title: "3rd")
                Spacer()
            }
            .padding(.all)
            .navigationBarTitle("NavigationLinks")
        }
    }
}

struct ChildView<Content: View>: View {
    var destinationView: Content
    var title: String

    init(destinationView: Content,  title: String) {
        self.destinationView = destinationView
        self.title = title
    }

    var body: some View {
        NavigationLink(destination: destinationView){
            Text("This item opens the \(title) view").foregroundColor(Color.black)
        }
    }
}

struct ThirdView: View {
    var body: some View {
        VStack(spacing: 8){

            ChildView(destinationView: Text("View1"), title: "1st")
            ChildView(destinationView: Text("View2"), title: "2nd")
            ChildView(destinationView: ThirdView(), title: "3rd")
            Spacer()
        }
        .padding(.all)
        .navigationBarTitle("NavigationLinks")
    }
}
Yarm
  • 1,178
  • 4
  • 16
  • 29
10

The accepted answer is nice and simple. The syntax got even cleaner with iOS 14 + macOS 11:

struct ContainerView<Content: View>: View {
  @ViewBuilder var content: Content
    
  var body: some View {
    content
  }
}

Then continue to use it like this:

ContainerView{
  ...
}
Clifton Labrum
  • 13,053
  • 9
  • 65
  • 128
7

I really struggled to make mine work for an extension of View. Full details about how to call it are seen here.

The extension for View (using generics) - remember to import SwiftUI:

extension View {

    /// Navigate to a new view.
    /// - Parameters:
    ///   - view: View to navigate to.
    ///   - binding: Only navigates when this condition is `true`.
    func navigate<SomeView: View>(to view: SomeView, when binding: Binding<Bool>) -> some View {
        modifier(NavigateModifier(destination: view, binding: binding))
    }
}


// MARK: - NavigateModifier
fileprivate struct NavigateModifier<SomeView: View>: ViewModifier {

    // MARK: Private properties
    fileprivate let destination: SomeView
    @Binding fileprivate var binding: Bool


    // MARK: - View body
    fileprivate func body(content: Content) -> some View {
        NavigationView {
            ZStack {
                content
                    .navigationBarTitle("")
                    .navigationBarHidden(true)
                NavigationLink(destination: destination
                    .navigationBarTitle("")
                    .navigationBarHidden(true),
                               isActive: $binding) {
                    EmptyView()
                }
            }
        }
    }
}
George
  • 25,988
  • 10
  • 79
  • 133
  • 2
    Outstanding. This is an elegant implementation of view customization. It also federates hiding navbar in one place rather than repeating it in all Views, and does customization such as hiding disclosure indicator in this case, when encased in a List. – Tao-Nhan Nguyen Mar 19 '20 at 14:38
3

Alternatively you can use a static function extension. For example, I make a titleBar extension to Text. This makes it very easy to reuse code.

In this case you can pass a @Viewbuilder wrapper with the view closure returning a custom type that conforms to view. For example:

import SwiftUI

extension Text{
    static func titleBar<Content:View>(
        titleString:String,
        @ViewBuilder customIcon: ()-> Content
    )->some View {
        HStack{
            customIcon()
            Spacer()
            Text(titleString)
                .font(.title)
            Spacer()
        }
        
    }
}

struct Text_Title_swift_Previews: PreviewProvider {
    static var previews: some View {
        Text.titleBar(titleString: "title",customIcon: {
            Image(systemName: "arrowshape.turn.up.backward")
        })
            .previewLayout(.sizeThatFits)
    }
}


Nic Wanavit
  • 2,363
  • 5
  • 19
  • 31
2

If anyone is trying to pass two different views to other view, and can't do it because of this error:

Failed to produce diagnostic for expression; please submit a bug report...

Because we are using <Content: View>, the first view you passed, the view is going to store its type, and expect the second view you are passing be the same type, this way, if you want to pass a Text and an Image, you will not be able to.

The solution is simple, add another content view, and name it differently.

Example:

struct Collapsible<Title: View, Content: View>: View {
@State var title: () -> Title
@State var content: () -> Content

@State private var collapsed: Bool = true

var body: some View {
    VStack {
        Button(
            action: { self.collapsed.toggle() },
            label: {
                HStack {
                    self.title()
                    Spacer()
                    Image(systemName: self.collapsed ? "chevron.down" : "chevron.up")
                }
                .padding(.bottom, 1)
                .background(Color.white.opacity(0.01))
            }
        )
        .buttonStyle(PlainButtonStyle())
        
        VStack {
            self.content()
        }
        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: collapsed ? 0 : .none)
        .clipped()
        .animation(.easeOut)
        .transition(.slide)
    }
}

}

Calling this View:

Collapsible {
            Text("Collapsible")
        } content: {
                ForEach(1..<5) { index in
                    Text("\(index) test")
                }
        }
Alessandro Pace
  • 206
  • 4
  • 8
  • 1
    Note that erasing view types can incur a performance penalty because SwiftUI looses information about its internal structure. To solve your problem, you could introduce a second generic type constrained to View (e.g. Content2) and use that on the second view. – Alex Nov 01 '22 at 11:29
  • You are right Alex, I will update my answer with two types – Alessandro Pace Nov 02 '22 at 11:39
0

Syntax for 2 Views

struct PopOver<Content, PopView> : View where Content: View, PopView: View {
var isShowing: Bool
@ViewBuilder var content: () -> Content
@ViewBuilder var popover: () -> PopView

var body: some View {
    ZStack(alignment: .center) {
        self
            .content()
            .disabled(isShowing)
            .blur(radius: isShowing ? 3 : 0)
        
        ZStack {
            self.popover()
        }
        .frame(width: 112, height: 112)
        .opacity(isShowing ? 1 : 0)
        .disabled(!isShowing)
        
    }
}

}

Christopher
  • 73
  • 10