iOS 응용 (SwiftUI)

[iOS - SwiftUI] ScrollView 스크롤 하단 도달했는지 확인 방법, ContentOffset 구하는 방법 (#reachToBottom, #ContentOffset)

jake-kim 2024. 10. 31. 01:55

contentOffset과 하단에 도달했는지 알려주는 ScrollView

ScrollView

  • SwiftUI에서는 ScrollView를 사용할 때, UIKit에서 제공하는 UIScrollView와는 아래 정보 확인이 바로 어려움
    • reachToBottom (스크롤이 바닥에 도달했는지)
    • contentOffset (얼만큼 스크롤을 진행했는지)
  • 위 정보를 알 수 있으려면 ScrollView를 감싸서 따로 계산하여 정보 획득이 가능

reachToBottom과 contentOffset 구하는 아이디어

  • ScrollView를 감싸서 구현하고, 아래처럼 사용하는쪽에서는 CustomScrollView에 클로저로 콘텐츠 뷰들을 넣을 수 있도록 구현 
var body: some View {
    VStack {
        CustomScrollView(
            frameHeight: 300,
            contentOffset: $contentOffset,
            reachToBottom: $reachToBottom
        ) { _ in
            VStack(spacing: 20) {
                // data...
            }
        }
    }
}
  • ScrollView를 감싸서 내부에 Color.clear를 넣고 여기에 offset이 변경될때마다 offset과 contentheight를 계산하여 스크롤이 바닥에 닿았는지 (reachToBottom) 파악
    • offset이 변경될때마다 계산해야하는 코드가 들어가야하므로, offset이 변경되는것을 옵저빙하기 위해서 PreferenceKey사용
    • PreferenceKey 개념은 이전 포스팅 글 참고

구현

  • CustomScrollView 선언
struct CustomScrollView<Content: View>: View {
}
  • 초기화에 필요한 값들도 준비
struct CustomScrollView<Content: View>: View {
    /// 넘겨주는 값
    @Binding private var contentOffset: CGPoint
    @Binding private var reachToBottom: Bool
    
    /// 내부 계산에서 사용하는 값
    private let frameHeight: CGFloat
    @State private var contentHeight = CGFloat.zero
    @Namespace private var coordinateSpaceName: Namespace.ID
    @ViewBuilder private var content: (ScrollViewProxy) -> Content
    
    init(
        frameHeight: CGFloat,
        contentOffset: Binding<CGPoint>,
        reachToBottom: Binding<Bool>,
        @ViewBuilder content: @escaping (ScrollViewProxy) -> Content
    ) {
        self.frameHeight = frameHeight
        _contentOffset = contentOffset
        _reachToBottom = reachToBottom
        self.content = content
    }
}
  • body부분 구현
    • 외부에서 주입해준 뷰를 담고있는 content를 ScrollView 하위에 놓은 형태
    • content에 특정 속성을 넣어서 contentOffset, reachToBottom을 구할 수 있도록 아래에서 계속 구현..
var body: some View {
    ScrollView(.vertical, showsIndicators: true) {
        ScrollViewReader { scrollViewProxy in
            content(scrollViewProxy)
            	// TODO: content에 특정 뷰를 붙여서 offset을 계산할 것
        }
    }
}
  • content에 *background를 넣어서 contentView 밑에 중첩되게끔 위치시키고 Color.clear를 넣어서 이 뷰를 이용하여 스크롤을 관찰할 수 있도록 구현
    • cf) background vs overlay? -> 둘 다 뷰를 중첩하지만 background는 뷰 아래에, overlay는 뷰 위에 중첩시키는것

content 하위에 다음과 같이 구현)

            content(scrollViewProxy)
                .background {
                    GeometryReader { geometryProxy in
                        Color.clear
                            // TODO: 스크롤 offset을 방출시키는 코드 넣기
                    }
                }
  • geometryProxy를 사용하면 contentHeight와 contentOffset을 구할 수 있음
    • contentHeight는 단순히 geometryProxy.size.height로 사용
GeometryReader { geometryProxy in
    Color.clear
        .onAppear {
            let contentHeight = geometryProxy.size.height
            self.contentHeight = contentHeight
        }
}
  • contentOffset은 현재 ScrollView의 좌표값과 geometryProxy를 비교하면 구할 수 있음

ex) ScrollView의 좌표값은 coordinateSpaceName을 사용하여 구할 수 있음

struct CustomScrollView<Content: View>: View {
	...
    @Namespace private var coordinateSpaceName: Namespace.ID
    
    var body: some View {
        ScrollView(.vertical, showsIndicators: true) {
            ScrollViewReader { scrollViewProxy in
                content(scrollViewProxy)
                    .background {
                        GeometryReader { geometryProxy in
                            Color.clear
                                .onAppear {
                                    let contentHeight = geometryProxy.size.height
                                    self.contentHeight = contentHeight
                                }
                        }
                    }
            }
        }
        .coordinateSpace(name: coordinateSpaceName)
    }
}

