Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
Tags
- swift documentation
- RxCocoa
- uiscrollview
- 리펙터링
- UICollectionView
- Xcode
- combine
- 리팩토링
- 스위프트
- clean architecture
- 애니메이션
- map
- Human interface guide
- ios
- tableView
- Clean Code
- 리펙토링
- swiftUI
- Observable
- Protocol
- SWIFT
- Refactoring
- MVVM
- HIG
- collectionview
- UITextView
- uitableview
- rxswift
- ribs
- 클린 코드
Archives
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS - SwiftUI] ScrollView 스크롤 하단 도달했는지 확인 방법, ContentOffset 구하는 방법 (#reachToBottom, #ContentOffset) 본문
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
'iOS 응용 (SwiftUI)' 카테고리의 다른 글
[iOS - SwiftUI] background vs overlay 차이 (0) | 2024.11.05 |
---|---|
[iOS - SwiftUI] PreferenceKey, onPreferenceChange (자식뷰에서 부모뷰로 데이터 변경 알리기, 데이터 바인딩) (0) | 2024.10.29 |
[iOS - SwiftUI] stroke 사용 시 주의사항 (#원 안에 테두리 넣기) (1) | 2024.10.22 |
[iOS - SwiftUI] 뷰를 버튼처럼 만드는 방법 (DragGesture, pressed color 효과, 커스텀 버튼) (2) | 2024.10.14 |
[iOS - SwiftUI] 애니메이션 적용 주의사항 (animation, transition, withAnimation) (2) | 2024.10.11 |
Comments