swiftuiswiftui-tabview

How can I create a custom tab bar that triggers onAppear


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)")
            }
    }
}

Solution

  • 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 Screens 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,

    See my answer here for the specifics.