→ Swift Archive

[SwiftUI] 스크롤 위치 추적하기

Swift librarian 2024. 2. 16. 15:24

개발을 하다보면 아래와 같이 스크롤의 상태, 스크롤된 정도를 추적하고 싶을 때가 있다. Swift 개발을 하다보면 NavigationTitle 의 경우가 이렇게 스크롤을 추적하여 상단 툴바에 Title 을 표시해준다.

 

다양한 인터렉션을 위해서 스크롤의 정도를 추적하고 싶다면... 세가지 방법을 소개하겠다 :)

 

1. PreferenceKey 와 View extension 활용

아래와 같은 코드를 작성해주면 된다. PreferenceKey 는 하위뷰에서 상위뷰로 값을 전달해주고 싶을 때 사용하게 된다.

import SwiftUI

struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
        value = nextValue()
    }
}

extension View {
    func onScrollOffsetChanged(action: @escaping (_ offset: CGPoint) -> Void) -> some View {
        self
            .background(GeometryReader { geometry in
                Color.clear
                    .preference (
                        key: ScrollOffsetPreferenceKey.self,
                        value: geometry.frame(in: .global).origin
                    )
            })
            .onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: action)
    }
}

 

뷰에서 사용방법은 아래와 같이 사용 할 수 있다.

import SwiftUI

struct ContentView: View {
    @State private var offset: CGPoint = .zero
    
    var body: some View {
        ScrollView {
            Color(.systemGray3)
                .frame(height: 200)
                .onScrollOffsetChanged { offset in
                    self.offset = offset
                }
                .overlay {
                    Text("\(offset.y)")
                }
        }
    }
}

 

 

이슈

단점이라고 볼 수 있는데... 59.0 부터 값이 시작한다는 것이다. 상단의 SafeArea 의 높이가 59 이기 때문에 발생한다. 하지만 모든 기기에서 동일하게 59.0 부터 시작되기 때문에 -59 를 해줘서 보정을 해주면 될 듯하다.

 

해결방법

coordinateSpaceName 을 지정해준다면 0부터 시작이 된다.

import SwiftUI

struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
        value = nextValue()
    }
}

extension View {
    func onScrollOffsetChanged(name: UUID, action: @escaping (_ offset: CGPoint) -> Void) -> some View {
        self
            .background(GeometryReader { geometry in
                Color.clear
                    .preference (
                        key: ScrollOffsetPreferenceKey.self,
                        value: geometry.frame(in: .named(name)).origin
                    )
            })
            .onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: action)
    }
}

struct ContentView: View {
    @State private var offset: CGPoint = .zero
    
    private let coordinateSpaceName = UUID()
    
    var body: some View {
        ScrollView {
            Color(.systemGray3)
                .frame(height: 200)
                .onScrollOffsetChanged(name: coordinateSpaceName) { offset in
                    self.offset = offset
                }
                .overlay {
                    Text("\(offset.y)")
                }
        }
        .coordinateSpace(name: coordinateSpaceName)
    }
}

 

2. PreferenceKey 활용하여 CustomView 만들기

위에서 사용한 것을 응용해서 아래와 같은 나만의 OffsetObservingScrollView 를 만들 수 있다.

import SwiftUI

struct PositionObservingView<Content: View>: View {
    var coordinateSpace: CoordinateSpace
    @Binding var position: CGPoint
    @ViewBuilder var content: () -> Content
    
    var body: some View {
        content()
            .background(GeometryReader { geometry in
                Color.clear.preference(
                    key: PreferenceKey.self,
                    value: geometry.frame(in: coordinateSpace).origin
                )
            })
            .onPreferenceChange(PreferenceKey.self) { position in
                self.position = position
            }
    }
}

private extension PositionObservingView {
    struct PreferenceKey: SwiftUI.PreferenceKey {
        static var defaultValue: CGPoint { .zero }
        static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
    }
}

struct OffsetObservingScrollView<Content: View>: View {
    var axes: Axis.Set = [.vertical]
    var showsIndicators = true
    @Binding var offset: CGPoint
    @ViewBuilder var content: () -> Content
    private let coordinateSpaceName = UUID()
    
    var body: some View {
        ScrollView(axes, showsIndicators: showsIndicators) {
            PositionObservingView(
                coordinateSpace: .named(coordinateSpaceName),
                position: $offset,
                content: content
            )
        }
        .coordinateSpace(name: coordinateSpaceName)
    }
}


사용하는 방법은 좀더 간단하다. 아래와 같이 ScrollView 처럼 써주고 offset 만 전달해주면 된다.

import SwiftUI

struct ContentView: View {
    @State private var offset: CGPoint = .zero
    
    private let coordinateSpaceName = UUID()
    
    var body: some View {
        OffsetObservingScrollView(offset: $offset) {
            Color(.systemGray3)
                .frame(height: 200)
                .overlay {
                    Text("\(offset.y)")
                }
        }
    }
}

 

3. Binding 만 사용하여 CustomView 만들기

사실 의문점이 생겼다. 굳이 PreferenceKey 를 만들어야 할까? @Binding 만으로 전달하여 offset 을 추적할 수 있지 않을까? 아래와 같이 만들어 주면 된다.

import SwiftUI

struct ObservingScrollView<Content: View>: View {
    var axes: Axis.Set = [.vertical]
    var showsIndicators = true
    @Binding var offset: CGPoint
    var content: () -> Content
    
    private let coordinateSpaceName = UUID()

    var body: some View {
        ScrollView(axes, showsIndicators: showsIndicators) {
            content()
                .background(GeometryReader { geometry in
                    Color.clear
                        .onAppear {
                            calculateOffset(in: geometry)
                        }
                        .onChange(of: geometry.frame(in: .named(coordinateSpaceName)).origin) {
                            calculateOffset(in: geometry)
                        }
                })
        }
        .coordinateSpace(name: coordinateSpaceName)
    }
    
    private func calculateOffset(in geometry: GeometryProxy) {
        let newOffset = geometry.frame(in: .named(coordinateSpaceName)).origin
        DispatchQueue.main.async {
            self.offset = newOffset
        }
    }
}

 

사용방법은 2번과 같다. ScrollView 처럼 사용하면 될 것 같다.

import SwiftUI

struct ContentView: View {
    @State private var offset: CGPoint = .zero
    
    private let coordinateSpaceName = UUID()
    
    var body: some View {
        ObservingScrollView(offset: $offset) {
            Color(.systemGray3)
                .frame(height: 200)
                .overlay {
                    Text("\(offset.y)")
                }
        }
    }
}

 

가로로 스크롤 할 경우

이 경우도 고려하여 만들었다. offset.x 를 가져오면 된다. 물론 axis.horizontal 이 되면 된다.

 

마치며

세개의 코드중 어떤 것이 제일 좋고, 최적화가 된 코드인지는 아직 모르겠다. 하지만 장단점은 분명히 있다. 첫번째는 정말 내가 원하는 여러가지 요소의 위치를 추적하고 싶을 때 자유롭게 사용이 가능하지만, 두번째 세번째는 스크롤뷰 그 자체만 추적이 가능하여 좀더 편하게 사용가능하긴 하지만 제한적이라는 생각이 든다.