Search code examples
iosswiftxcodeswiftui

iOS - How to Implement a Customizable Tab Bar with Transparent Overlay in SwiftUI?


I'm trying to create a custom tab bar in SwiftUI similar to the one in the Microsoft Teams app iOS. Specifically, I need the following functionality:

When the "More" tab item is pressed, a transparent overlay view should open, displaying additional options. Selecting any item in this overlay should set it as the root view for the "More" tab. I've attached a screenshot for reference.

enter image description here

enter image description here

struct ContentView: View {
    @State private var selectedTab = 0
    @State private var showMoreOptions = false

    var body: some View {
        VStack {
            TabView(selection: $selectedTab) {
                Text("Teams").tabItem { Label("Teams", systemImage: "person.3") }.tag(0)
                Text("Chat").tabItem { Label("Chat", systemImage: "message") }.tag(1)
                Text("Calendar").tabItem { Label("Calendar", systemImage: "calendar") }.tag(2)
                Text("Calls").tabItem { Label("Calls", systemImage: "phone") }.tag(3)
                Text("More").tabItem { Label("More", systemImage: "ellipsis") }
                    .tag(4)
                    .onTapGesture {
                        showMoreOptions.toggle()
                    }
            }

            if showMoreOptions {
                TransparentOverlayView()
            }
        }
    }
}

i stuck here to implement the same logic which is there in teams app. ex: when I press Updates in more bottom sheet it should set rootview of the more tab.

Any help or suggestions to implement this in SwiftUI would be greatly appreciated!


Solution

  • Here is a starting point, from which you can add your own styling/layout etc. This just uses a HStack for the bottom tab bar, and shows the "more" tabs in a Grid with 4 tabs per row.

    struct CustomTabView<Content: View, Selection: Hashable>: View {
        
        @Binding var selectedTab: Selection
        
        @ViewBuilder let content: () -> Content
        
        // The maximum number of tabs that can be shown at the bottom (including "More")
        let maxTabsShown = 5
        
        @State private var moreShown = false
        
        var body: some View {
            ZStack(alignment: .bottom) {
                ExtractMulti(content) { views in
                    // The current tab
                    ForEach(views) { view in
                        // Instead of this 'if', use .opacity(view.id(as: Selection.self) == selectedTab ? 1 : 0)
                        // to control visibility if you want to preserve the state in each tab (e.g. scroll offset)
                        if view.id(as: Selection.self) == selectedTab {
                            view
                                .frame(maxWidth: .infinity, maxHeight: .infinity)
                        }
                    }
                    
                    // The sheet for selecting the extra tabs
                    if moreShown {
                        Color.black.opacity(0.7)
                            .ignoresSafeArea(edges: .top)
                            .onTapGesture {
                                moreShown = false
                            }
                            .transition(.opacity)
                        
                        // Here I've laid the extra tabs in a grid
                        Grid(horizontalSpacing: 30, verticalSpacing: 30) {
                            let hiddenTabs = views.dropFirst(maxTabsShown - 1)
                            let viewRows = hiddenTabs.chunks(ofCount: 4)
                            
                            ForEach(viewRows.indices, id: \.self) { i in
                                let row = viewRows[i]
                                GridRow {
                                    ForEach(row) { view in
                                        Button {
                                            if let selection = view.id(as: Selection.self) {
                                                selectedTab = selection
                                                moreShown = false
                                            }
                                        } label: {
                                            if let label = view[CustomTabItemTrait.self] {
                                                label
                                            } else {
                                                Text("Unnamed")
                                            }
                                        }
                                    }
                                }
                            }
                        }
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(.background, in: UnevenRoundedRectangle(topLeadingRadius: 10, topTrailingRadius: 10))
                        .transition(.move(edge: .bottom).combined(with: .opacity))
                    }
                    Divider()
                }
            }
            // The bottom tab bar
            .safeAreaInset(edge: .bottom) {
                HStack {
                    Spacer()
                    ExtractMulti(content) { views in
                        let shownTabs = 
                            views.count <= maxTabsShown ?
                                views.prefix(maxTabsShown) :
                                views.prefix(maxTabsShown - 1)
                        
                        ForEach(shownTabs) { view in
                            Group {
                                if let label = view[CustomTabItemTrait.self] {
                                    label
                                } else {
                                    Text("Unnamed")
                                }
                            }
                            .onTapGesture {
                                if let selection = view.id(as: Selection.self) {
                                    selectedTab = selection
                                    moreShown = false
                                }
                            }
                            .foregroundStyle(
                                view.id(as: Selection.self) == selectedTab ?
                                    AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.opacity(1))
                            )
                            Spacer()
                        }
                        
                        if views.count > maxTabsShown {
                            Label("More", systemImage: "ellipsis")
                                .onTapGesture {
                                    moreShown.toggle()
                                }
                                .foregroundStyle(
                                    shownTabs.contains(where: { $0.id(as: Selection.self) == selectedTab }) ?
                                        AnyShapeStyle(.opacity(1)) : AnyShapeStyle(Color.accentColor)
                                )
                            Spacer()
                        }
                    }
                }
            }
            .animation(.default, value: moreShown)
        }
    }
    
    extension View {
        func customTabItem<Content: View>(@ViewBuilder content: () -> Content) -> some View {
            _trait(CustomTabItemTrait.self, AnyView(content()))
        }
    }
    
    struct CustomTabItemTrait: _ViewTraitKey {
        static let defaultValue: AnyView? = nil
    }
    

    This depends on ExtractMulti from View Extractor. It's not difficult to implement yourself if you don't want to add a dependency:

    // From View Extractor - https://github.com/GeorgeElsham/ViewExtractor
    public struct ExtractMulti<Content: View, ViewsContent: View>: View {
        let content: () -> Content
        let views: (Views) -> ViewsContent
    
        public init(_ content: Content, @ViewBuilder views: @escaping (Views) -> ViewsContent) {
            self.content = { content }
            self.views = views
        }
    
        public init(@ViewBuilder _ content: @escaping () -> Content, @ViewBuilder views: @escaping (Views) -> ViewsContent) {
            self.content = content
            self.views = views
        }
    
        public var body: some View {
            _VariadicView.Tree(
                MultiViewRoot(views: views),
                content: content
            )
        }
    }
    
    fileprivate struct MultiViewRoot<Content: View>: _VariadicView_MultiViewRoot {
        let views: (Views) -> Content
    
        func body(children: Views) -> some View {
            views(children)
        }
    }
    
    public typealias Views = _VariadicView.Children
    

    I have also used chunks(ofCount:) from Swift Algorithms. Again, this is something easy enough to implement yourself if you don't want to add a dependency.


    When using this, remember to use customTabItem instead of the built-in tabItem, and id instead of tag to tag each tab.

    Example usage:

    @State var selectedTab = 0
    
    var body: some View {
        CustomTabView(selectedTab: $selectedTab) {
            ForEach(0..<10) { i in
                Text("Tab \(i)")
                    .customTabItem {
                        Label("\(i)", systemImage: "0\(i).circle")
                    }
                    .id(i)
            }
        }
    }
    

    enter image description here

    enter image description here