버그 잡이

SwiftUI - Paging 되는 상단 tabbar 구현하기 본문

SwiftUI

SwiftUI - Paging 되는 상단 tabbar 구현하기

버그잡이 2023. 5. 20. 17:12

https://www.youtube.com/watch?v=80n0zv7r9Lc

위 영상을 보고 학습한 내용입니다.

 

 

swiftUI로 위와 같은 화면을 만들어 보겠습니다.

 

위 화면을 만들기 위해서 크게 두 개의 View가 생성될 예정입니다.

 

1. OffsetPageTabView

- UIScrollView를 활용해서 content를 담을 수 있는 View

- 탭바 아래 스크롤 되는 conent 화면

 

2. PagerTabView

-  TabView와 OffSetPageTabView를 가지고 있는 View

 

 

하나씩 차근차근 만들어보겠습니다.

 

 

OffsetPageTabView

1. content를 통해서 UIScrollView 안에 넣어줄 View를 받습니다.

2. MakeUIView로 UIScrollView를 만들고 안에 Content를 넣어줍니다.

3. 탭 클릭시 원하는 offset으로 이동하기 위해서 "offset" 변수를 둡니다.

    - 탭 선택시에 'offset' 을 변경해주고 scrollView.contentOffset.x와 다르면 스크롤 해줍니다.

struct OffsetPageTabView<Content: View>: UIViewRepresentable {
    
    var content: Content
    @Binding var offset: CGFloat
    
    // Content가 될 View를 받아서 ScrollView안에 넣어줌
    // 추후에 List 뷰를 받아서 넣어줄 것임
    init(offset: Binding<CGFloat>, @ViewBuilder content: @escaping ()->Content) {
        self.content = content()
        self._offset = offset
    }
    
    func makeCoordinator() -> Coordinator {
        return OffsetPageTabView.Coordinator(parent: self)
    }
    
    // SwiftUI뷰를 받아서 UIKit의 ScrollView에 넣어준다
    func makeUIView(context: Context) -> UIScrollView {
        let scrollView = UIScrollView()

        let hostview = UIHostingController(rootView: content)
        hostview.view.translatesAutoresizingMaskIntoConstraints = false
        let constraints = [
            hostview.view.topAnchor.constraint(equalTo: scrollView.topAnchor),
            hostview.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            hostview.view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
            hostview.view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
            
            hostview.view.heightAnchor.constraint(equalTo: scrollView.heightAnchor)
        ]
        
        scrollView.addSubview(hostview.view)
        scrollView.addConstraints(constraints)
        
        scrollView.isPagingEnabled = true
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        
        // coordinator 연결
        scrollView.delegate = context.coordinator
        
        return scrollView
    }
    
    // 1. scrollView의 contentOffset이 변경될때마다 호출됨
    // 2. self.offset이 변경될때 호출됨
    func updateUIView(_ uiView: UIScrollView, context: Context) {
        let currentOffet = uiView.contentOffset.x
        
        // 스크롤할때는 setContentOffset을 호출하지 않고 상단 탭 선택시에 스크롤 수동 변경
        if currentOffet != offset {
            uiView.setContentOffset(CGPoint(x: offset, y: 0), animated: true)
        }
    }
    
    // UIKit의 scrollView.contentOffset 데이터를 받아서 현재 View의 offset 변수에 넣어줌
    class Coordinator: NSObject, UIScrollViewDelegate {
        var parent: OffsetPageTabView
        
        init(parent: OffsetPageTabView) {
            self.parent = parent
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            let offset = scrollView.contentOffset.x
            
            parent.offset = offset
        }
    }
}

 

 

이해가 필요한 개념들에 대해서 좀 더 알아보겠습니다.

 

*UIViewRespresentable

SwiftUI에서 UIKit을 쓰기 위해서는 UIViewRepresentable를 써야합니다.

위 View도 UIScrollView를 써야하는 상황이었기 때문에 UIViewRepresentable를 활용하였습니다.

간단하게 함수를 이해해봅시다

(어떤 역할을 하는 함수인지 주석을 달아놨습니다.)

public protocol UIViewRepresentable : View where Self.Body == Never {
  associatedtype UIViewType : UIView
  
  // View를 생성하고 초기화
  func makeUIView(context: Self.Context) -> Self.UIViewType
  
  // View가 업데이트가 필요할 때 호출하는 메소드
  func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)
  
  static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)
  
  associatedtype Coordinator = Void
  
  // Coordinator를 통해서 UIKit -> SwiftUI 로 데이터 전달 가능
  func makeCoordinator() -> Self.Coordinator
  
  typealias Context = UIViewRepresentableContext<Self>
}

