Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
SwiftUI/SwiftUI(Advanced)

8. Custom TabView / TabBar 커스텀 탭뷰/탭바

by Toughie 2023. 6. 24.

🦁Custom TabView / TabBar🦁

탭뷰/ 탭바는 매우 많은 앱에서 사용하는 기능이다.

기본 탭뷰는 아래와 같은 형태이다. 하지만 기본 컴포넌트인 만큼 커스텀에 제약이 많다.

따라서 기본 탭뷰와 가장 유사하게 동작하지만, 많은 부분을 커스텀할 수 있는 탭뷰를 구현해 보자.

        TabView(selection: $selection) {
            Color.pink
                .tabItem {
                    Image(systemName: "house")
                    Text("home")
                }
            Color.green
                .tabItem {
                    Image(systemName: "tree")
                    Text("tree")
                }
            Color.blue
                .tabItem {
                    Image(systemName: "drop.circle")
                    Text("water")
                }
        }
//TabView Definition

public struct TabView<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View {

    /// Creates an instance that selects from content associated with
    /// `Selection` values.
    public init(selection: Binding<SelectionValue>?, @ViewBuilder content: () -> Content)

이와 같이 플로팅 탭바의 느낌으로 구현하고자 한다 !


Enum TabItem 

먼저 탭 아이템을 보면 심볼을 위한 이름, 탭 아이템 이름, 그리고 컬러가 필요하다.

모델을 만들어 관리한다면 아래와 같이 할 수 있을 것이다.

struct TabBarItem: Hashable {
    let iconName: String
    let title: String
    let color: Color
}

하지만 보통 탭 아이템의 개수는 서버통신등에 의해 변하지 않고, 처음부터 개발자의 의도대로 정해져 있다.(개수를 알고 있음)

따라서 enum을 활용해서 아래와 같이 관리하는 것이 효율적일 수 있다.

enum TabBarItem: Hashable {
    case home, likes, profile
    
    var iconName: String {
        switch self {
        case .home: return "house"
        case .likes: return "heart"
        case .profile: return "person"
        }
    }
    
    var title: String {
        switch self {
        case .home: return "Home"
        case .likes: return "Likes"
        case .profile: return "Profile"
        }
    }
    
    var color: Color {
        switch self {
        case .home: return .red
        case .likes: return .green
        case .profile: return .blue
        }
    }
}

탭뷰의 구조는 크게 실제 컨텐츠가 들어가는 컨텐츠 영역, 그리고 하단의 탭아이템(탭바)영역으로 나눌 수 있다.

먼저 탭아이템(탭바)영역부터 구현해 본다.

TabBarView

먼저 하단의 탭바 부분을 구현해보자.

사전에 TabBarItem enum을 만들어 뒀다는 것을 기억하자.(계산속성으로 iconName, title, Color 리턴)

import SwiftUI

struct CustomTabBarView: View {
    //배열로 TabBarItem을 받아옴
    let tabs: [TabBarItem]
    //탭바 선택에 따라 컨텐츠 전환을 위한 바인딩 프로퍼티
    @Binding var selection: TabBarItem
    //탭바 내부 전환에만 애니메이션을 걸기 위한 @State 프로퍼티
    @State var localSelection: TabBarItem
    //matchedGeometryEffect를 통해 탭바 내부 전환 애니메이션을 구현하기 위한 namespace
    @Namespace private var namespace
    
