SwiftUI로 UI를 구성할 때 기본 TabView는 손쉽게 사용할 수 있지만, 기능을 커스터마이징하기는 어려운 경우가 많았습니다. 특히 iOS 17, 18을 기준으로 뷰를 구성한 예제를 보면 감탄이 나오는 수준이었지만 iOS 15에서는 아직 제한 사항이 존재했습니다. 이번 글에서는 SwiftUI에서 TabView를 활용해 커스템 텝 뷰를 구현하는 방법을 설명하고자 합니다. TabViewStyle, ScrollViewReader, Namespace 등을 사용해 UI를 좀 더 풍성하게 만들어보고자 노력했습니다.
TabViewStyle: 기본 TabView에 커스터마이징이 가능한 페이지 스타일을 적용시킬 수 있습니다.
여러 가지 텝뷰 스타일이 존재하지만 그 중 verticalPage는 watchOS에서 사용할 수 있기 때문에 대표적인 page를 사용했습니다. page를 사용하면 텝뷰를 스크롤할 수 있습니다.
.page를 사용하면 다음과 같이 현재의 페이지를 나타내는 컴포넌트가 자동으로 나타납니다. 이를 제거하고자 한다면 indexDisplayMode를 .never로 처리해줍니다.
.tabViewStyle(.page(indexDisplayMode: .never))
ScrollViewReader: 사용자가 선택한 탭에 맞게 스크롤 조절 가능
scrollviewReader는 SwiftUI에서 스크롤 위치를 프로그래밍 방식으로 제어할 수 있도록 돕는 뷰입니다. 이 뷰를 사용하면 스크롤할 위치를 특정 조건이나 이벤트에 따라 동적으로 조정할 수 있습니다. 내부적으로 proxy를 활용해, 지정된 자식 뷰로 스크롤하도록 합니다.
Namespace: 탭 전환 시 애니메이션을 자연스럽게 만들기
Namespace와 machedGeometryEffect를 활용하면 애니메이션이 부드롭게 이동할 수 있도록 만들 수 있습니다.
1. Options 구조체로 데이터 관리
struct Options {
let title: String
let color: Color
}
해당 구조체는 각 탭의 제목과 색상을 정의합니다. 여러 탭을 가질 수 있도록 options 배열에 저장해, 유동적으로 탭의 콘텐츠를 변경할 수 있습니다.
2. TabView 정의
struct ViewPage: View {
let id: String
let color: Color
var body: some View {
color
.opacity(0.2)
.edgesIgnoringSafeArea(.all)
}
}
우선 options 구조체에 들어갈 뷰를 만들어줍니다. 해당 뷰는 각 탭을 구분하기 위한 목적으로 색상만 넣어서 구분했습니다.
struct ContentView: View {
@State private var currentTab: Int = 0
@State private var options: [Options] = [ Options(title: "Hello World", color: Color.red),
Options(title: "Hello World2", color: Color.blue),
Options(title: "Hello Worl3", color: Color.yellow),
Options(title: "Hello Worl4", color: Color.green),
Options(title: "Hello Worl5", color: Color.purple)]
var body: some View {
ZStack(alignment: .top) {
TabView(selection: $currentTab) {
ForEach(options.indices, id: \.self) { index in
ViewPage(id: options[index].title, color: options[index].color)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.edgesIgnoringSafeArea(.all)
}
}
}
다음으로는 텝 뷰를 정의해 주었습니다. currentTab은 현재 보여지고 있는 탭을 인덱스로 보여주고 있습니다. 만약 탭이 바뀐다면 TabView(selection:) 이 currentTab을 변경해주어 보여지는 탭을 바꿔줍니다.
3. 탭바 정의
탭뷰를 만들었다면 각 탭을 쉽게 바꿔줄 수 있는 탭바를 만들어주고자 합니다.
struct TabBarItem: View {
@Binding var currentTab: Int
let namespace: Namespace.ID
var tabBarItemName: String
var tab: Int
var body: some View {
Button {
withAnimation {
self.currentTab = tab
}
} label: {
VStack {
Spacer()
Text(tabBarItemName)
if currentTab == tab {
Color.black
.frame(height: 2)
.matchedGeometryEffect(id: "underLIne", in: namespace, properties: .frame)
} else {
Color.clear.frame(height: 2)
}
}
.animation(.spring(), value: self.currentTab)
}
.buttonStyle(.plain)
}
}
각 텝을 나타내는 텝바 아이템은 다음과 같습니다. 특이한 점은 namespace과 matchedGeometryEffect를 함께 특정 뷰 간의 애니메이션 전환을 부드럽게 하기 위해 사용한 것입니다. 이를 통해 두 개의 다른 위치에 있는 뷰가 같은 상태로 자연스럽게 이동하는 듯한 효과를 줄 수 있습니다. matchedGeometryEffect는 두 개 이상의 뷰가 같은 id와 namespace를 가지고 있을 경우, 뷰 간의 자연스러운 전환을 제공합니다.
struct TabBarView: View {
@Binding var currentTab: Int
@Namespace var namespace
let options: [String]
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 20) {
ForEach(Array(zip(self.options.indices, self.options)), id: \.0) { index, name in
TabBarItem(currentTab: self.$currentTab, namespace: namespace, tabBarItemName: name, tab: index)
.id(index) // 각 TabBarItem에 ID 부여
}
}
.padding(.horizontal)
}
.background(Color.white)
.frame(height: 80)
.edgesIgnoringSafeArea(.all)
.onChange(of: currentTab) { newTab in
withAnimation {
proxy.scrollTo(newTab, anchor: .center) // 새로운 탭 선택 시 중앙으로 스크롤
}
}
}
}
}
ScrollViewReader를 사용하여 ScrollView에 대해 스크롤 위치를 제어할 수 있게 하였습니다
.onchange 메서드를 통해 현재의 텝이 바뀌었을 때 proxy.scrollTo(newTab, anchor: .center)를 사용해 선택된 탭 항목이 화면 중앙에 위치하도록 만들었습니다. 또한 자연스러운 전환을 위해 withAnimation을 함께 사용하였습니다.