swiftswiftui

How to have any view as a property in a struct that can be pass to a view in an array


I'm building a custom tabs component in SwiftUI, that I want to re-use. In order to do this, I have some UI that handles the state of the tabs that is custom and in order to show the content that belongs to the tab I want to use the native TabView. In order to make it re-usable, I want to be able to pass an array of a specific struct (lets call it TabInput) that includes an id, a title but also its content, which can be in theory any view, like a VStack, a List, etc. What I have now:

public struct TabInput: Identifiable {
    public let id: UUID = UUID()
    let title: Text
    // content property here..
}

public struct Tabs: View {
    @Binding var activeIndex: Int
    
    public var tabs: [TabInput]
    
    public var body: some View {
        TabBarView(activeIndex: $activeIndex, tabs: tabs.map { $0.title })
        
        TabView(selection: $activeIndex) {
            ForEach (tabs.indices, id: \.self) { tabId in
                // tabs content property here...
            }
        }
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }
}

I've tried adding a ViewBuilder property to the TabInput struct with a generic, but that gives me the problem that I have to define the generic upfront when typing the tabs var: Reference to generic type 'TabInput' requires arguments in <...>. Whatever I try to use as argument there doesn't seem to work. My code so far:

public struct TabInput<Content: View>: Identifiable {
    public let id: UUID = UUID()
    let title: Text
    
    @ViewBuilder let content: () -> Content
    
    public init(title: Text, content: @escaping () -> Content) {
        self.title = title
        self.content = content
    }
}

public struct Tabs: View {
    @Binding var activeIndex: Int
    
    public var tabs: [TabInput]
    
    public var body: some View {
        TabBarView(activeIndex: $activeIndex, tabs: tabs.map { $0.title })
        
        TabView(selection: $activeIndex) {
            ForEach (tabs.indices, id: \.self) { tabId in
                tabs[tabId].content()
            }
        }
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }
}


#Preview {
    Tabs(
        activeIndex: .constant(1),
        tabs: [
            TabInput(title: Text("Discover")) {
                HStack {
                    Text("Discover")
                }
            },
            TabInput(title: Text("For you")) {
                VStack {
                    Text("For you")
                }
            },
        ]
    )
    .loadCustomFontsForPreviews()
}

Anyone knows how I can make this better and achieve my goal?


Solution

  • You basically described the solution in your question:

    but also its content, which can be in theory any view, like a VStack, a List, etc.

    Since you need to pass multiple views as content to TabInput you need a @ViewBuilder, but you don't want to be restricted as to the type of views passed, which requires type erasure using AnyView.

    So let the content be of type AnyView, pass a @ViewBuilder to the initializer, and treat the content as AnyView:

    struct TabInput: Identifiable {
        
        //Parameters
        let id: UUID
        let title: Text
        let content: AnyView
        
        //Initializer
        init<Content: View>(id: UUID = UUID(), title: Text, @ViewBuilder content: () -> Content) {
            self.id = id
            self.title = title
            self.content = AnyView(content())
        }
    }
    

    Since your code wasn't reproducible due to the missing TabBarView, I put one together to get it working. If you provide your actual TabBarView, I will update the code below to include it:

    import SwiftUI
    
    struct TabInput: Identifiable {
        
        //Parameters
        let id: UUID
        let title: Text
        let content: AnyView
        
        //Initializer
        init<Content: View>(id: UUID = UUID(), title: Text, @ViewBuilder content: () -> Content) {
            self.id = id
            self.title = title
            self.content = AnyView(content())
        }
    }
    
    struct Tabs: View {
        
        //Parameters
        @Binding var activeIndex: Int
        let tabs: [TabInput]
        
        //Body
        var body: some View {
            ZStack(alignment: .bottom) {
                
                TabView(selection: $activeIndex) {
                    ForEach(tabs.indices, id: \.self) { tabId in
                        tabs[tabId].content
                            .font(.largeTitle)
                    }
                }
                .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
                
                TabBarView(activeIndex: $activeIndex, tabs: tabs)
                    .animation(.easeInOut, value: activeIndex)
            }
        }
    }
    
    struct TabBarView: View {
        
        //Parameters
        @Binding var activeIndex: Int
        let tabs: [TabInput]
        
        //Body
        var body: some View {
            
            HStack {
                ForEach(tabs.indices, id: \.self) { tabIndex in
                    Button {
                        withAnimation {
                            activeIndex = tabIndex
                        }
                    } label: {
                        tabs[tabIndex].title
                            .frame(maxWidth: .infinity, alignment: .center)
                            .padding()
                            .foregroundStyle(activeIndex == tabIndex ? Color.white : Color.primary)
                    }
                    .background(
                        activeIndex == tabIndex ? Color.blue : Color.clear,
                        in: Capsule()
                    )
                }
                .padding(.horizontal)
            }
        }
    }
    
    //Preview
    #Preview("Tabs") {
        @Previewable @State var activeIndex: Int = 0 // <- use @Previewable so you don't have to use a constant value binding
        
        Tabs(
            activeIndex: $activeIndex,
            tabs: [
                TabInput(title: Text("Discover")) {
                    HStack {
                        Text("Discover")
                    }
                },
                TabInput(title: Text("For you")) {
                    VStack {
                        Text("For you")
                    }
                },
            ]
        )
    }
    

    enter image description here