Context:
I am attempting to make a custom design for a TabView
on tvOS. Specifically, it's a left side bar, as opposed to the default of a top set of pill tab items. The main problem is that I'm having some trouble with replicating how the default TabView works with the view lifecycle of the tab content views.
Specifically, when using a TabView, the child content views retain their @State
but .onAppear()
and .onDisappear()
only activate when the view actually appears or disappears. Replication of this is surprisingly difficult.
Sample program:
import SwiftUI
struct TestView<Content>: View where Content : View {
@State var count = 0
let content: () -> Content
var body: some View {
VStack {
Text("Count: \(count)")
Button(action: {
count += 1
}, label: {
Text("Increment")
})
content()
}
}
}
struct ContentView: View {
@State var selection: Int = 0
var body: some View {
TabView(selection: $selection) {
ForEach(0..<3) { i in
TestView(content: {
Button(action: {
selection = (i + 1) % 3
}, label: {
Text("\(i). Go to \(i+1).")
})
.onAppear { print("onAppear: \(i)") }
.onDisappear { print("onDisappear: \(i)") }
})
.tag(i)
}
}
}
}
Attempts and issues:
- Attempt to use a
TabView
and not use the default.tabItem()
view.- A
TabView
with a selection binding and.tag()
'd content views, but no.tabItem()
will show a working tab view with no bottom toolbar on iOS. - The same is not true on tvOS. The top tab bar/pill still appears, but contains no tab items.
- The view can be hidden using
.tabViewStyle(.page(indexDisplayMode: .never))
, but this activates paging behavior by swiping across screens, that I do not want. - It appears impossible to create a custom TabViewStyle, since it requires using internals of SwiftUI (and the protocol is empty).
- A
- Replicate behavior with conditionally rendering the view based on the selection.
- This would be like
switch tabSelection { case .home: Home(); case .settings: Settings() }
- While
.onAppear()
and.onDisappear()
work here, the@State
is not held for the views when switching between.
- This would be like
- Replicate behavior with
.opacity()
- This would set the opacity to 1 for the selected view, and 0 for the non-selected views (all held within a ZStack).
- While the State is held for each view, all have
.onAppear()
called instantly, and.onDisappear()
is never called.
- Replicate behavior with
.hidden()
- The SwiftUI
.hidden()
is unconditional though, so this is uses a view modifier which takes the main view and conditionally applies.hidden()
on it. - Code:
@ViewBuilder func isHidden(_ hidden: Bool) -> some View { if hidden { self.hidden() } else { self } }
- Because of SwiftUI shenanigans, the
@State
is not held when transitioning from hidden to not hidden. I feel like there's something I could do here to preserve the state, but I'm not sure what.
- The SwiftUI
- UIKit shenanigans
- Utilize the HideableView UIViewRepresentable from Hiding and unhiding views in SwiftUI | by Håvard Fossli | Medium
- This seems to actually work! Except that
.environment()
-injected resources aren't properly making their way to the child views. It seems to sidestep some core functionality of how SwiftUI and UIKit properly integrate. I don't wish to continue making hacks to improve this if it's going to be a losing battle trying to reconnect UIKit and SwiftUI. - I'm not sure of a way to modify this to fit my needs either.
So, I'm at an impasse. Are there any tips on this? I can best boil this down to needing to keep a view in the view hierarchy, but separating it from appearing/disappearing. I don't know how TabView
is able to achieve this on its internals.
Figuring out how TabView works internally at all has been very difficult, especially with how .tag()
with the Hashable selection works. I think it has something to do with PreferenceKeys, but I don't know much more than that.