iyOmSd/Title: SwiftUI

[SwiftUI] ScrollView Offset

냄수 2022. 9. 7. 20:02
반응형

스유를 사용해서 구현하다보면 스크롤 이벤트를 처리하기 까다로운 기능들이 몇몇 있는데 그중 흔하게 사용하는

offset 체크 -> 일정높이에서의 이벤트, scroll의 처음부분, 끝부분처리 UX기능에 적용가능

스크롤 방향 체크 -> 위아래로 스크롤하며 자연스러운 UX기능에 적용가능

와 같은 이벤트를 프로퍼티로 제공해주지않기때문에

직접 구현해서 사용해야하는 번거로움이 있어요

이런 뷰가있을떄

스크롤이벤트를 구현해보도록 할게요

아래코드는 간단한 뷰만 구현한 껍데기코드에요

struct ContentView: View {
    private let data: [String] = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16"]
    @State private var direct: Direct = .none
    @State private var offset: CGFloat = .zero
    
    var body: some View {
        ScrollView {
            LazyVStack(pinnedViews: .sectionHeaders) {
                Section {
                    ForEach(data, id: \.self) {
                        CellView(title: $0)
                    }
                } header: {
                    HeaderView(direct: $direct, offset: $offset)
                }
            }
        }
        .clipped()
    }
    
    enum Direct {
        case none
        case up
        case down
        
        var title: String {
            switch self {
                case .none: return "ㅇㅇ"
                case .up: return "위"
                case .down: return "아래"
            }
        }
    }
    
    struct HeaderView: View {
        @Binding var direct: Direct
        @Binding var offset: CGFloat
        
        var body: some View {
            ZStack {
                Color.orange
                VStack {
                Text("Header View")
                    Text("\(direct.title)로 스크롤중")
                    Text("현재위치: \(offset)")
                }
            }
            .frame(height: 100)
        }
    }
    
    struct CellView: View {
        let title: String
        
        var body: some View {
            ZStack {
                Color.green
                Text(title)
            }
            .frame(height: 50)
        }
    }
}

 

PreferenceKey를 이용해서 offset을 측정할거에요

뷰에서 생성한 이름있는 값

    struct ScrollOffsetKey: PreferenceKey {
        static var defaultValue: Value
        static func reduce(value: inout Value, nextValue: () -> Value) {
            value += nextValue()
        }
    }

PreferenceKey프로토콜을 채택하면 이런 구현부를 볼 수 있어요

Value에는 사용할 타입을 넣어주면되요

reduce함수는 뷰로부터 값을 받았을때 

현재값을 지금 들어온값과 어떻게 계산할건지 정의해주는 부분이에요

지금같은경우는 +를통해 값을 누적시키도록 했어요

구현을 하고 스크롤을하면 아래처럼 찍히는데요

보이는것과 같이 nextValue는 0이고

value가 진짜값으로들어와요 inout이기때문에 그 값에 적용이되는거죠

여기서 value값이 변하면

onPreferenceChange에서 값을 확인할 수 있어요

reduce함수에 아무것도 구현하지않아도 변경된 value이 print찍히는걸 알 수 있죠

 

 

이제 구현해볼까요?

먼저 프로토콜을 채택한 구조체를 만들거에요

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

value값에 어떤처리를해줘야

위에서말한 onPreferenceChange가 호출이되서 새로운 값을 받아 올 수 있고

그 값을이용해서 처리를 할 수 있어요

즉, 0을 더하던 어떤 값을 더하던 계산을 했다 해줘야 변경됫다고 인지하더라구요

value = 3 ❌

value += 0 ✅

value += nextValue() ✅

 

이 구조체를 어디에쓰느냐

원하는 위치에 선언해서쓰면되요

이 뷰가 있는 부분을 기준으로 offset을 옵저빙할거에요