// contentOffset값 = -geometryProxy.frame(in: .named(coordinateSpaceName)).minY
  • contentOffset값은 -geometryProxy.frame(in: .named(coordinateSpaceName)).minY 이므로 이 값을 실시간으로 방출시키고 이값을 옵저빙하면 reachToBottom도 스크롤 되는 순간 구하는것이 가능
    • 데이터를 실시간으로 방출시키고, 옵저빙하는 기능은 PreferenceKey를 사용하면 되므로 PreferenceKey를 정의하여 이것을 사용하여 계산
    • PreferenceKey관련 개념은 이전 포스팅 글 참고

PreferenceKey 정의)

  • reduce(value:nextValue:)의 구현부를 비워두는 이유는 외부에서 어차피 offset을 계산하여 넘길 것이므로 reduce 구현부는 비워두기
private struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGPoint { .zero }
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}
  • ContentOffset와 reachToBottom 계산
    • 데이터 방출: preference(key:value:)에 이 키를 등록하고 geometryProxy와 coordinateSpaceName으로 contentOffset을 계산하여 데이터 방출
    • 데이터 옵저빙 & 계산: onPreferenceChange에서 reachToBottom을 계산 (단순히 contentHeight와 contentOffset을 계산하여 contentOffset.y 값보다 같거나 작으면 하단에 도달했다고 판단)
var body: some View {
    ScrollView(.vertical, showsIndicators: true) {
        ScrollViewReader { scrollViewProxy in
            content(scrollViewProxy)
                .background {
                    GeometryReader { geometryProxy in
                        Color.clear
                            .onAppear {
                                let contentHeight = geometryProxy.size.height
                                self.contentHeight = contentHeight
                            }
                            .preference( // <-
                                key: ScrollOffsetPreferenceKey.self,
                                value: CGPoint(
                                    x: -geometryProxy.frame(in: .named(coordinateSpaceName)).minX,
                                    y: -geometryProxy.frame(in: .named(coordinateSpaceName)).minY
                                )
                            )
                    }
                }
        }
    }
    .coordinateSpace(name: coordinateSpaceName)
    .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
        guard contentHeight != 0 else { return }
        let currentScrollOffset = value.y + frameHeight
        reachToBottom = contentHeight <= currentScrollOffset
        contentOffset = value
    }
}
  • 완성) CustomScrollView
struct CustomScrollView<Content: View>: View {
    /// 넘겨주는 값
    @Binding private var contentOffset: CGPoint
    @Binding private var reachToBottom: Bool
    
    /// 내부 계산에서 사용하는 값
    private let frameHeight: CGFloat
    @State private var contentHeight = CGFloat.zero
    @Namespace private var coordinateSpaceName: Namespace.ID
    @ViewBuilder private var content: (ScrollViewProxy) -> Content
    
    init(
        frameHeight: CGFloat,
        contentOffset: Binding<CGPoint>,
        reachToBottom: Binding<Bool>,
        @ViewBuilder content: @escaping (ScrollViewProxy) -> Content
    ) {
        self.frameHeight = frameHeight
        _contentOffset = contentOffset
        _reachToBottom = reachToBottom
        self.content = content
    }
    
    var body: some View {
        ScrollView(.vertical, showsIndicators: true) {
            ScrollViewReader { scrollViewProxy in
                content(scrollViewProxy)
                    .background {
                        GeometryReader { geometryProxy in
                            Color.clear
                                .onAppear {
                                    let contentHeight = geometryProxy.size.height
                                    self.contentHeight = contentHeight
                                }
                                .preference(
                                    key: ScrollOffsetPreferenceKey.self,
                                    value: CGPoint(
                                        x: -geometryProxy.frame(in: .named(coordinateSpaceName)).minX,
                                        y: -geometryProxy.frame(in: .named(coordinateSpaceName)).minY
                                    )
                                )
                        }
                    }
            }
        }
        .coordinateSpace(name: coordinateSpaceName)
        .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
            guard contentHeight != 0 else { return }
            let currentScrollOffset = value.y + frameHeight
            reachToBottom = contentHeight <= currentScrollOffset
            contentOffset = value
        }
    }
}

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

사용하는 쪽)

struct ContentView: View {
    @State private var contentOffset = CGPoint.zero
    @State private var reachToBottom = false
    
    var body: some View {
        VStack {
            Text("contentOffset: (\(contentOffset.x), \(contentOffset.y))")
            Text("Reached Bottom: \(reachToBottom ? "Yes" : "No")")
            
            CustomScrollView(
                frameHeight: 300,
                contentOffset: $contentOffset,
                reachToBottom: $reachToBottom
            ) { _ in
                VStack(spacing: 20) {
                    ForEach(1...30, id: \.self) { index in
                        Text("Item \(index)")
                            .frame(maxWidth: .infinity)
                            .padding()
                            .background(Color.blue.opacity(0.2))
                            .cornerRadius(10)
                    }
                }
                .padding()
            }
            .frame(height: 300)
            .border(Color.gray)
        }
        .padding()
    }
}

 

동작)

 

* 참고

- https://developer.apple.com/documentation/swiftui/preferencekey

- https://green1229.tistory.com/463