    var body: some View {
        tabBar
        //탭바 내부 전환 애니메이션 구현을 위한 onChange.
        //바인딩 selection의 변경은 탭아이템 탭제스쳐를 통해 이루어짐.
        //즉 탭 아이템을 탭했을 때, 애니메이션과 함께 localSelection을 해당 탭으로 바꿔줘서
        //해당 탭으로 전환 애니메이션이 자연스럽게 이루어짐.
            .onChange(of: selection) { newValue in
                withAnimation(.easeInOut(duration: 0.25)) {
                    localSelection = newValue
                }
            }
    }
}

extension CustomTabBarView {
    //MARK: 탭 아이템 구현
    private func tabView(tab: TabBarItem) -> some View {
        //TabBarItem 타입을 받아와서 VStack에 쌓음
        VStack {
            Image(systemName: tab.iconName)
                .font(.subheadline)
            Text(tab.title)
                .font(.system(size: 10, weight: .semibold, design: .rounded))
        }
        //실제 콘텐츠 부분을 전환하는 부분과 따로, 탭바 내부의 컬러 변경 및 애니메이션을 위해
        //@State localSelection 프로퍼티가 존재함.
        //굳이 이렇게 하지 않고, 탭 선택에 따라 변하는 프로퍼티를 하나만 관리할 수도 있음
        //ForEach를 통해 탭바를 그리고, 선택/미선택에 따라 색상 변경을 위한 코드
        .foregroundColor(localSelection == tab ? tab.color : Color.gray)
        .padding(.vertical, 8)
        .frame(maxWidth: .infinity)
        .background(
            //선택되었을 때 확실한 표시를 위한 배경
            ZStack {
                if localSelection == tab {
                    RoundedRectangle(cornerRadius: 10)
                        .fill(tab.color.opacity(0.2))
                    //탭간 자연스러운 전환 애니메이션을 위한 matchedGeometryEffect
                        .matchedGeometryEffect(id: "backgroundRect", in: namespace)
                }
            }
        )
    }
    
    //MARK: 탭바 구현
    private var tabBar: some View {
        //수평으로 탭아이템들이 나열된 형태 -> HStack, ForEach 활용
        HStack {
            //tabs는 [TabBarItem], 탭바아이템은 Hashable
            ForEach(tabs, id: \.self) { tab in
                tabView(tab: tab)
                //다른 탭아이템을 탭했을 때 해당 탭으로 전환을 위한 코드
                    .onTapGesture {
                        switchToTab(tab: tab)
                    }
            }
        }
        .padding(6)
        .background(
            Color.white.ignoresSafeArea(edges: .bottom)
        )
        .cornerRadius(10)
        //그림자를 통해 입체감 추가
        .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5)
        .padding(.horizontal)
    }
    
    //MARK: 탭 간 전환을 위한 메서드
    private func switchToTab(tab: TabBarItem) {
        //주석 처리된 부분을 해제하면 컨텐츠 전환(내부 화면)에도 애니메이션이 걸린다.
//        withAnimation(.easeInOut) {
        //선택한 탭을 바인딩 selection에 할당해줌
        selection = tab
//        }
    }
}

TabBarContainerView

이제 탭바 코드가 준비되었으니, 컨텐츠와 함께 그려보자.

기본 탭뷰의 정의를 다시 보면 아래와 같다.

public struct TabView<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View {

    /// Creates an instance that selects from content associated with
    /// `Selection` values.
    public init(selection: Binding<SelectionValue>?, @ViewBuilder content: () -> Content)

제네릭과 뷰빌더가 활용되어 있다.

SelectionValue는 Hashable 해야하고, Content는 View 타입이며 

@ViewBuilder를 통해 클로저를 활용할 수 있다.(View 를 채택하는 Content를 반환해야함).

지금 우리의 커스텀 코드에서는 TabBarItem이 Hashable하기 때문에 정의 단계에서 제네릭 조건이 생략되어있음을 참고.

struct CustomTabBarContainerView<Content: View>: View {
	//상위 뷰에서 초기화 할 때 TabBarItem을 받아옴
    @Binding var selection: TabBarItem
    //하단 탭바 초기화를 위해서는 [TabBarItem] 필요. 
    @State private var tabs: [TabBarItem] = []
    //탭뷰 컨텐츠
    let content: Content
    
