TabView accepts a content
body that contains multiple subviews, and displays only one of them at a time.
I would like to make my own view that receives heterogeneous Content via a viewBuilder, the same way, and then selectively displays only one of the child views so provided, hiding the rest.
Something like what TabView does, but under my programmatic control, and without TabView's other behaviors. It should be able to accept content items of any mix of types, e.g. from a ForEach
output, Group
elements, etc. Just like TabView does.
What I have:
But I want just one of those three subviews to be visible (my choice).
Here is the code that I would like to make work, the goal should be clear...
import SwiftUI
struct SwitcherView<Content>: View where Content: View {
let content: () -> Content
@State var selection: Int = 0 // will be changed by logic not shown
public init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
@MainActor public var body: some View {
VStack {
content()[selection] // obviously this doesn't work
}
}
}
struct SwitcherView_Previews: PreviewProvider {
static var previews: some View {
VStack {
Text("I would like to show just one of these at a time:")
SwitcherView {
// Note heterogeneous content: each element
// has its own type and state, none of which
// SwitcherView gets to know about explicitly..
// just like TabView!
Text("First Content")
.frame(width: 100, height: 100)
.background(.red)
Button(action: {}, label: {
Text("I'm a yellow button!")
})
.buttonStyle(.borderedProminent)
.tint(.green)
.frame(width: 100, height: 100)
HStack {
Text("Some thing").background(.yellow)
Text("Else").background(.blue)
}
.frame(width: 100, height:100)
.background(.brown)
}
}
}
}
TabView does it, so it's clearly possible! (for Apple.)
Approaches that don't work:
Privately wrap the content in a custom Layout
: Layout
s can't hide subviews.
Receive content as an array of AnyView instead: horribly un-SwiftUI like to use, and forces use of AnyView
, which is performance poison
List my sub-views explicitly, wrapping each with its show/hide logic: Bad encapsulation. Not the desired API.
TLDR; This can be implemented with result builders or variadic views. The latter has better (not yet perfect) support for ForEach and Group, but uses an 'undocumented' interface.
This is possible with the help of a Result Builder, TabViews are implemented using a similar technique. Note that you will need separate buildBlock functions and internal views for different amounts of subviews. This is the reason the number of subviews in SwiftUI is limited to 10! You can probably use the new Swift Macro's to prevent you from hand-coding this (but I consider this to be beyond the scope of this answer).
I am going to use a custom environment variable to control which view is shown.
private struct SelectedIndexKey: EnvironmentKey {
static let defaultValue: Int = 0
}
extension EnvironmentValues {
var selectedIndex: Int {
get { self[SelectedIndexKey.self] }
set { self[SelectedIndexKey.self] = newValue }
}
}
The code for the result builder:
struct SwitcherInternal3<V0: View, V1: View, V2: View>: View {
@Environment(\.selectedIndex) private var selectedIndex
let v0: V0
let v1: V1
let v2: V2
var body: some View {
switch(selectedIndex) {
case 0:
v0
case 1:
v1
case 2:
v2
default:
EmptyView()
}
}
}
@resultBuilder
enum SwitcherBuilder {
static func buildBlock<V0: View, V1: View, V2: View>(
_ v0: V0,
_ v1: V1,
_ v2: V2) -> some View {
SwitcherInternal3(v0: v0, v1: v1, v2: v2)
}
}
The implementation of the SwitcherView:
struct SwitcherView<Content>: View where Content : View {
let selectedIndex: Int
let content: Content
public init(_ selectedIndex: Int, @SwitcherBuilder content: () -> Content) {
self.selectedIndex = selectedIndex
self.content = content()
}
var body: some View {
content
.environment(\.selectedIndex, selectedIndex)
}
}
And how to use the SwitcherView:
struct ContentView: View {
@State var selectedIndex = 0
var body: some View {
SwitcherView(selectedIndex) {
Text("First Content")
.frame(width: 100, height: 100)
.background(.red)
Button(action: {}, label: {
Text("I'm a yellow button!")
})
.buttonStyle(.borderedProminent)
.tint(.green)
.frame(width: 100, height: 100)
HStack {
Text("Some thing").background(.yellow)
Text("Else").background(.blue)
}
.frame(width: 100, height:100)
.background(.brown)
}
Button(action: { selectedIndex = (selectedIndex + 1) % 3 }) {
Text("Select View")
}
}
}
This approach uses variadic views https://movingparts.io/variadic-views-in-swiftui, which allows us to access child views. Since this uses an interface prefixed with an underscore, I would not use it in production. The code is a lot simpler, since we do not need a custom resultBuilder and just use the viewBuilder.
struct SwitcherView<Content: View>: View {
let selectedIndex: Int
let content: Content
init(_ selectedIndex: Int, @ViewBuilder content: () -> Content) {
self.selectedIndex = selectedIndex
self.content = content()
}
var body: some View {
_VariadicView.Tree(SwitchedLayout(selectedIndex)) { content }
}
}
struct SwitchedLayout: _VariadicView_MultiViewRoot {
let selectedIndex: Int
init(_ selectedIndex: Int) {
self.selectedIndex = selectedIndex
}
@ViewBuilder
func body(children: _VariadicView.Children) -> some View {
if selectedIndex < children.count {
children[selectedIndex]
} else {
EmptyView()
}
}
}
Note: This approach still does not support mixing ForEach and other content! ForEach on its own works fine.