iOS 응용 (SwiftUI)
[iOS - SwiftUI] ScrollView 스크롤 하단 도달했는지 확인 방법, ContentOffset 구하는 방법 (#reachToBottom, #ContentOffset)
jake-kim
2024. 10. 31. 01:55
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