ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI - Custom TabView
    SwiftUI 2023. 7. 3. 15:39

    안녕하세요. 그린입니다🍏

    이번 포스팅에서는 SwiftUI로 커스텀한 탭뷰를 구현해보려 합니다🙌

     

    SwiftUI에서 기본적으로 제공해주는 TabView 컴포넌트가 있지만 아예 완전 커스텀하게 탭뷰를 입맛대로 구성해볼 수 도 있습니다.

    기본적인 탭뷰 API가 궁금하신분은 아래 포스팅을 보시면 도움이 될 수 있어요 😄

    https://green1229.tistory.com/234

     

    SwiftUI - TabView

    안녕하세요. 그린입니다🟢 이번 포스팅에서는 SwiftUI 내에서 기본 애플에서 제공하는 TabView에 대해 알아보겠습니다🙌 주로 앱을 구성하다보면 하단에 홈 / 설정 등 메뉴로 갈 수 있는 TabBar에 대

    green1229.tistory.com

     

    자 이런 기본적으로 제공해주는 형태가 아닌 우리만의 커스텀한 탭뷰를 만들어 볼께요!

    물론 기본 TabView는 전혀 사용하지 않습니다ㅎㅎ

     

    우선 만들기전에 어떤 커스텀한 탭뷰를 만들지 선택해야 될것 같아요.

     

    저는 아래와 같은 탭뷰를 만들어보려 합니다!

    보시면 기본 탭뷰처럼 아주 매끄럽게 잘 동작합니다!

    각 탭뷰를 누르면 아래 하단 bar도 부드럽게 애니메이션 되어 해당 탭 영역으로 이동하고

    담긴 컨텐츠도 갈아끼워지는걸 볼 수 있습니다.

     

    그럼 간단히 이정도 소개하고 실제 코드를 보면서 더 자세히 설명해볼께요 🙋🏻

     

    Custom TabView 만들기

    import SwiftUI
    
    public protocol TabTitleConvertible {
      var title: String { get }
    }
    
    public struct CustomTabView<Selection>: View where Selection: Hashable & Identifiable & Comparable & TabTitleConvertible {
      public var views: [Selection: AnyView]
      @Binding public var selection: Selection
      @State private var barXOffset: CGFloat = 0
      @State private var barIsActive = false
      private var count: Int {
        views.count
      }
      
      public init(
        views: [Selection: AnyView],
        selection: Binding<Selection>
      ) {
        self.views = views
        self._selection = selection
      }
      
      public var body: some View {
        VStack(spacing: 0) {
          GeometryReader { geometry in
            let tabSize = geometry.size.width / CGFloat(count)
            VStack(spacing: 0) {
              HStack(spacing: 0) {
                ForEach(
                  views.keys.sorted(),
                  content: { key in
                    Button(
                      action: { selection = key },
                      label: {
                        HStack(spacing: 0) {
                          Spacer()
                          
                          Text(key.title)
                            .font(.title)
                            .foregroundColor(.black)
                          
                          Spacer()
                        }
                      }
                    )
                    .frame(width: tabSize)
                  }
                )
              }
              .padding(.vertical, 15)
              .frame(height: 53)
              
              ZStack(alignment: .bottom) {
                Rectangle()
                  .fill(.gray)
                  .frame(height: 1)
                
                HStack {
                  Rectangle()
                    .fill(Color.black)
                    .frame(width: tabSize, height: 3)
                    .offset(x: barXOffset)
                    .animation(barIsActive ? .linear(duration: 0.25) : .none, value: barXOffset)
                  
                  Spacer()
                }
              }
            }
            .onAppear {
              let selectedIndex = CGFloat((selection.id as? Int ?? 0))
              barXOffset = selectedIndex * tabSize
              
              DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                barIsActive = true
              }
            }
            .onChange(of: selection, perform: { newValue in
              let selectedIndex = CGFloat((newValue.id as? Int ?? 0))
              barXOffset = selectedIndex * tabSize
            })
          }
          .frame(height: 56)
          
          if let view = views[selection] {
            view
          } else {
            EmptyView()
          }
        }
      }
    }

    코드가 좀 길죠..?ㅎㅎ

    그래도 구현은 간단합니다.

     

    TabTitleConvertible

    우선, TabTitleConvertible이라는 프로토콜을 선언해둡니다.

    이 프로토콜을 CustomTabView와 실제 추후 만들어질 Tab 열거 타입에서 채택 후 손쉽게 제네릭하게 title을 가져오기 위함입니다.

    그건 조금 있다 더 설명해볼께요!

     

    views & selection 프로퍼티

    CustomTabView는 views와 selection 프로퍼티가 핵심이에요.

    views는 각 탭 뷰에 담길 컨텐츠 뷰들의 딕셔너리입니다.

    key, value값을 가진 형태구요.

    selection은 추후 구현할 Tab들을 타입으로 가지죠.

    즉 셀렉션으로 어떤 뷰를 보여주고 탭 전환을 할지 id 값을 비교하기 위함입니다.

    제네릭하게 받아오고 탭 간 컨텐츠 비교를 위해 필요한 프로토콜을 채택함을 볼 수 있습니다.

     

    bar 프로퍼티

    그리고 탭 전환 시 위 영상에서 본것처럼 아래 검은 막대 바가 해당 탭 영역에 맞춰 움직일 수 있도록 barXOffset을 가집니다.

    barIsActive는 바가 이동될때 애니메이션을 가질지에 대한 여부입니다.

     

    그럼 body 구성을 보시죠!

     

    탭 바 영역

    GeometryReader를 이용해 현재 기기 뷰 사이즈를 가지고 탭 영역의 width를 공평하게 배분해줍니다.

    즉 2개일때는 width가 1/2 3개일때는 1/3 등 여러개의 views들이 구성되어도 공평한 widht를 가지죠.

    탭 영역에는 해당 컨텐츠의 title을 가집니다.

    아까 채택한 TabTitleConvertible 프로토콜 때문에 이렇게 쓸 수 있게 되었어요!

     

    막대바 또한 구현해주는데 여기서 애니메이션을 barIsActive 값에 따라 달리 주었어요.

    초기 탭바가 구성될때 바도 구성되기에 애니메이션이 들어가 있다면 탭 영역이 조금 밑에 있거나 하면
    하늘에서 떨어지는 아래와 같은 애니메이션이 조금 노출되기에 이를 방지하고자 처음 시작 시 애니메이션을 none으로 줍니다.

    그 다음부터는 자연스럽게 위치하도록 linear 애니메이션을 심어줍니다.

     

    그리고 컨텐츠 위 탭바 영역이 onAppear 될때, 막대 바 위치를 잡아주도록 계산해줍니다.

    여기서 첫 실행 시에는 0.1초 후 barIsActive 상태를 true로 주어 처음에는 애니메이션이 없고 첫 뷰가 나타난 후 애니메이션이 들어갈 수 있도록 해줍니다.

     

    그 후, onChange 즉, selection인 선택된 탭이 변경될때도 마찬가지로 막대 바의 위치를 변환시켜주도록 계산하여 변경해줍니다.

     

    컨텐츠 영역

    담길 컨텐츠는 아까 가져온 views에서 selection값으로 해당하는 뷰를 띄워주도록 해줍니다.

    안전하게 바인딩을 통해 없다면 빈 뷰를 노출시켜주죠.

    아주 간단하게 뷰 컨텐츠를 다룰 수 있는게 핵심인것 같아요 😊

     

    자 그럼 이제 앞서 말한 Tab을 구성해볼까요?

     

    Tab

    enum Tab: Int, Identifiable, Hashable, Comparable, TabTitleConvertible {
      case one
      case two
      case three
      
      var title: String {
        switch self {
        case .one: return "탭 1"
        case .two: return "탭 2"
        case .three: return "탭 3"
        }
      }
      var id: Int {
        rawValue
      }
      
      static func < (lhs: Self, rhs: Self) -> Bool {
        return lhs.rawValue < rhs.rawValue
      }
    }

    id로 탭을 구분하고 TabTitleConvertible을 채택하여 title을 구성해줌으로 해당 타이틀 값을 바로 사용할 수 있습니다.

    만약 해당 프로토콜을 채택하지 않고 String을 채택한다면 Multiple enum raw types 'Int' and 'String' 경고를 볼 수 있습니다 ⚠️

    이 이유로 인해 별도 title String을 가지는 프로토콜을 만들어 양쪽에서 채택하여 사용한것 입니다.

     

    그럼 이제 해당 뷰를 띄워볼까요?

     

    ViewModel

    먼저 뷰모델에서 처음 노출할 아이템을 구성하도록 만들어볼께요.

    물론 View에서 직접 가져도 되지만 ViewModel로 빼는것도 명확하고 좋습니다.

    import SwiftUI
    
    class PoCTribeTabViewModel: ObservableObject {
      @Published var selectedTab: Tab = .one
    }

     

    마지막으로 CustomTabView를 올릴 뷰를 구성해보죠!

     

    View

    import SwiftUI
    
    struct ContentView: View {
      @StateObject private var viewModel = PoCTribeTabViewModel()
      
      var body: some View {
        CustomTabView(
          views: [
            .one: AnyView(BlueView()),
            .two:  AnyView(RedView()),
            .three:  AnyView(YellowView())
          ],
          selection: $viewModel.selectedTab
        )
      }
    }
    
    // MARK: - 하위 탭 컨텐츠 뷰
    private struct BlueView: View {
      fileprivate init() { }
      
      fileprivate var body: some View {
        Color.blue
      }
    }
    
    private struct RedView: View {
      fileprivate init() { }
      
      fileprivate var body: some View {
        Color.red
      }
    }
    
    private struct YellowView: View {
      fileprivate init() { }
      
      fileprivate var body: some View {
        Color.yellow
      }
    }

    이렇게 탭 되었을때 보여줄 컨텐츠 뷰들을 하위뷰로 구성합니다.

    그 후 CustomTabView를 이용해 views에 하위 뷰들을 AnyView로 감싸 넣어주고

    selection에는 viewModel에 있는 선택된 탭 프로퍼티를 넣어 바인딩 시켜주면 끝😁

     

    마무리

    아주 간단하고 다양하게 커스텀 가능한 탭 뷰를 만들어 보았습니다 🏄🏻‍♂️

    해당 샘플 프로젝트는 아래 제 깃헙 레포에서 확인해보실 수 있습니다 👍

    https://github.com/GREENOVER/playground/tree/main/CustomTabView

     

    GitHub - GREENOVER/playground: 학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터

    학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터. Contribute to GREENOVER/playground development by creating an account on GitHub.

    github.com

Designed by Tistory.