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?
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")
}
},
]
)
}