private var scrollObservableView: some View {
    GeometryReader { proxy in
        let offsetY = proxy.frame(in: .global).origin.y
        Color.clear
            .preference(
                key: ScrollOffsetKey.self,
                value: offsetY
            )
            .onAppear { // 나타날때 뷰의 최초위치를 저장하는 로직
                viewModel.setOriginOffset(offsetY)
            }
    }
    .frame(height: 0)
}

GeometryReader을 이용해서 view frame을 가져올거구요

Color.clear를 이용해서 안보이게 하면

결론적으론

컬러가 없고 hegiht 0인 뷰를 몰래 만들어서 해당 뷰의 위치를 체크하는 방식으로 offset을 옵저빙하는거죠

 

해당 뷰가 그려지고 이동하면서

preference() 저부분이 실행되고

그중에 frame의 origin y값을 전달하는 방식이죠

 

저값을 받는 부분은

위에서 본 body부분의 scrollView에

onPreferenceChange를 정의해주면 되겠네요

var body: some View {
    ScrollView {
        scrollObservableView
        LazyVStack(pinnedViews: .sectionHeaders) {
            Section {
                ForEach(data, id: \.self) {
                    CellView(title: $0)
                }
            } header: {
                HeaderView(direct: $direct, offset: $offset)
            }
        }
    }
    .clipped()
    .onPreferenceChange(ScrollOffsetKey.self) {  // 추가부분
        viewModel.setOffset($0)
    }
}

 

 

지금까지 구현한걸 요약하면

1. PreferenceKey 프로토콜 채택한 구조체만들기

2. 컬러 없고 height 0인 뷰에 전달할 값 정의하기

3. onPreferenceChange로 값 받기

 

이젠 이값을 이용해서

스크롤위치와

스크롤방향을 알아볼게요

 

 

우선 뷰모델을 하나 정의할게요

 

 

컬러가 없고 height가 0인뷰가 생기는 위치는 정의하기 나름이기떄문에

정의된 위치를 0으로 잡기위해 한번만 origin위치를 저장하는 변수와

계속 변경되는 offset을 저장할 변수를 정의했어요

final class ViewModel: ObservableObject {
    var offset: CGFloat = 0
    var originOffset: CGFloat = 0
    var isCheckedOriginOffset: Bool = false
    
    func setOriginOffset(_ offset: CGFloat) {
        guard !isCheckedOriginOffset else { return }
        self.originOffset = offset
        isCheckedOriginOffset = true
    }
    
    func setOffset(_ offset: CGFloat) {
        self.offset = offset
    }
}

스크롤위치는 간단하죠

계속변경된 offset정보를 가지고있는 변수를 보여주면 끝이죠

 


struct ContentView: View {
    private let data: [String] = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16"]
    @StateObject private var viewModel: ViewModel = ViewModel()
    
    ...
    
    var body: some View {
        ScrollView {
            scrollObservableView  // 옵저버 위치
            LazyVStack(pinnedViews: .sectionHeaders) {
                Section {
                    ForEach(data, id: \.self) {
                        CellView(title: $0)
                    }
                } header: {
                    HeaderView(direct: $viewModel.direct, offset: $viewModel.offset)
                }
            }
        }
        .clipped()
        .onPreferenceChange(ScrollOffsetKey.self) {
            viewModel.setOffset($0)
        }
    }
    
    ...
}

현재위치는 safe area바로아래이기때문에

아이폰 13기준 최초위치는  47일거에요

빌드를 시키면

잘 뜨네요!

스크롤도하면 위치가 변하는것도 볼 수 있어요

 

또한 이전값이랑 비교해서

위로 스크롤 하는지

아래로 하는지도 알 수 있겠죠?

 

 

앞에서 정의한 뷰를 보면

private var scrollObservableView: some View {
    GeometryReader { proxy in
        let offsetY = proxy.frame(in: .global).origin.y
        Color.clear
            .preference(
                key: ScrollOffsetKey.self,
                value: offsetY
            )
            .onAppear {
                viewModel.setOriginOffset(offsetY)
            }
    }
    .frame(height: 0)
}

