새로운 도시에 가서 새로운 곳에 갈 때 그 곳의 이정표를 보고 길을 찾기도 하지만, 여행객으로서 처음 마주하여 도시의 분위기와 정서를 알 수 있는 부분이기도 하다.
앱을 실행했을 때 이정표처럼 사용자가 가야 할 곳을 알려주고, 내 앱의 분위기를 확실하게 전달하는 수단으로 이 TabView는 최근 다양하고 예쁜 스타일로 고리타분함에서 변화를 꾀하고 있다.
그래서 그런지 요즘 자꾸 TabBar 모서리를 둥글려 달라는 디자이너들이 많다

Apple Music의 TabBar

토스의 TabBar
SwiftUI를 하면서 가장 난감할 때가 바로 이런 때이다.
분명 충분히 많은 기본 컴포넌트도 있고, 원하는 뷰를 뚝딱뚝딱 만들기도 편하지만, 기본 컴포넌트를 수정해 원하는 디자인을 만드려면 엄청난 공수가 들어간다.
(특히 Navigation 쪽 컴포넌트들이 그런 것 같다. 아예 처음부터 만들어야 하는 것도 있을 정도...)
이 점에 대해 Apple Developer Academy에서 Cupertino 팀에게 질문한 적이 있는데, 답변은 대충 '있는거 써라' 였다.
SwiftUI로 작업하면서 TabBar를 둥글려 달라는 수많은디자이너들의 요청에 '불가능한건 아닌데 꽤 공수가 든다' 라고 했을 때, 부디 해줬으면 좋겠다는 초롱초롱한 눈망울을 차마 져버리지 못했다.
하여 한 세 번 정도 모서리가 둥근 TabBar를 만드려는 시도를 했었다.


아직 SwiftUI에 불신이 쌓여있을 무렵(...), UIKit의 Storyboard에 UITabBarController를 넣고 하위에 UIHostingController를 넣은 뒤 여기에 SwiftUI View를 올려서 썼었다.
TabBar는 UIKit에서 수정하면 되니 기존과 마찬가지로 간단하게 둥근 모서리를 구현할 수 있었고, 순정 TabView를 사용하기 때문에 메모리 누수 등의 문제가 발생할 확률도 적었다.
솔직히 시도했던 방법 중에는 썩 나쁘지 않은 방법이었던 것 같기는 한데, 어딘가 석연찮은 방법임에는 분명하다.


그 이후에는 TabView를 직접 만드는 방법을 사용했다.
직접 만드는 방법이라 함은,
이렇게 하면 TabBar를 내 마음대로 커스터마이징 할 수 있고, Google에 검색하면 대부분 이런 방식을 설명하지만...

View가 단순히 hidden처리 된 것으로, 사용하지 않는 View도 메모리에 올라가 있다.
어? 그럼 if-else로 보여줄 View를 선택하면 되지 않냐, 싶지만...
그렇게 하면 탭 A에서 작업을 하던 도중 B로 갔다가 A로 돌아왔을 때 작업 내용이 날라갈 수 있다.
좋은 방법을 고민하다가 결국 해답을 찾지 못한 채 iOS 17이 도래했다.
그저 기억속에서 잊혀진 채로 "앞으로 순정 TabView만 사용하리라" 라고 다짐했었는데, 새로운 토이 프로젝트를 시작하면서 다시 TabBar 커스터마이징에 대한 고민이 수면 위로 떠올랐다.
사실 이 고민을 하면서 새로운 방법이 떠올랐기 때문인데, 그냥 TabView를 사용한 채 기본 TabBar를 숨겨버리고, 내가 만든 TabBar를 보여주면 되지 않을까 하는 생각이었다.

