I need a custom tab bar for my application. The implementations of custom nav bar I've seen so far stacks screens in a ZStack and use opacity to conditionally show the selected screen. My issue is that a lot of my current logic relies on onAppear. And switching opacity doesn't trigger onAppear.
My question is if there are other ways to build a custom tab bar which keep track of state for each screen like the ZStack/opacity approach but also triggers onAppear. Or do I just have to adapt the rest of the app to not rely on onAppear?
In this sample code, you can see how switching tab triggers onAppear for NativeTabView but doesn't for my CustomTabView:
import SwiftUI
struct ContentView: View {
var body: some View {
NativeTabView()
CustomTabView()
}
}
#Preview {
ContentView()
}
struct NativeTabView: View {
var body: some View {
TabView {
Screen(name: "first")
.tabItem { Text("first") }
Screen(name: "second")
.tabItem { Text("second") }
}
}
}
struct CustomTabView: View {
@State var currentTab = "first"
var body: some View {
VStack(spacing: 0) {
content
tabBar
}
}
var content: some View {
ZStack {
Screen(name: "first")
.opacity(currentTab == "first" ? 1 : 0)
Screen(name: "second")
.opacity(currentTab == "second" ? 1 : 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
var tabBar: some View {
HStack {
Text("first")
.onTapGesture {
currentTab = "first"
}
.foregroundStyle(currentTab == "first" ? .blue : .black)
.frame(maxWidth: .infinity)
Text("second")
.foregroundStyle(currentTab == "second" ? .blue : .black)
.onTapGesture {
currentTab = "second"
}
.frame(maxWidth: .infinity)
}
.frame(height: 44)
.frame(maxWidth: .infinity)
}
}
struct Screen: View {
let name: String
var body: some View {
Text(name)
.onAppear {
print("onAppear: \(name)")
}
}
}
You'd need to make your own onAppear
and use that instead. Let's call this tabOnAppear
. You can implement this as a PreferenceKey
.
struct TabOnAppearKey: PreferenceKey {
static let defaultValue: @MainActor () -> Void = {}
static func reduce(value: inout @MainActor () -> Void, nextValue: () -> @MainActor () -> Void) {
let curr = value
let next = nextValue()
value = {
curr()
next()
}
}
}
Note the ordering of curr()
and next()
affects the order in which tabOnAppear
is called between sibling views.
Then the tabOnAppear
modifier can be implemented as a transformPreference
.
extension View {
func tabOnAppear(_ action: @MainActor @escaping () -> Void) -> some View {
self
.transformPreference(TabOnAppearKey.self) { value in
let curr = value
value = {
curr()
action()
}
}
}
}
// usage:
struct Screen: View {
let name: String
var body: some View {
Text(name)
.tabOnAppear {
print("onAppear: \(name)")
}
}
}
Note the ordering of curr()
and next()
affects the order in which tabOnAppear
is called between parent and child views.
Finally, instead of wrapping the Screen
s in if
statements, we put an invisible view wrapped in an if
in the backgroundPreferenceValue
. We can then use onAppear
on that invisible view to call the preference value's closure.
Screen(name: "first")
.opacity(currentTab == "first" ? 1 : 0)
.backgroundPreferenceValue(TabOnAppearKey.self) { action in
if currentTab == "first" {
Color.clear
.onAppear {
action()
}
}
}
Screen(name: "second")
.opacity(currentTab == "second" ? 1 : 0)
.backgroundPreferenceValue(TabOnAppearKey.self) { action in
if currentTab == "second" {
Color.clear
.onAppear {
action()
}
}
}
To make this tab view more reusable, you can do
struct CustomTabView<Content: View, Selection: Hashable>: View {
@Binding var selectedTab: Selection
let content: Content
init(selectedTab: Binding<Selection>, @ViewBuilder content: () -> Content) {
self.content = content()
self._selectedTab = selectedTab
}
var body: some View {
ZStack(alignment: .bottom) {
ForEach(subviews: content) { view in
view
.frame(maxWidth: .infinity, maxHeight: .infinity)
.opacity(view.containerValues.tag(for: Selection.self) == selectedTab ? 1 : 0)
.backgroundPreferenceValue(TabOnAppearKey.self) { action in
if view.containerValues.tag(for: Selection.self) == selectedTab {
Color.clear
.onAppear {
action()
}
}
}
}
}
// The bottom tab bar
.safeAreaInset(edge: .bottom) {
HStack {
Spacer()
ForEach(subviews: content) { view in
view.containerValues.customTabItem
.onTapGesture {
if let selection = view.containerValues.tag(for: Selection.self) {
selectedTab = selection
}
}
.foregroundStyle(
view.containerValues.tag(for: Selection.self) == selectedTab ?
AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.opacity(1))
)
Spacer()
}
}
}
}
}
extension ContainerValues {
@Entry var customTabItem: AnyView = AnyView(EmptyView())
}
extension View {
func customTabItem<Content: View>(@ViewBuilder content: () -> Content) -> some View {
self.containerValue(\.customTabItem, AnyView(content()))
}
}
// Usage:
struct ContentView: View {
@State var selectedTab = "first"
var body: some View {
CustomTabView(selectedTab: $selectedTab) {
Screen(name: "first")
.customTabItem {
Text("first")
}
.tag("first")
Screen(name: "second")
.customTabItem {
Text("second")
}
.tag("second")
}
}
}
For versions before iOS 18,
ExtractMulti
from View Extractor instead of ForEach(subviews:)
_ViewTraitKey
and _trait(_:_:)
instead of ContainerValues
id
instead of tag
to identify tab selections.See my answer here for the specifics.