반응형

👋 들어가기 전
사이드에서, pullToRefresh와 Pagination Loading을 위해, 조금 old한 방법으로 구현을 했느네,
iOS18.0 이상부터 사용가능한 좋은 API가 있어서 그걸로 대체해보려가한다..
우리 프로젝트는 18.0이상이니깐..
😂 기존 코드와 문제점
1️⃣ GeometryReader + PreferenceKey +coordinateSpace
- 부가적인 코드가 너무 많음
- 코드 흐름 파악이 힘듬
2️⃣ 타이밍을 운에 걸어야함
- ScrollView 높이를 얻기 위해 GeometryReader 내부의 proxy.size.height을 onChange에서 계속 받음
기능상 큰 문제는 없지만, 편법을 하기위한 트레이드 오프 생각보다 작지는 않아보인다.
오늘의 주인공으로 해당 문제를 한번 해결해보자.
결국 나는 scrollView의 높이와, Content 높이를 얻으면 된다.
🦸 구세주 등장
🧩 역할
스크롤 뷰 geometry 정보 변화를 실시간으로 추적하여 전달
역할 자체가 그냥 내꺼다..
📌 구성 요소
nonisolated
func onScrollGeometryChange<T>(
for type: T.Type,
of transform: @escaping (ScrollGeometry) -> T,
action: @escaping (T, T) -> Void
) -> some View where T : Equatable
- type: 관찰하고자 하는 타입
- transform: action쪽에 넘겨주기 전에 데이터를 가공하는 역할, ScrollGeometry는 아래의 정보를 제공
- contentOffset: 현재 스크롤 위치
- contentSize: 전체 콘텐츠 길이
- containerSize: 화면에 보이는 스크롤 뷰의 크기
- contentInsets: 상하좌우 여백
- action: transform에서 전달 받은 값을 oldValue,newValue 형태로 전달.
⏳ 기존 코드
import SwiftUI
public struct OffsetKey: PreferenceKey {
public static var defaultValue: CGFloat = .zero
public static func reduce(
value: inout CGFloat,
nextValue: () -> CGFloat
) {
value = nextValue()
}
}
public extension View {
/// 해당 좌표계에서, 특정 edge에 해당되는 값을 얻음
/// - Parameters:
/// - coordinateSpace: 좌표게 네임스페이스
/// - edge: CGRectEdge
/// - offset: 핸들러 함수
/// - Returns: edge값
@ViewBuilder
func offset(
coordinateSpace: String,
edge: CGRectEdge,
offset: @escaping ((CGFloat) -> Void)
) -> some View {
self
.overlay {
GeometryReader { proxy in
let frame = proxy.frame(in: .named(coordinateSpace))
let value: CGFloat = switch edge {
case .minXEdge: frame.minX
case .maxXEdge: frame.maxX
case .minYEdge: frame.minY
case .maxYEdge: frame.maxY
}
Color.clear
.preference(key: OffsetKey.self, value: value)
.onPreferenceChange(OffsetKey.self) {
offset($0)
}
}
}
}
}
@Observable
private class RefreshableViewModel: NSObject, UIGestureRecognizerDelegate {
/// 새로고침 트리거 임계치를 넘었는지 여부
var isEligible: Bool = false
/// 실제 새로고침 동작이 진행 중인지 여부
var isRefreshing: Bool = false
/// 사용자가 아래로 당긴 총 스크롤 거리
var scrollOffset: CGFloat = .zero
/// 콘텐츠 자체의 현재 위치 오프셋
var contentOffset: CGFloat = .zero
/// 0~1 사이 진행도 (인디케이터 표시용)
var progress: CGFloat = .zero
/// 아래 도달했는 지를 추적하는 변수
var maxYoffset: CGFloat = .zero
/// 스크롤뷰 Height
var scrollViewHeight: CGFloat = .zero
/// 페이지 네이션을 위한 로딩
var isLoadingNextPage: Bool = false
/// 모든 상태 초기화 (refresh 종료 시 호출)
func reset() {
isEligible = false
isRefreshing = false
scrollOffset = .zero
contentOffset = .zero
progress = .zero
}
/// 전역 Pan 제스처 추가 → 스크롤 release 시점 감지용
func addGesture() {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(onGestureChange(gesture:)))
panGesture.delegate = self
// 루트 뷰에 제스처를 붙여서 ScrollView와 동시에 감지
UIApplication.shared.rootViewController?.view.addGestureRecognizer(panGesture)
}
/// 뷰 사라질 때 제스처 제거
func removeGesture() {
UIApplication.shared.rootViewController?.view.gestureRecognizers?.removeAll()
}
/// 사용자가 손을 뗐는지 감지
@objc func onGestureChange(gesture: UIPanGestureRecognizer) {
if gesture.state == .cancelled || gesture.state == .ended {
// 아직 refresh 중이 아니고, 충분히 당겨졌다면 refresh 가능 상태로 전환
if !isRefreshing {
isEligible = scrollOffset > indicatorViewHeight
}
}
}
/// ScrollView의 pan 제스처와 동시에 인식 허용
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
return true
}
}
public struct RefreshableView<
Content: View,
IndicatorView: View
>: View {
private let showBottomIndicator: Bool
private let content: () -> Content
private let topIndicatorView: () -> IndicatorView
private let onRefresh: () async -> Void
private let onLoadMore: (() async -> Void)?
@State private var viewModel = RefreshableViewModel()
/// pullToRefresh + 페이지네이션 로딩을 지원하는 컨테이너 뷰
/// - Parameters:
/// - showBottomIndicator: 바텀 페이지네이션용 인디케이터 사용 여부
/// - content: 스크롤뷰안에 넣을 내용 관련 뷰
/// - topIndicatorView: pullToRefresh에 표현 될 뷰
/// - onRefresh: pullToRefresh 시작 이벤트 전달
/// - onLoadMore: 페이지네이션 로딩 이벤트 전달
/// - Note: 하단 페이지네이션 인디케이터는 아 커스텀 할 지 모름
public init(
showBottomIndicator: Bool,
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder topIndicatorView: @escaping () -> IndicatorView = { TPLIndicatorView() },
onRefresh: @escaping () async -> Void,
onLoadMore: (() async -> Void)? = nil
) {
self.showBottomIndicator = showBottomIndicator
self.content = content
self.topIndicatorView = topIndicatorView
self.onRefresh = onRefresh
self.onLoadMore = onLoadMore
}
public var body: some View {
GeometryReader { proxy in
ScrollView(.vertical, showsIndicators: true) {
VStack(spacing: .zero) {
ZStack {
topIndicatorView()
.scaleEffect(0.6 + (viewModel.progress * 0.4)) // 당길수록 커짐
}
.frame(height: indicatorViewHeight * viewModel.progress) // 당긴 비율만큼 높이 증가
.opacity(viewModel.progress) // 점점 나타남
.offset(y: viewModel
.isEligible ? -max(0, viewModel.contentOffset) :
-max(0, viewModel.scrollOffset)
)
content()
if viewModel.isLoadingNextPage {
TPLIndicatorView()
}
}
.offset(coordinateSpace: "SCROLL", edge: .minYEdge) { offset in
viewModel.contentOffset = offset
if !viewModel.isEligible {
let progress = (offset / indicatorViewHeight).clamped(to: 0 ... 1)
viewModel.scrollOffset = offset
viewModel.progress = progress
}
if viewModel.isEligible && !viewModel.isRefreshing {
viewModel.isRefreshing = true
}
}
.offset(coordinateSpace: "SCROLL", edge: .maxYEdge) { offset in
if showBottomIndicator && !viewModel.isLoadingNextPage {
viewModel.maxYoffset = offset
checkIfReachedBottom()
}
}
}
.coordinateSpace(name: "SCROLL")
.onChange(of: proxy.size.height) { _, value in
viewModel.scrollViewHeight = value
}
}
.onAppear(perform: viewModel.addGesture)
.onDisappear(perform: viewModel.removeGesture)
.onChange(of: viewModel.isRefreshing) { _, value in
if value {
Task {
await onRefresh()
withAnimation(.easeIn) {
viewModel.reset()
}
}
}
}
.onChange(of: viewModel.isLoadingNextPage) { _, value in
if value {
Task {
await onLoadMore?()
viewModel.isLoadingNextPage = false
}
}
}
}
private func checkIfReachedBottom() {
let threshold: CGFloat = 50
if viewModel.maxYoffset - threshold <= viewModel.scrollViewHeight {
viewModel.isLoadingNextPage = true
}
}
}
🏋️ 개선 코드
import SwiftUI
private let indicatorViewHeight: CGFloat = 20 + Spacing.sp600 * 2
@Observable
private class RefreshableViewModel: NSObject, UIGestureRecognizerDelegate {
/// 새로고침 트리거 임계치를 넘었는지 여부
var isEligible: Bool = false
/// 실제 새로고침 동작이 진행 중인지 여부
var isRefreshing: Bool = false
/// 사용자가 아래로 당긴 총 스크롤 거리
var scrollOffset: CGFloat = .zero
/// 콘텐츠 자체의 현재 위치 오프셋
var contentOffset: CGFloat = .zero
/// 0~1 사이 진행도 (인디케이터 표시용)
var progress: CGFloat = .zero
/// 페이지 네이션을 위한 로딩
var isLoadingNextPage: Bool = false
/// 모든 상태 초기화 (refresh 종료 시 호출)
func reset() {
isEligible = false
isRefreshing = false
scrollOffset = .zero
contentOffset = .zero
progress = .zero
}
/// 전역 Pan 제스처 추가 → 스크롤 release 시점 감지용
func addGesture() {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(onGestureChange(gesture:)))
panGesture.delegate = self
// 루트 뷰에 제스처를 붙여서 ScrollView와 동시에 감지
UIApplication.shared.rootViewController?.view.addGestureRecognizer(panGesture)
}
/// 뷰 사라질 때 제스처 제거
func removeGesture() {
UIApplication.shared.rootViewController?.view.gestureRecognizers?.removeAll()
}
/// 사용자가 손을 뗐는지 감지
@objc func onGestureChange(gesture: UIPanGestureRecognizer) {
if gesture.state == .cancelled || gesture.state == .ended {
// 아직 refresh 중이 아니고, 충분히 당겨졌다면 refresh 가능 상태로 전환
if !isRefreshing {
isEligible = scrollOffset > indicatorViewHeight
}
}
}
/// ScrollView의 pan 제스처와 동시에 인식 허용
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
return true
}
}
public struct RefreshableView<
Content: View,
IndicatorView: View
>: View {
private let showBottomIndicator: Bool
private let content: () -> Content
private let topIndicatorView: () -> IndicatorView
private let onRefresh: () async -> Void
private let onLoadMore: (() async -> Void)?
@State private var viewModel = RefreshableViewModel()
/// pullToRefresh + 페이지네이션 로딩을 지원하는 컨테이너 뷰
/// - Parameters:
/// - showBottomIndicator: 바텀 페이지네이션용 인디케이터 사용 여부
/// - content: 스크롤뷰안에 넣을 내용 관련 뷰
/// - topIndicatorView: pullToRefresh에 표현 될 뷰
/// - onRefresh: pullToRefresh 시작 이벤트 전달
/// - onLoadMore: 페이지네이션 로딩 이벤트 전달
/// - Note: 하단 페이지네이션 인디케이터는 아 커스텀 할 지 모름
public init(
showBottomIndicator: Bool,
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder topIndicatorView: @escaping () -> IndicatorView = { TPLIndicatorView() },
onRefresh: @escaping () async -> Void,
onLoadMore: (() async -> Void)? = nil
) {
self.showBottomIndicator = showBottomIndicator
self.content = content
self.topIndicatorView = topIndicatorView
self.onRefresh = onRefresh
self.onLoadMore = onLoadMore
}
public var body: some View {
ScrollView(.vertical, showsIndicators: true) {
VStack(spacing: .zero) {
ZStack {
topIndicatorView()
.scaleEffect(0.6 + (viewModel.progress * 0.4)) // 당길수록 커짐
}
.frame(height: indicatorViewHeight * viewModel.progress) // 당긴 비율만큼 높이 증가
.opacity(viewModel.progress) // 점점 나타남
.offset(y: viewModel
.isEligible ? -max(0, viewModel.contentOffset) :
-max(0, viewModel.scrollOffset)
)
content()
if viewModel.isLoadingNextPage {
TPLIndicatorView()
}
}
}
// 2. 상단 오프셋 및 Progress 감시 (기본 offset 연산 대체)
.onScrollGeometryChange(for: CGFloat.self) { geo in
return -geo.contentOffset.y - geo.contentInsets.top // 현재 스크롤 Y 위치(-를 붙혀 양수로) - inset을 빼줘야 progress와 매피이됨
} action: { _, offsetY in
let scrollY = offsetY // 당길 때 양수가 되도록 반전
viewModel.contentOffset = scrollY
if !viewModel.isEligible {
let progress = (scrollY / indicatorViewHeight).clamped(to: 0 ... 1)
viewModel.scrollOffset = scrollY
viewModel.progress = progress
print("🍯 \(scrollY) \(progress)")
}
// 드래그 중 임계치를 넘었을 때 처리 (제스처와 연동)
if viewModel.isEligible && !viewModel.isRefreshing {
viewModel.isRefreshing = true
}
}
// 3. 바닥 도달 감시 (checkIfReachedBottom 및 maxYoffset 대체)
.onScrollGeometryChange(for: Bool.self) { geo in
// 전체 콘텐츠 높이
let totalContentHeight = geo.contentSize.height
// 현재 보이는 영역 하단의 위치 (오프셋 + 화면 높이)
let currentBottom = geo.contentOffset.y + geo.containerSize.height
let threshold: CGFloat = 50
return currentBottom >= (totalContentHeight - threshold)
} action: { wasBottom, isBottom in
// 바닥에 도달한 순간(false -> true)에만 실행
if isBottom && !wasBottom {
if showBottomIndicator && !viewModel.isLoadingNextPage && onLoadMore != nil {
viewModel.isLoadingNextPage = true
}
}
}
.onAppear(perform: viewModel.addGesture)
.onDisappear(perform: viewModel.removeGesture)
.onChange(of: viewModel.isRefreshing) { _, value in
if value {
Task {
await onRefresh()
withAnimation(.easeIn) {
viewModel.reset()
}
}
}
}
.onChange(of: viewModel.isLoadingNextPage) { _, value in
if value {
Task {
await onLoadMore?()
viewModel.isLoadingNextPage = false
}
}
}
}
}
#Preview {
@Previewable @State var items: [Int] = Array(0 ..< 100)
RefreshableView(
showBottomIndicator: true,
content: {
VStack {
ForEach(items, id: \.self) {
Text("\($0)")
}
}
},
onRefresh: {
try? await Task.sleep(nanoseconds: 3_000_000_000)
},
onLoadMore: {
try? await Task.sleep(nanoseconds: 3_000_000_000)
items.append(contentsOf: Array(100 ..< 120))
}
)
}
😀 소감 및 마무리
너무 깔끔해졌다...
출처
https://developer.apple.com/documentation/swiftui/view/onscrollgeometrychange(for:of:action:)
반응형
'iOS > SwiftUI' 카테고리의 다른 글
| [WWDC2024] Create custom visual effects with SwiftUI (0) | 2025.11.19 |
|---|---|
| PhotoPicker (0) | 2025.11.02 |
| [WWDC 2023] Wind your way through advanced animations in SwiftUI (0) | 2025.10.30 |
| [WWDC2023] Explore SwiftUI animation (0) | 2025.10.29 |
| 커스텀 DynamicScrollTabVIew 만들기 (0) | 2025.10.26 |