    init(selection: Binding<TabBarItem>, @ViewBuilder content: () -> Content) {
    	//바인딩 프로퍼티라 언더바
        self._selection = selection
        self.content = content()
    }
    
    var body: some View {
        ZStack(alignment: .bottom) {
            //화면에 꽉 차게
            content.ignoresSafeArea()
            
            //하단 탭바 초기화
            CustomTabBarView(tabs: tabs, selection: $selection, localSelection: selection).ignoresSafeArea(.keyboard)
        }
        //탭들을 PreferenceKey로 관리함. 
        //배열에 추가하는 방식으로
        .onPreferenceChange(TabBarItemsPreferenceKey.self) { value in
            self.tabs = value
        }
    }
}

TabBarItemsPreferenceKey

struct TabBarItemsPreferenceKey: PreferenceKey {
    //defaultValue는 빈배열
    static var defaultValue: [TabBarItem] = []
    
    //커스텀 탭뷰에서 탭아이템을 추가할 때마다 위 배열에 추가되는 방식.
    static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) {
        value += nextValue()
    }
}

기본 탭뷰의 사용법은 아래와 같다.

이와 유사하게 사용할 수 있도록 아래와 같이 구현할 수 있다.

struct TabBarItemViewModifier: ViewModifier {
    
    let tab: TabBarItem
    @Binding var selection: TabBarItem
    
    func body(content: Content) -> some View {
        content
        //선택되었을 때만 투명도가 1이 되어 컨텐츠가 보이는 형태
        //다만 이 방식은 뷰 계층에 모든 탭뷰의 컨텐츠가 렌더링되어 있고, 투명도만 바꾸기 때문에
        //효율적인 코드라고는 생각하지 않음.
        //(간단한 뷰들은 괜찮지만 탭의 개수가 많아지거나, 컨텐츠가 무거울 경우)
            .opacity(selection == tab ? 1.0 : 0.0)
            //preference를 통해 배열에 탭을 추가함
            .preference(key: TabBarItemsPreferenceKey.self, value: [tab])
    }
}
extension View {
    
    func tabBarItem(tab: TabBarItem, selection: Binding<TabBarItem>) -> some View {
        self
            .modifier(TabBarItemViewModifier(tab: tab, selection: selection))
    }
}

상위뷰에서 활용한다면

struct AppTabBarView: View {

    @State private var tabSelection: TabBarItem = .home
    
    var body: some View {
        CustomTabBarContainerView(selection: $tabSelection) {
            Color.red
                .tabBarItem(tab: .home, selection: $tabSelection)

            Color.green
                .tabBarItem(tab: .likes, selection: $tabSelection)

            Color.blue
                .tabBarItem(tab: .profile, selection: $tabSelection)
        }
    }
}

 

확실히 커스텀을 하면 예쁘고..입맛대로 바꿀 수 있어서 좋지만

너무나도 복잡해지고 공수가 많이 들기 때문에 우선은 기본 컴포넌트를 사용하는 방향으로 가고

정말 커스텀이 필요한 경우 디자이너와 협의를 보는 방향이 좋지 않을까...라는 생각이 들었다. ㅋㅋㅋ

 

@State, @Binding, Generic, @ViewBuilder, @PreferenceKey, matchedGeometryEffect, enum, extension...

정말 사용된 개념이 많아서 많이 복잡하고 어려웠다.. 😅

 

특히 그냥 구현하는 것이 아니라, 재사용성과 유지보수, 모듈화를 위한 코드는 훨씬 작성이 어려운 것 같다.

 

'SwiftUI > SwiftUI(Advanced)' 카테고리의 다른 글

10. UIViewRepresentable / Custom TextField  (0) 2023.06.25
9. Custom NavigationView/Link  (0) 2023.06.25
7. PreferenceKey / Custom Navigation Bar  (0) 2023.06.23
6. @ViewBuilder / 뷰 빌더  (0) 2023.06.22
5. Generic 제네릭  (0) 2023.06.21