본문 바로가기

학습 노트/UIBreaking (2023)

AutoScrolling #05

Tab을 전환하는 방법은 반드시 하나만 있어야 하는 건 아니다.
지금처럼 Tab들을 좌우로 Swipe 하여 이동할 수도 있지만,
Tab들의 이름을 직접 선택해 다음 Tab으로 이동하는 방식도 고려해 볼 수 있다.

이번엔 지금까지 구현한 Tab Indicator에 Tap Gesture를 추가해 Tab 간의 이동을 구현해 본다.

TabGesture 추가

before

func TabIndicatorView() -> some View {
    GeometryReader {
        let size = $0.size
        let tabWidth = size.width / 3

        LazyHStack(spacing: 0) {
            ForEach(Tab.allCases, id: \.rawValue) { tab in
                Text(tab.rawValue)
                    .font(.title3.bold())
                    .foregroundColor(activeTab == tab ? .primary : .gray)
                    .frame(width: tabWidth)
            }
        }
        .frame(width: CGFloat(Tab.allCases.count) * tabWidth)
        .padding(.leading, tabWidth)
        .offset(x: scrollProgress * tabWidth)
    }
    .frame(height: 50)
    .padding(.top, 15)
}

after

func TabIndicatorView() -> some View {
    GeometryReader {
        let size = $0.size
        let tabWidth = size.width / 3

        LazyHStack(spacing: 0) {
            ForEach(Tab.allCases, id: \.rawValue) { tab in
                Text(tab.rawValue)
                    .font(.title3.bold())
                    .foregroundColor(activeTab == tab ? .primary : .gray)
                    .frame(width: tabWidth)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        withAnimation(.easeInOut(duration: 0.3)) {
                            activeTab = tab
                            /// Scroll Progess Explicitly
                            scrollProgress = CGFloat(tab.index)
                        }
                    }
            }
        }
        .frame(width: CGFloat(Tab.allCases.count) * tabWidth)
        .padding(.leading, tabWidth)
        .offset(x: scrollProgress * tabWidth)
    }
    .frame(height: 50)
    .padding(.top, 15)
}

크게 변한 부분은 다음과 같다.

    .contentShape(Rectangle())
    .onTapGesture {
        withAnimation(.easeInOut(duration: 0.3)) {
            activeTab = tab
            /// Scroll Progess Explicitly
            scrollProgress = CGFloat(tab.index)
        }
    }

TapGesture를 추가하고, scrollProgress를 변경하는 부분은 특별하지 않지만,
contentShape를 변경하는 것은 조금 특별하다.

왼쪽은 HStack의 영역이고, 오른쪽은 HStack의 ChildView인 TextView의 영역이다.
추가한 Gesture는 Text에 추가돼 있기 때문에 오른쪽 사진의 좁은 범위 내에서 작동하게 된다.
이때 contentShape를 변경하게 Rectangle로 변경하게 되면 조금 더 넓은 범위인 HStack의 범위를 일부 사용한다.

Gesture에 반응하는 영역이 contentShape를 Rectangle로 변경한 쪽이 훨씬 넓은 걸 확인할 수 있다.

TabGesture의 Animation 버그 고치기

이미 눈치챘겠지만 Indicator에 Tap Gesture를 추가한 이후로 Animation이 이상해진 것을 알 수 있다.
이는 Tab을 선택하면 해당 Tab으로 스크롤되고, 이때 ScrollObserver가 호출되는데 이 것이 원인이다.
따라서 Animation이 시작되면 offsetObserver를 비활성화하고, 종료되면 다시 활성화하는 방식으로 해결할 수 있다.
DispatchQueue를 사용할 수도 있지만 SwiftUI의 Animatable을 사용할 수도 있다.
이번엔 후자의 방식으로 해결한다.

AnimationEndCallBack.swift

struct AnimationState {
    var progress: CGFloat = 0
    var status: Bool = false
}

새로운 구조체를 만들고 속성을 추가한다.
progress는 animation이 언제 끝나는지를 감지하기 위해 사용하는 CGFloat의 변수다.
status는 animation이 끝나면 

AnimationEndCallBack.swift

struct AnimationState {
    var progress: CGFloat = 0
    var status: Bool = false

    mutating func startAnimation() {
        progress = 1.0
        status = true
    }

    mutating func reset() {
        progress = .zero
        status = false
    }
}

animation의 상태가 바뀌면 progress도 함께 초기화하도록 메서드를 두 개 추가한다.

AnimationEndCallBack.swift

struct AnimationEndCallBack<Value: VectorArithmetic>: Animatable, ViewModifier {
    var animatableData: Value {
        didSet {
            checkIfAnimationFinished()
        }
    }
}

본격적으로 코드를 작성한다.
AnimationEndCallBack은 Animatable과 ViewModifier 프로토콜을 채용하는 구조체이고 형태는 다음과 같다.

AnimationEndCallBack.swift

