본문 바로가기

학습 노트/UIBreaking (2023)

AutoScrolling #03

이번엔 Tab Indicator에 적절한 animation을 추가하기 위해 필요한 준비를 해 본다.

Tab

before

enum Tab: String, CaseIterable {
    case dance = "Dance"
    case fruite = "Fruite"
    case mirror = "Mirror"
    case night = "Night"
    case road = "Road"
}

after

enum Tab: String, CaseIterable {
    case dance = "Dance"
    case fruite = "Fruite"
    case mirror = "Mirror"
    case night = "Night"
    case road = "Road"

    var index: Int {
        return Tab.allCases.firstIndex(of: self) ?? 0
    }

    var count: Int {
        return Tab.allCases.count
    }
}

우선은 Tab에 속성을 조금 추가한다.
count는 Tab 내의 Case와 연동되는 동적인 개수를 반환하는 속성이며,
index는 각각의 case에 해당되는 index를 반환하는 속성이다.

 

ScrollOffsetModifier

struct OffsetKey: PreferenceKey {
    static var defaultValue: CGRect = .zero

    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        value = nextValue()
    }
}

새로운 파일을 하나 생성했다. 이름은 ScrollOffsetModifier로,
제스처의 진행 상태에 따라 Tab Indicator의 이동 거리를 계산하게 된다.

defaultValue는 기본값으로 0을 저장하고, PreferenceKey의 필수 메서드인 reduce를 구현한다.
reduce 메서드는 PreferenceKey 프로토콜을 채용한 OffsetKey를 사용하는 View들을 순회하며 정보를 상위 View로 전달하는 역할을 한다.

extension View {
    func offsetX(completion: @escaping(CGRect) -> ()) -> some View {
        self
            .overlay {
                GeometryReader {
                    let rect = $0.frame(in: .global)
                    Color.clear
                        .preference(key: OffsetKey.self, value: rect)
                        .onPreferenceChange(OffsetKey.self, perform: completion)
                }
            }
    }
}

다음 View에 Custom Modifier를 추가한다.
offsetX는 이름 그대로 View의 X축을 변화시키는 modifier로, 위와 같이 구성된다.

GeometryReader를 사용해 표시되는 화면의 frame을 사용해 투명한 View를 변화시킨다.
clear View에 적용된 preference modifier를 사용해 rect 값을 전달한다.
onPreferenceChange modifier는 이렇게 전달될 때 completion handler를 호출하게 된다.

 

Home

이제 새롭게 만든 ScrollOffsetModifier를 사용할 수 있도록 Home의 코드를 수정한다.

before

struct Home: View {
    @State private var activeTab: Tab = .dance
    @State private var scrollProgress: CGFloat = .zero

    var body: some View {
        VStack(spacing: 0) {
            TabIndicatorView()

            TabView(selection: $activeTab) {
                ForEach(Tab.allCases, id: \.rawValue) { tab in
                    TabImageView(tab)
                }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
            .ignoresSafeArea(.container, edges: .bottom)
        }
    }
}

after

struct Home: View {
    @State private var activeTab: Tab = .dance
    @State private var scrollProgress: CGFloat = .zero

    var body: some View {
        GeometryReader {
            let size = $0.size

            VStack(spacing: 0) {
                TabIndicatorView()

                TabView(selection: $activeTab) {
                    ForEach(Tab.allCases, id: \.rawValue) { tab in
                        TabImageView(tab)
                            .tag(tab)
                            .offsetX { rect in
                                let minX = rect.minX
                                let pageOffset = minX - (size.width * CGFloat(tab.index))
                                print(pageOffset)
                            }
                    }
                }
                .tabViewStyle(.page(indexDisplayMode: .never))
            }
            .ignoresSafeArea(.container, edges: .bottom)
        }
    }
}

크게 바뀐 부분은 화면의 크기를 계산하기 위한 GeometryReader의 추가와
새롭게 추가한 offsetX modifier의 적용이다.
우선 각각의 ImageView가 구분될 수 있도록 Tab을 사용해 tag를 사용한다.
tag를 사용할 때는 서로 중복되지 않도록 유의하도록 하자.

offsetX의 completion 블록으로는 하위 View의 rect 값을 전달한다.
minX는 X축의 최소 값으로, 해당 값에서 몇 개의 페이지가 지나갔는지, 그리고 각각의 페이지의 너비가 얼마나 되는지를 빼 주면
스크롤이 총 얼마나 진행 됐는지를 확인할 수 있다. 예를 들면 다음과 같다.

mirror가 화면에 표시된 상태라면 mirror의 index인 2와 기기의 너비(아이폰 14 기준)를 곱해 0에서 빼 주면
총 스크롤한 이동 거리는 -780이 된다.

즉, 이와 비슷한 메커니즘을 사용하면 Tab Indicator도 TabView의 스크롤 거리와 쉽게 이동시킬 수 있게 된다.

'학습 노트 > UIBreaking (2023)' 카테고리의 다른 글

AutoScrolling #05  (0) 2023.04.04
AutoScrolling #04  (0) 2023.03.23
AutoScrolling #02  (0) 2023.03.22
AutoScrolling #01  (0) 2023.03.16
AutoScrolling #00  (0) 2023.03.16