var body: some View {
    ScrollView {
        scrollObservableView
        LazyVStack(pinnedViews: .sectionHeaders) {
           ...
        }
    }
    .onPreferenceChange(ScrollOffsetKey.self) {
        viewModel.setOffset($0)
    }
}

setOffset -> setOriginOffset 순으로 함수가호출되요

즉, Color.clear쪽에서

preference() 호출후

onAppear()가 불린다는 것이죠

 

그래서초기화 될 때 예방코드를 작성해주셔야해요

 

final class ViewModel: ObservableObject {
    @Published var offset: CGFloat = 0
    @Published var direct: Direct = .none
    private var originOffset: CGFloat = 0
    private var isCheckedOriginOffset: Bool = false
    
    func setOriginOffset(_ offset: CGFloat) {
        guard !isCheckedOriginOffset else { return }
        self.originOffset = offset
        self.offset = offset
        isCheckedOriginOffset = true
    }
    
    func setOffset(_ offset: CGFloat) {
        guard isCheckedOriginOffset else { return }
        if self.offset < offset {
            direct = .down
        } else if self.offset > offset {
            direct = .up
        } else {
            direct = .none
        }
        self.offset = offset
    }
}

주의해야할점으로는

pan제스처가아니라 뷰의 위치이기때문에 bounce되는 위치도 스크롤 방향에 적용된다는 점이죠

 

전체코드입니다

enum Direct {
    case none
    case up
    case down
    
    var title: String {
        switch self {
            case .none: return "ㅇㅇ"
            case .up: return "위"
            case .down: return "아래"
        }
    }
}

struct ContentView: View {
    private let data: [String] = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16"]
    @StateObject private var viewModel: ViewModel = ViewModel()
    
    private var scrollObservableView: some View {
        GeometryReader { proxy in
            let offsetY = proxy.frame(in: .global).origin.y
            Color.clear
                .preference(
                    key: ScrollOffsetKey.self,
                    value: offsetY
                )
                .onAppear {
                    viewModel.setOriginOffset(offsetY)
                }
        }
        .frame(height: 0)
    }
    
    var body: some View {
        ScrollView {
            scrollObservableView
            LazyVStack(pinnedViews: .sectionHeaders) {
                Section {
                    ForEach(data, id: \.self) {
                        CellView(title: $0)
                    }
                } header: {
                    HeaderView(direct: $viewModel.direct, offset: $viewModel.offset)
                }
            }
        }
        .clipped()
        .onPreferenceChange(ScrollOffsetKey.self) {
            viewModel.setOffset($0)
        }
    }
    
    struct HeaderView: View {
        @Binding var direct: Direct
        @Binding var offset: CGFloat
        
        var body: some View {
            ZStack {
                Color.orange
                VStack {
                Text("Header View")
                    Text("\(direct.title)로 스크롤중")
                    Text("현재위치: \(offset)")
                }
            }
            .frame(height: 100)
        }
    }
    
    struct CellView: View {
        let title: String
        
        var body: some View {
            ZStack {
                Color.green
                Text(title)
            }
            .frame(height: 50)
        }
    }

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

final class ViewModel: ObservableObject {
    @Published var offset: CGFloat = 0
    @Published var direct: Direct = .none
    private var originOffset: CGFloat = 0
    private var isCheckedOriginOffset: Bool = false
    
    func setOriginOffset(_ offset: CGFloat) {
        guard !isCheckedOriginOffset else { return }
        self.originOffset = offset
        self.offset = offset
        isCheckedOriginOffset = true
    }
    
    func setOffset(_ offset: CGFloat) {
        guard isCheckedOriginOffset else { return }
        if self.offset < offset {
            direct = .down
        } else if self.offset > offset {
            direct = .up
        } else {
            direct = .none
        }
        self.offset = offset
    }
}

 

뷰모델에는 최초위치가 저장되어있으니까 이값을 이용하면

지금 지점이 스크롤뷰의 제일 상단인지도 체크할 수 있어요

👍

반응형