SwiftUI에서 Lottie 커스텀 Pull-to-Refresh 구현하기

Minsang Kang·2026년 6월 1일

iOS Develop

목록 보기
14/14

참고 : 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에 어떻게 접근하는가

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
            )
    }
}

UIScrollView 탐색 전략

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()
    }
}

Delegate 가로채기

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 리셋
[우리]  다시 설정 → 반복

시도 1: UIView.animate → 실패

UIView.animate(withDuration: 0.25) {
    scrollView.contentOffset.y = -(safeTop + threshold)
}

UIScrollViewcontentOffsetUIView.animate 블록 안에서 설정해도 UIScrollView의 내부 물리 엔진이 이를 무시하고 자체 애니메이션으로 처리한다.

시도 2: CADisplayLink로 매 프레임 강제 고정 → 깜빡임 발생

// 매 프레임마다 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 피드백 루프 → 깜빡임

최종 해결: 이벤트 기반 교정 + forwarding 차단

핵심 인사이트: 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 플래그는 교정 중 발생하는 재귀 호출을 막는다.


startRefreshing / endRefreshing

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
    }
}

lockScrollView가 필요한 이유

self.scrollViewweak var다. await currentAction() 완료 시점에 SwiftUI가 리렌더링하면서 UIScrollView가 교체될 경우 self.scrollViewnil이 될 수 있다. lockScrollView(strong)로 보관해두면 Task completion까지 안전하게 참조할 수 있다.


Lottie 애니메이션 연동

당기는 동안은 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 nilSwiftUI 리렌더링으로 weak ref 소실lockScrollView strong ref 보관
defer의 함정early return 해도 defer는 실행됨defer를 early return 이후로 이동

참고

  • SwiftUI ScrollView의 UIScrollView는 iOS 버전마다 내부 구조가 다를 수 있다. 특히 UIKitPlatformViewHost 래핑 방식이 변경될 경우 findScrollView()의 Case 2 탐색이 영향을 받을 수 있다.
  • scrollViewDidScroll forwarding을 차단하는 구간(.refreshing, .completing) 동안에는 ScrollViewReader.scrollTo, 스크롤 인디케이터 위치 등 SwiftUI 내부 스크롤 추적이 일시 중단된다. 새로고침 완료 후 첫 scroll 이벤트에서 자동으로 동기화된다.
profile
 iOS Developer

0개의 댓글