참고 : Claude-Code 기반 구현 및 내용 정리

SwiftUI의
.refreshable은 기본 스피너만 지원한다. Lottie 애니메이션으로 대체하려면 UIKit 레이어까지 내려가야 한다. 이 글은 그 과정에서 맞닥뜨린 문제들과 최종 해결책을 정리한다.
.refreshable을 쓰지 않는가SwiftUI의 .refreshable(action:) modifier는 UIRefreshControl을 래핑한 것으로, UI 커스터마이징이 불가능하다. 로딩 애니메이션을 Lottie로 통일한 디자인 시스템에서는 사용할 수 없다.
CustomRefreshableModifier ← View modifier (public API)
└─ PullToRefreshScrollView ← UIViewRepresentable (anchor 역할)
└─ PullToRefreshAnchorView ← UIView (ScrollView 탐색)
└─ PullToRefreshCoordinator ← NSObject (핵심 로직)
└─ PullRefreshAnimationView ← Lottie 뷰
ScrollView {
// content
}
.customRefreshable {
await viewModel.fetchData()
}
SwiftUI의 ScrollView는 내부적으로 UIScrollView를 사용하지만 직접 접근할 방법을 제공하지 않는다. UIViewRepresentable로 만든 빈 뷰를 .background에 심고, 뷰 계층을 타고 올라가서 UIScrollView를 찾는다.
// CustomRefreshableModifier.swift
private struct CustomRefreshableModifier: ViewModifier {
func body(content: Content) -> some View {
content
.background(
PullToRefreshScrollView(...)
.frame(width: 0, height: 0) // 화면에 보이지 않는 anchor
)
}
}
SwiftUI가 UIViewRepresentable을 배치하는 방식에 따라 두 가지 경우가 있다.
Case 1: modifier가 ScrollView 내부 콘텐츠에 붙은 경우
UIScrollView
└─ UIKitPlatformViewHost
└─ PullToRefreshAnchorView ← 우리 뷰
→ superview를 타고 올라가면 UIScrollView를 찾을 수 있다
Case 2: modifier가 ScrollView 자체 또는 상위에 붙은 경우
UIKitPlatformViewHost (grandParent)
├─ UIKitPlatformViewHost (hostView) ← PullToRefreshAnchorView의 부모
│ └─ PullToRefreshAnchorView
└─ UIScrollView ← 형제 subtree에 있다
→ grandParent에서 hostView를 제외하고 탐색
private func findScrollView() {
// Case 1: ancestor traversal
var ancestor: UIView? = superview
while let view = ancestor {
if let sv = view as? UIScrollView {
coordinator?.attach(to: sv)
return
}
ancestor = view.superview
}
// Case 2: sibling subtree
if let hostView = superview,
let grandParent = hostView.superview,
let sv = firstScrollView(in: grandParent, excluding: hostView) {
coordinator?.attach(to: sv)
return
}
// 미발견 시 50ms 후 재시도 (SwiftUI 레이아웃이 아직 완성되지 않은 경우)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.window != nil else { return }
self.findScrollView()
}
}
UIScrollView를 찾으면 기존 delegate(SwiftUI 내부)를 보관하고, 우리 coordinator를 새 delegate로 교체한다. 모든 delegate 메서드는 원본 delegate에게 forwarding해서 SwiftUI 동작을 유지한다.
func attach(to scrollView: UIScrollView) {
guard !attached else { return }
attached = true
self.scrollView = scrollView
originalDelegate = scrollView.delegate // SwiftUI 내부 delegate 보관
scrollView.delegate = self // 우리 coordinator로 교체
insertHeader(into: scrollView)
}
// 상태 머신
private enum RefreshState {
case idle
case pulling(CGFloat) // 당기는 중 (progress 0~1)
case triggered // threshold 초과, 손을 놓으면 새로고침
case refreshing // 새로고침 실행 중
case completing // 애니메이션으로 복귀 중
}
scrollViewDidScroll에서 pullDistance를 계산해 상태를 전이한다.
let pullDistance = -(scrollView.contentOffset.y + scrollView.adjustedContentInset.top)
let progress = min(pullDistance / threshold, 1.0)
state = progress >= 1.0 ? .triggered : .pulling(progress)
updateHeaderProgress(progress / 2) // Lottie scrubbing
손을 놓으면 scrollViewDidEndDragging에서 상태에 따라 분기한다.
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
defer { originalDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) }
switch state {
case .triggered:
startRefreshing(scrollView)
case .pulling:
state = .idle
updateHeaderProgress(0)
default:
break
}
}
UIScrollView의 content 좌표계에서 y = -threshold 위치에 헤더를 삽입한다. 사용자가 당기면 이 영역이 노출된다.
private func insertHeader(into scrollView: UIScrollView) {
let header = UIView(frame: CGRect(x: 0, y: -threshold, width: width, height: threshold))
let hc = UIHostingController(rootView: makeScrubbingView(progress: 0))
hc.view.frame = header.bounds
header.addSubview(hc.view)
scrollView.addSubview(header)
}
새로고침이 시작되면 로딩 UI가 보이는 위치에 스크롤을 고정해야 한다. 단순히 contentOffset을 한 번 설정하는 것만으로는 동작하지 않는다.
contentOffset = X가 안 먹히는가SwiftUI의 ScrollView는 내부적으로 scrollViewDidScroll을 통해 contentOffset 변화를 감지하고, 자신의 상태와 불일치하면 contentOffset을 다시 리셋한다.
단순 설정이 무력화되는 이유를 순서대로 보면:
[우리] contentOffset = -lockedOffsetY
[UIKit] scrollViewDidScroll 호출
[SwiftUI originalDelegate] scrollViewDidScroll 수신 → 내부 상태와 다름 → contentOffset 리셋
[우리] 다시 설정 → 반복
UIView.animate → 실패UIView.animate(withDuration: 0.25) {
scrollView.contentOffset.y = -(safeTop + threshold)
}
UIScrollView의 contentOffset은 UIView.animate 블록 안에서 설정해도 UIScrollView의 내부 물리 엔진이 이를 무시하고 자체 애니메이션으로 처리한다.
// 매 프레임마다 contentOffset 강제 설정
@objc private func tickDisplayLink() {
scrollView.setContentOffset(CGPoint(x: 0, y: -lockedOffsetY), animated: false)
}
고정 자체는 됐지만 심한 깜빡임이 발생했다. 원인은 scrollViewDidScroll forwarding이었다.
[CADisplayLink] contentOffset = -lockedOffsetY (60fps)
[UIKit] scrollViewDidScroll 호출
[SwiftUI] scrollViewDidScroll 수신 → contentOffset 리셋 ← 이게 문제
[CADisplayLink] 다시 덮어씀
→ 60fps 피드백 루프 → 깜빡임
핵심 인사이트: SwiftUI가 scrollViewDidScroll을 통해서만 contentOffset 변화를 감지한다면, 해당 구간에서 forwarding만 차단하면 피드백 루프가 끊긴다.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
switch state {
case .refreshing:
// SwiftUI에게 forwarding 차단 → 리셋 피드백 루프 방지
// 이탈 시에만 교정 (60fps 폴링 아님)
guard !isCorrectingOffset else { return }
let target = -lockedOffsetY
if abs(scrollView.contentOffset.y - target) > 0.5 {
isCorrectingOffset = true
scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: target), animated: false)
isCorrectingOffset = false
}
return
case .completing:
return // 복귀 애니메이션 중에도 SwiftUI 간섭 차단
default:
break
}
defer { originalDelegate?.scrollViewDidScroll?(scrollView) }
// ... 일반 당김 감지 로직
}
isCorrectingOffset 플래그는 교정 중 발생하는 재귀 호출을 막는다.
private func startRefreshing(_ scrollView: UIScrollView) {
state = .refreshing
updateHeaderToLooping() // Lottie → looping 모드
let safeTop = scrollView.adjustedContentInset.top - scrollView.contentInset.top
lockedOffsetY = safeTop + threshold
scrollView.contentInset.top = threshold // 헤더 공간 확보
scrollView.contentOffset.y = -lockedOffsetY // 헤더 위치에 고정
scrollView.isScrollEnabled = false // 사용자 스크롤 차단
lockScrollView = scrollView // strong ref (weak ref 소실 방지)
let currentAction = action
Task { [weak self] in
await currentAction()
await MainActor.run {
guard let self, let sv = self.lockScrollView else { return }
self.endRefreshing(sv)
}
}
}
private func endRefreshing(_ scrollView: UIScrollView) {
state = .completing
scrollView.isScrollEnabled = true
// .completing 상태에서는 scrollViewDidScroll forwarding이 차단되므로
// SwiftUI가 개입하지 않아 UIView.animate가 정상 동작한다
let safeTop = scrollView.adjustedContentInset.top - scrollView.contentInset.top
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) {
scrollView.contentInset.top = 0
scrollView.contentOffset.y = -safeTop
} completion: { _ in
self.lockScrollView = nil
self.resetHeader()
self.state = .idle
}
}
self.scrollView는 weak var다. await currentAction() 완료 시점에 SwiftUI가 리렌더링하면서 UIScrollView가 교체될 경우 self.scrollView가 nil이 될 수 있다. lockScrollView(strong)로 보관해두면 Task completion까지 안전하게 참조할 수 있다.
당기는 동안은 progress에 따라 scrubbing, 새로고침 중에는 looping 모드로 전환한다.
// PullRefreshAnimationView
public enum Mode {
case scrubbing(CGFloat) // 0.0 ~ 1.0: 당기는 정도에 따라 프레임 고정
case looping // 새로고침 중 무한 재생
}
// 당기는 중: progress의 절반만 scrubbing (절반 지점에서 시각적으로 완성된 느낌)
updateHeaderProgress(progress / 2)
// threshold 도달 후 손을 놓으면
updateHeaderToLooping() // looping 전환
customRefreshable(action:)
↓
CustomRefreshableModifier
.background(PullToRefreshScrollView) // 0x0 anchor 뷰
↓
PullToRefreshAnchorView.didMoveToWindow()
→ findScrollView() // ancestor / sibling subtree 탐색
↓
PullToRefreshCoordinator.attach(to: UIScrollView)
→ originalDelegate 저장
→ scrollView.delegate = self
→ insertHeader() // y = -threshold 위치에 Lottie 헤더 삽입
↓
scrollViewDidScroll()
→ pullDistance 계산 → state 전이
→ .refreshing/.completing 구간: forwarding 차단 + 이탈 교정
↓
scrollViewDidEndDragging()
→ .triggered → startRefreshing()
→ contentInset.top = threshold
→ contentOffset.y = -(safeTop + threshold)
→ isScrollEnabled = false
→ await action()
→ endRefreshing()
→ UIView.animate(contentInset → 0, contentOffset → -safeTop)
→ state = .idle
| 문제 | 원인 | 해결 |
|---|---|---|
contentOffset 설정이 무시됨 | SwiftUI가 scrollViewDidScroll 수신 후 리셋 | forwarding 차단 |
| CADisplayLink 사용 시 깜빡임 | 60fps 피드백 루프 | 이벤트 기반 교정으로 교체 |
UIView.animate가 안 먹힘 | SwiftUI 간섭 | forwarding 차단 후 정상 동작 |
| Task 완료 시점에 scrollView nil | SwiftUI 리렌더링으로 weak ref 소실 | lockScrollView strong ref 보관 |
defer의 함정 | early return 해도 defer는 실행됨 | defer를 early return 이후로 이동 |
ScrollView의 UIScrollView는 iOS 버전마다 내부 구조가 다를 수 있다. 특히 UIKitPlatformViewHost 래핑 방식이 변경될 경우 findScrollView()의 Case 2 탐색이 영향을 받을 수 있다.scrollViewDidScroll forwarding을 차단하는 구간(.refreshing, .completing) 동안에는 ScrollViewReader.scrollTo, 스크롤 인디케이터 위치 등 SwiftUI 내부 스크롤 추적이 일시 중단된다. 새로고침 완료 후 첫 scroll 이벤트에서 자동으로 동기화된다.