Replicating TabView view hierarchy behavior?

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:

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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.

It's my understanding that SwiftUI state is kept externally from the Views we create in our code, and changes to the view hierarchy are what makes new state appear or be dropped. And views staying where they are keeps state.

This would be why in attempt #4 where we're hiding a view optionally failed: in one instance, the view hierarchy is something like Container { View {} }, and in another it's Container { Hidden { View {} } }. Which is why the state drops.

While I bet TabView under-the-hood uses UIKit stuff probably, could it be possible that one way to re-implement it in SwiftUI is to combine .hidden() and PreferenceKeys? Perhaps the entirety of the TabView's content argument is always .hidden() in the view hierarchy so that it keeps state, and a PreferenceKey for the view's tag "brings" the view to another, visible portion of the view hierarchy?

Not sure if I can help with your root problem, but I too have experienced issues with environment and UIKit wrappers. I think maybe you could add

@Environment(\.self) private var env

… to your HidableView and inject it back into your hosting view:

func updateUIView(_ container: ViewContainer<Content>, context: Context) {
    container.child.rootView = view.environment(\.self, env)
    container.isContentHidden = isHidden
}

It definitely feels like a gode smell, but maybe it'll fix your problems?

That worked, @sveinhal! I do agree it's a bit of a code smell, but I didn't know you could port the entire environment over to the embedded SwiftUI View with @Environment(\.self). That doesn't seem too hacky to me.

It'd be great if there were a more SwiftUI-y solution that didn't need to have both a UIViewRepresentable and a UIHostingController embedded together, but I'll go with this for now.

There's not much documentation on how exactly .onAppear and such work. I suppose another solution would be to ditch .onAppear and roll my own that I call from the pseudo-TabView, but that'd be unfortunate that I have to go around the framework.

1 Like

Although, I am already seeing other side effects. While porting over the environment now works, the .ignoresSafeArea() doesn't pass through. I can fix this a bit more manually & easily, but it means I'll be missing something else and it'll be a game of whack-a-mole for manually porting over everything needed that SwiftUI does itself when you're using UIViewRepresentable or UIHostingController correctly.

Maybe you'll get more insightful responses over at Apple Developer Forums, since this question isn't so much about Swift-the-language, but more about the inner workings of Apple's SwiftUI framework. I think that's where most Apple platform devs hang around, including a lot of Apple employees.