SwiftUI) Custom Tab View 만들기(iOS 15)

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을 함께 사용하였습니다.

Leave a Comment