새로운 토이 프로젝트는 자동차 일기 프로젝트로, 요즘 트렌드에 맞게 테슬라의 인포테인먼트 시스템이나 현대자동차의 ccNC 인포테인먼트 시스템의 디자인을 오마쥬하고 싶었다.
그래서 TabBar를 공중에 띄워 마치 최신 전기 자동차의 느낌을 주려고 했다.
이 방법을 사용하면서 TabView는 SwiftUI의 것을 그대로 사용한다.
import SwiftUI
struct ContentView: View {
var body: some View {
TabView {
AView()
BView()
CView()
}
}
}
struct AView: View {...}
struct BView: View {...}
struct CView: View {...}
#Preview {
ContentView()
}

우선 원래 TabView를 사용하는 것 처럼 만들어준다.
TabView는 순정의 것을 사용하되, TabBar만 원하는 모양으로 만들어줄 것이다.
지금 상태로는 TabItem을 View에 추가해주지 않아서 TabBar가 나타나지 않는다.
그래서 "어? 이 상태로 위에 TabBar만 만들어주면 되는거 아닌가?" 싶겠지만, 자식 View에 색상을 넣어 보면

IgnoreSafeErea를 해주지 않았을 때 TabBar가 여전히 자리를 차지하고 있는 것을 볼 수 있다.
그래서 우선 저 TabBar를 없애줄 것인데, 그것은 AView, BView, CView를 Group으로 묶고, 거기에 toolbar(_:for:) modifier를 사용해 TabBar를 숨김해줄 것이다.
struct ContentView: View {
var body: some View {
TabView {
Group {
AView()
BView()
CView()
}
.toolbar(.hidden, for: .tabBar)
}
}
}

이제 TabBar가 더 이상 자리를 차지하지 않는다.
참고로 toolbar(_:for:) modifier는 iOS 16 이상에서 사용 가능하니, 그 이하 버전을 타겟으로 만드는 앱에서는 다른 방법을 찾아보는 것이 좋겠다.
여전히 그 미만 버전에서도 TabBar를 숨기는 방법이 있지만, 여기에서 굳이 다루지는 않을 예정이다.
이제 TabBar를 만들어줄건데, 그 전에 직접 만든 TabBar에서 TabView의 자식 View들을 컨트롤할 수 있도록 tag를 달아줄 것이다.
struct ContentView: View {
enum Tab { // Tag에서 사용할 Tab 열겨형
case a, b, c
}
@State private var selected: Tab = .a // 선택된 Tab을 컨트롤할 수 있는 상태 변수
var body: some View {
TabView(selection: $selected) { // selected 변수를 TabView의 selection에 binding
Group {
AView()
.tag(Tab.a) // 각 View에 tag 달아주기
BView()
.tag(Tab.b)
CView()
.tag(Tab.c)
}
.toolbar(.hidden, for: .tabBar)
}
}
}
이제 selected 변수를 조작하여 Tab을 선택하거나, 선택된 Tab을 확인할 수 있게 되었다.
다음은 TabBar를 내 마음대로 만들어준다.
var tabBar: some View {
HStack {
Spacer()
Button {
selected = .a
} label: {
VStack(alignment: .center) {
Image(systemName: "star")
.resizable()
.scaledToFit()
.frame(width: 22)
if selected == .a {
Text("View A")
.font(.system(size: 11))
}
}
}
.foregroundStyle(selected == .a ? Color.accentColor : Color.primary)
Spacer()
Button {
selected = .b
} label: {
VStack(alignment: .center) {
Image(systemName: "gauge.with.dots.needle.bottom.0percent")
.resizable()
.scaledToFit()
.frame(width: 22)
if selected == .b {
Text("View B")
.font(.system(size: 11))
}
}
}
.foregroundStyle(selected == .b ? Color.accentColor : Color.primary)
Spacer()
Button {
selected = .c
} label: {
VStack(alignment: .center) {
Image(systemName: "fuelpump")
.resizable()
.scaledToFit()
.frame(width: 22)
if selected == .c {
Text("View C")
.font(.system(size: 11))
}
}
}
.foregroundStyle(selected == .c ? Color.accentColor : Color.primary)
Spacer()
}
.padding()
.frame(height: 72)
.background {
RoundedRectangle(cornerRadius: 24)
.fill(Color.white)
.shadow(color: .black.opacity(0.15), radius: 8, y: 2)
}
.padding(.horizontal)
}