struct AnimationEndCallBack<Value: VectorArithmetic>: Animatable, ViewModifier {
    var animatableData: Value {
        didSet {
            checkIfAnimationFinished()
        }
    }

    var endValue: Value
    var onEnd: () -> ()

    init(endValue: Value, onEnd: @escaping () -> ()) {
        self.endValue = endValue
        self.animatableData = endValue
        self.onEnd = onEnd
    }

    func body(content: Content) -> some View {
        content
    }

    func checkIfAnimationFinished() {
        if animatableData == endValue {
            DispatchQueue.main.async {
                onEnd()
            }
        }
    }
}

이러한 방식은 Animation Observer를 사용하는 대중적인 방식으로,
자세한 설명은 다음의 링크와 같은 여러 자료들로 보충할 수 있다.

 

SwiftUI Animation Observer - Track Animation Progress and Get Completion Callback | Swift UI recipes

Track SwiftUI animation progress and completion via callbacks. For an animated value (offset, opacity, etc.), get its current value as the animation progresses and then get notified when the animation is completed.

swiftuirecipes.com

Home.swift

struct Home: View {
    @State private var activeTab: Tab = .dance
    @State private var scrollProgress: CGFloat = .zero
    @State private var tapState: AnimationState = .init()
    .
    .
    .

이제 animationObserver를 사용하기 위해 Home에 추가한다.
tapState는 AnimationEndCallBack이 반환하는 AnimationState 형식의 변수다.

Home.swift

    .
    .
    .
    func TabIndicatorView() -> some View {
        GeometryReader {
            let size = $0.size
            let tabWidth = size.width / 3

            HStack(spacing: 0) {
                ForEach(Tab.allCases, id: \.rawValue) { tab in
                    Text(tab.rawValue)
                        .font(.title3.bold())
                        .foregroundColor(activeTab == tab ? .primary : .gray)
                        .frame(width: tabWidth)
                        .contentShape(Rectangle())
                        .onTapGesture {
                            withAnimation(.easeInOut(duration: 0.3)) {
                                activeTab = tab
                                scrollProgress = -CGFloat(tab.index)
                                tapState.startAnimation()
    .
    .
    .

TapGesture에서는 startAnimation을 호출해 progress를 1.0으로,  status를 true로 변경한다.

Home.swift

    .
    .
    .
    func TabIndicatorView() -> some View {
        GeometryReader {
            let size = $0.size
            let tabWidth = size.width / 3

            HStack(spacing: 0) {
                ForEach(Tab.allCases, id: \.rawValue) { tab in
                    Text(tab.rawValue)
                        .font(.title3.bold())
                        .foregroundColor(activeTab == tab ? .primary : .gray)
                        .frame(width: tabWidth)
                        .contentShape(Rectangle())
                        .onTapGesture {
                            withAnimation(.easeInOut(duration: 0.3)) {
                                activeTab = tab
                                scrollProgress = -CGFloat(tab.index)
                                tapState.startAnimation()
                            }
                        }
                }
            }
            .frame(width: CGFloat(Tab.allCases.count) * tabWidth)
            .padding(.leading, tabWidth)
            .offset(x: scrollProgress * tabWidth)
        }
        .modifier(
            AnimationEndCallBack(endValue: tapState.progress, onEnd: {
                print("reset")
                tapState.reset()
            })
        )
        .frame(height: 50)
        .padding(.top, 15)
    }
    .
    .
    .

이후 CustomModifier를 추가한다.
종료 값은 progress이고, 종료 시 completionHandler에서 로그를 출력하고 reset 메서드를 호출한다.

이때 이 modifier의 위치가 중요한데,
Indicator 자체에 추가하게 되면 ForEach 덕분에 모든 Tab에 observer가 추가되므로 메서드 호출이 중복되는 문제가 생긴다.

Home.swift

    .
    .
    .
    TabView(selection: $activeTab) {
        ForEach(Tab.allCases, id: \.rawValue) { tab in
            TabImageView(tab)
                .tag(tab)
                .offsetX(activeTab == tab) { rect in
                    let minX = rect.minX
                    let pageOffset = minX - (size.width * CGFloat(tab.index))

                    let pageProgress = pageOffset / size.width

                    if !tapState.status {
                        scrollProgress = max(min(pageProgress, 0), -CGFloat(Tab.allCases.count - 1))
                    }
                }
        }
    }
    .
    .
    .

Animation Observer에 Scroll Observer가 영향을 받으므로 tab의 시작과 끝에서 Tab Indicator의 움직임을 제한하던 부분은
tapState가 false인 경우 동작하도록 예외처리를 진행한다.

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

ParallaxEffect  (0) 2023.06.30
AutoScrolling #06  (0) 2023.04.07
AutoScrolling #04  (0) 2023.03.23
AutoScrolling #03  (0) 2023.03.23
AutoScrolling #02  (0) 2023.03.22