A Custom Pull-to-Refresh in SwiftUI


Greetings, traveler!

SwiftUI provides a built-in .refreshable modifier, which covers the majority of use cases. At some point, product requirements start to demand more control over the refresh interaction. Custom animations, branded indicators, precise control over gesture thresholds, and coordination with complex layouts are typical examples. In these cases, a custom pull-to-refresh component becomes a practical solution.

This article walks through the design and implementation of a reusable PullToRefreshScrollView. The goal is to expose the refresh lifecycle to the caller and allow full control over the indicator view, while keeping the interaction predictable and close to native behavior.

When a Custom Solution Makes Sense

A custom pull-to-refresh is useful when the default API does not provide enough flexibility. Common scenarios include:

  • A branded or animated refresh indicator
  • Synchronization with other layout elements
  • Fine-tuned gesture thresholds and animation timing
  • Embedding the indicator into scroll content instead of overlaying it
  • Supporting intermediate states such as pull progress or finishing animation

The component described here focuses on exposing the refresh lifecycle as state and letting the caller render UI accordingly.

Modeling the Refresh Lifecycle

The core idea is to represent the interaction as a state machine. Instead of hiding internal transitions, the component exposes them through a public enum.

public enum PullToRefreshScrollViewState: Equatable {
    case idle
    case pulling(progress: CGFloat)
    case armed(progress: CGFloat)
    case refreshing
    case finishing(progress: CGFloat)
}

This model allows the caller to render different visuals depending on the phase of the interaction. The progress values are normalized to the 0...1 range, which simplifies animation logic.

Component Structure

The main component is a generic View that accepts two builders: one for the refresh indicator and one for the scrollable content.

public struct PullToRefreshScrollView<RefreshContent: View, Content: View>: View {
    private var threshold: CGFloat = 96
    private var refreshViewHeight: CGFloat = 80
    private var showsIndicators: Bool = true
    private var isEnabled: Bool = true
    private var animationDuration: Duration = .milliseconds(350)

    private let onRefresh: () -> Void
    private let refreshContent: (PullToRefreshScrollViewState) -> RefreshContent
    private let content: () -> Content

    @Binding private var isRefreshing: Bool

    @State private var scrollPhase: ScrollPhase = .idle
    @State private var pullDistance: CGFloat = .zero
    @State private var displayProgress: CGFloat = .zero
    @State private var isArmed = false
    @State private var isFinishing = false
    @State private var finishTask: Task<Void, Never>?
}

The component manages gesture tracking and animation, while delegating rendering to the caller.

Layout and Indicator Placement

The indicator is part of the scroll content. This approach ensures that it behaves naturally when scrolling and remains visible during refresh.

ScrollView {
    VStack(spacing: 0) {
        refreshContent(currentState)
            .frame(height: indicatorVisibleHeight, alignment: .bottom)
            .frame(height: effectiveRefreshViewHeight, alignment: .bottom)
            .frame(maxWidth: .infinity)
            .clipped()
            .allowsHitTesting(false)

        content()
    }
    .padding(.top, contentTopInset)
}

The indicator is initially hidden using a negative top inset. During refresh, the inset is removed so the indicator remains visible.

Tracking Pull Gesture

The component relies on scroll geometry to detect overscroll.

.onScrollGeometryChange(for: CGFloat.self) { geometry in
    geometry.contentOffset.y + geometry.contentInsets.top
} action: { _, offset in
    handleScrollOffsetChange(offset)
}

The handler computes overscroll and maps it to a normalized progress value.

let overscroll = max(-offset, 0)
let progress = min(overscroll / threshold, 1)
let isNowArmed = progress >= 1

This data drives the internal state and is later exposed to the indicator.

Triggering Refresh

Refresh is triggered when the user releases the gesture after crossing the threshold.

.onScrollPhaseChange { oldPhase, newPhase in
    if oldPhase == .interacting,
       newPhase != .interacting,
       isArmed,
       !isRefreshing {
        triggerRefresh()
    }
}

The triggerRefresh method updates internal state and notifies the caller.

private func triggerRefresh() {
    guard !isRefreshing else { return }

    isArmed = false
    isFinishing = false
    pullDistance = refreshViewHeight
    displayProgress = 1

    isRefreshing = true
    onRefresh()
}

The component sets isRefreshing to true, while the caller is responsible for setting it back to false once loading finishes.

Finishing Animation