UIKit의 view를 가져와서 쓰고 그 객체와 관련된 함수와 데이터를 가져와서 쓸 수 있게 도와주는 기능입니다.
(자세한 내용은 https://developer.apple.com/documentation/swiftui/uiviewrepresentable 여기를 참고 바랍니다.)

 

 

 

 

 

 

PagerTabView

 

1. label 과 content를 파라미터로 받아서 넣어줍니다.

2. "탭 선택으로 스크롤" 구현

    - overlay해서 tab에 맞게 Rectangle 뷰를 생성하고 onTapGesture를 달아줍니다. 

    - 그리고 offset 값을 변경해줍니다. (OffsetPageTabView에 offset값이 바인딩 되어 있기 때문에 자동 반영)

3. OffsetPageTabView에서 ScrollView.contentOffset을 받아와서 탭 갯수와 탭바 하단 라인의 offset인 tabOffset을 계산해줍니다. 

 

struct PagerTapView<Content: View, Label: View>: View {
    
    var content: Content
    var label: Label
    var tint: Color
    
    init(tint: Color,
         @ViewBuilder labels: @escaping ()->Label,
         @ViewBuilder content: @escaping ()->Content
    ) {
        self.content = content()
        self.label = labels()
        self.tint = tint
    }
    
    @State var offset: CGFloat = 0
    @State var maxTab: CGFloat = 0
    @State var tabOffset: CGFloat = 0
    
    var body: some View {
        VStack(spacing: 0) {
            HStack(spacing: 0) {
                label
            }
            .overlay( // 탭 선택으로 스크롤
                HStack(spacing: 0) {
                    ForEach(0..<Int(maxTab), id: \.self) { index in
                        Rectangle()
                            .fill(Color.black.opacity(0.01))
                            .onTapGesture { // 탭 했을때 스크롤뷰 offset 변경
                                let newOffset = CGFloat(index) * getScreenBounds().width
                                self.offset = newOffset
                            }
                    }
                }
            )
            .foregroundColor(tint)
            
            // 탭바 하단 라인
            Capsule()
                .fill(tint)
                .frame(width: maxTab == 0 ? 0 : (getScreenBounds().width / maxTab),
                       height: 5)
                .padding(.top, 10)
                .frame(maxWidth: .infinity, alignment: .leading)
                .offset(x: tabOffset)
            
            OffsetPageTabView(offset: $offset) {
                HStack(spacing: 0) {
                    content
                }
                .overlay(
                    // ScrollView의 frame 정보를 부모뷰에게 전달하기 위함. onPreferenceChange 에서 전달 받음
                    GeometryReader { proxy in
                        Color.clear
                            .preference(key: TabPreferenceKey.self, value: proxy.frame(in: .global))
                    }
                )
                .onPreferenceChange(TabPreferenceKey.self) { proxy in
                    // ScrollView frame 정보를 바탕으로 maxTab, tabOffset(탭 하단 라인의 offset) 계산
                    let maxWidth = proxy.width
                    let screenWidth = getScreenBounds().width
                    let maxTabs = (maxWidth / screenWidth).rounded()
                    self.maxTab = maxTabs
                    
                    let minX = -proxy.minX
                    let progress = minX / screenWidth // ex) 스크린화면 크기만큼 이동했을때 360 / 360 = 1
                    let tabOffset = progress * (screenWidth / maxTabs) // 1 * (360/3)
                    self.tabOffset = tabOffset
                }
            }
        }
    }
}

// preference 등록을 위한 객체 생성
struct TabPreferenceKey: PreferenceKey {
    static var defaultValue: CGRect = .init()
    
    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        value = nextValue()
    }
}

extension View {
    func getScreenBounds() -> CGRect {
        return UIScreen.main.bounds
    }
}

 

* GeometryReader와 Preference

 

GeometryReader와 Preference는 중요한 개념이지만 이번 포스팅에서는 현재 상황에서 위 코드에서 어떻게 쓰였는지에 대해서만 알아보겠습니다.

 

GeometryReader는 상위뷰의 좌표, 크기 정보를 자식 뷰에게 제공하는 역할을 합니다.

여기서는 OffsetPageTabView의 좌표, 크기 정보를 받아옵니다.

 

Preference는 하위 뷰의 정보를 상위 뷰에 전달할 수 있는 수단입니다.

1. 이를 사용하기 위해서 PreferenceKey를 채택하는 키를 정의해줘야합니다. (여기서는 TabPreferenceKey)

2. GemotryReader 내부의 Color라는 View에 preferenceKey를 등록해줌으로써 OffsetPageTabView의 좌표, 크기 정보를 preference에 전달할 수 있습니다.

3. 위 정보를 onPreferenceChange를 통해서 받아서 쓸 수 있습니다.

 

'그런데 그냥 GeometryReader 안에서 proxy 값으로 처리하면 되지 않을까요?'

- 안 됩니다. GeoMetryReader는 컨테이너뷰로 클로저 안에서 로직 처리를 할 수 없습니다.
- 그래서 조금은 복잡하지만 preference를 추가적으로 사용해주는 것입니다.


*참고: https://protocorn93.github.io/tags/PreferenceKey/

 

 

사용부

1. label과 content를 넘겨줍니다.

2. pageLabel()로 탭들을 화면에 꽉 맞게 채워주고, pageView()로 각 page의 width를 스크린 화면에 맞게 설정합니다.

struct AssetSearchView: View {
    
    @State var offset: CGFloat = 0
    @State var keyword: String = ""
    var screenSize: CGSize
    
    var body: some View {
        VStack {
            Spacer().frame(height: 50)

            PagerTapView(tint: .black) {
                Text("주식")
                    .pageLabel()
                Text("코인")
                    .pageLabel()
                Text("부동산")
                    .pageLabel()
            } content: {
                List {
                    Text("FirstTab")
                }
                .pageView()
                List {
                    Text("SecondTab")
                }
                .pageView()
                List {
                    Text("ThirdTab")
                }
                .pageView()
            }
        }
    }
}

extension View {
    func pageLabel() -> some View {
        self
            .frame(maxWidth: .infinity, alignment: .center)
    }
    
    func pageView() -> some View {
        self
            .frame(width: getScreenBounds().width, alignment: .center)
    }
}

 

 

 

반응형
Comments