디자인은 본인 취향에 맞게 만들어주면 되는 것이고, 내 경우는 간단하게 위와 같이 만들어 보았다.
Button {
selected = .a
} label: {
VStack(alignment: .center) {
Image(systemName: "star")
.resizable()
.scaledToFit()
.frame(width: 22)
if selected == .a {
Text("View A")
.font(.system(size: 11))
}
}
}
.foregroundStyle(selected == .a ? Color.accentColor : Color.primary)
어쨌든 핵심은, selected 변수에 따라 아이콘이나 텍스트 색상을 다르게 보여주는 것이다.
그리고 버튼을 눌렀을 때 selected 변수의 값을 적절한 tag로 변경해주어 TabView가 해당 Tab을 보여주도록 하는 것이다.
이렇게 SwiftUI의 순정 TabView를 가지고 나만의 커스텀 TabBar를 만들어 보았다.
이 방법이 현재까지 내가 찾은 방법 중에 가장 현실적이고 좋은 대안인 듯 하지만 여전히 한계가 존재하는데...
바로 커스텀 TabBar를 구현하기 위해 toolbar(_:for:) modifier를 사용해 기본 TabBar를 숨겼기 때문에 내가 만든 TabBar를 숨기고 싶다면 이 역시 따로 구현해주어야 한다는 것이다.
TabBar를 숨길만한 상황이라면 역시 Navigation 시에 TabBar를 숨기는 것 정도가 될 테이니, TabBar를 숨길 수 있는 변수를 두던가, UserNotification을 사용하던가 해서 따로 처리하는 것도 좋은 방법일 수 있겠다.
import SwiftUI
struct ContentView: View {
enum Tab {
case a, b, c
}
@State private var selected: Tab = .a
var body: some View {
ZStack {
TabView(selection: $selected) {
Group {
NavigationStack {
AView()
}
.tag(Tab.a)
NavigationStack {
BView()
}
.tag(Tab.b)
NavigationStack {
CView()
}
.tag(Tab.c)
}
.toolbar(.hidden, for: .tabBar)
}
VStack {
Spacer()
tabBar
}
}
}
var tabBar: some View {
HStack {
Spacer()
Button {
selected = .a
} label: {
VStack(alignment: .center) {
Image(systemName: "star")
.resizable()
.scaledToFit()
.frame(width: 22)
if selected == .a {
Text("View A")
.font(.system(size: 11))
}
}
}
.foregroundStyle(selected == .a ? Color.accentColor : Color.primary)
Spacer()
Button {
selected = .b
} label: {
VStack(alignment: .center) {
Image(systemName: "gauge.with.dots.needle.bottom.0percent")
.resizable()
.scaledToFit()
.frame(width: 22)
if selected == .b {
Text("View B")
.font(.system(size: 11))
}
}
}
.foregroundStyle(selected == .b ? Color.accentColor : Color.primary)
Spacer()
Button {
selected = .c
} label: {
VStack(alignment: .center) {
Image(systemName: "fuelpump")
.resizable()
.scaledToFit()
.frame(width: 22)
if selected == .c {
Text("View C")
.font(.system(size: 11))
}
}
}
.foregroundStyle(selected == .c ? Color.accentColor : Color.primary)
Spacer()
}
.padding()
.frame(height: 72)
.background {
RoundedRectangle(cornerRadius: 24)
.fill(Color.white)
.shadow(color: .black.opacity(0.15), radius: 8, y: 2)
}
.padding(.horizontal)
}
}
struct AView: View {
var body: some View {
Text("View A")
}
}
struct BView: View {
var body: some View {
Text("View B")
}
}
struct CView: View {
var body: some View {
Text("View C")
}
}
#Preview {
ContentView()
}