When isRefreshing becomes false, the component starts a finishing animation that gradually hides the indicator.

private func startFinishingAnimation() {
    isFinishing = true
    displayProgress = 1

    finishTask = Task { @MainActor in
        let frameDelay: Duration = .milliseconds(16)
        let step = frameDelay.timeInterval / animationDuration.timeInterval

        while displayProgress > 0 {
            try? await Task.sleep(for: frameDelay)
            displayProgress = max(displayProgress - step, 0)
        }

        reset()
    }
}

This explicit control over progress allows the indicator to animate smoothly while preserving access to intermediate values.

Custom Indicator Example

The indicator can react to the state and render different visuals.

private struct DemoRefreshHeader: View {
    let state: PullToRefreshScrollViewState

    @State private var spinningRotation: Double = .zero
    private let indicatorSize: CGFloat = 40

    var body: some View {
        Circle()
            .trim(from: 0, to: arcProgress)
            .stroke(
                AngularGradient(
                    colors: [.cyan, .mint, .white, .cyan],
                    center: .center
                ),
                style: .init(lineWidth: 8, lineCap: .round)
            )
            .frame(width: indicatorSize, height: indicatorSize)
            .rotationEffect(.degrees(rotation))
            .frame(maxWidth: .infinity)
            .padding(.vertical, 10)
            .drawingGroup()
            .accessibilityHidden(true)
            .onAppear {
                updateAnimationState()
            }
            .onChange(of: state) { _, _ in
                updateAnimationState()
            }
    }

    private var progress: CGFloat {
        switch state {
        case .idle:
            0
        case .pulling(let progress), .armed(let progress):
            progress
        case .refreshing:
            1
        case .finishing(let progress):
            progress
        }
    }

    private var arcProgress: CGFloat {
        switch state {
        case .refreshing:
            0.999
        default:
            progress
        }
    }

    private var rotation: Double {
        switch state {
        case .idle:
            0
        case .pulling(let progress):
            Double(progress) * 150
        case .armed:
            180
        case .refreshing:
            spinningRotation
        case .finishing(let progress):
            Double(progress) * 360
        }
    }

    private func updateAnimationState() {
        guard state == .refreshing else {
            spinningRotation = 0
            return
        }

        spinningRotation = 0

        Task { @MainActor in
            withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: false)) {
                spinningRotation = 360
            }
        }
    }
}

This example demonstrates how the indicator can evolve through the interaction phases without any knowledge of gesture handling.

Putting It All Together

The following demo shows how the component is used in practice.

private struct PullToRefreshDemoView: View {
    @State private var items = Array(1...16).map { "Row \($0)" }
    @State private var isRefreshing = false

    var body: some View {
        PullToRefreshScrollView(
            isRefreshing: $isRefreshing,
            onRefresh: reload
        ) { state in
            DemoRefreshHeader(state: state)
        } content: {
            LazyVStack(spacing: 14) {
                ForEach(items, id: \.self) { item in
                    RoundedRectangle(cornerRadius: 24)
                        .fill(.white.opacity(0.14))
                        .frame(height: 78)
                        .overlay(alignment: .leading) {
                            Text(item)
                                .font(.headline)
                                .foregroundStyle(.white)
                                .padding(.horizontal, 20)
                        }
                }
            }
            .padding(.horizontal, 20)
            .padding(.vertical, 18)
        }
        .threshold(110)
        .refreshViewHeight(88)
        .showsIndicators(false)
        .background(
            LinearGradient(
                colors: [
                    Color(red: 0.05, green: 0.08, blue: 0.16),
                    Color(red: 0.10, green: 0.22, blue: 0.35),
                    Color(red: 0.03, green: 0.10, blue: 0.15)
                ],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        )
    }

    private func reload() {
        Task {
            try? await Task.sleep(for: .seconds(2))
            await MainActor.run {
                items.insert("Updated \(Date.now.formatted(date: .omitted, time: .standard))", at: 0)
                isRefreshing = false
            }
        }
    }
}

This example highlights the intended usage pattern:

  • The component controls gesture detection and lifecycle.
  • The caller provides UI and handles data loading.
  • State flows through isRefreshing.

Conclusion

A custom pull-to-refresh component introduces more control over interaction and presentation. The approach described here keeps responsibilities clearly separated: gesture handling and lifecycle management live inside the component, while rendering remains fully customizable.

Available on my GitHub.