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.
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!
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)
}
}
}