SwiftUI Pull To Refresh Component

A pull-to-refresh indicator you can brand, unlike .refreshable's fixed system spinner.

Install the free SwiftUI pull to refresh

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKPullToRefresh.swift below into your project. It needs nothing else.

Usage

Usage.swift
ScrollView { ... }.skPullToRefresh { await reload() }

Full source · 177 lines · MIT

SKPullToRefresh.swift
//
//  SKPullToRefresh.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//  Requires iOS 18 / macOS 15 — see the note below. Every other component in
//  the kit is iOS 17.
//
//  Pull-to-refresh you can brand, unlike `.refreshable`'s fixed system spinner.
//  The arc fills in proportion to the pull and ticks once at the threshold, so
//  the commit point is felt rather than watched.
//
//  WHY iOS 18, AND WHY NOT A GeometryReader
//  ----------------------------------------
//  The classic trick — a `GeometryReader` in the scroll content publishing its
//  `minY` through a `PreferenceKey` — DOES NOT WORK under the modern scroll
//  system. Instrumented on iOS 26.2 with a counter: through a full drag and a
//  swipe, with the list demonstrably scrolling, the preference fired exactly
//  ONCE, at first layout. A GeometryReader is not re-evaluated for pure
//  translation, only for size changes. Named and `.global` coordinate spaces
//  both behave the same way.
//
//  `onScrollGeometryChange` is the API that actually reports offset, and it is
//  iOS 18+. Rather than ship a component that silently does nothing, this one
//  carries a higher floor and says so.
//
//  WHY IT OWNS THE ScrollView
//  --------------------------
//  Reading a scroll view's geometry requires being the scroll view. A modifier
//  applied to content already inside one cannot reach it.
//
//  Usage:
//      SKRefreshableScrollView {
//          await reload()
//      } content: {
//          ForEach(items) { ... }
//      }
//

import SwiftUI

@available(iOS 18.0, macOS 15.0, *)
public struct SKRefreshableScrollView<Content: View>: View {
    private let threshold: CGFloat
    private let showsIndicators: Bool
    private let action: () async -> Void
    private let content: Content

    @State private var pull: CGFloat = 0
    @State private var isRefreshing = false
    /// Set once the pull passes the threshold; the refresh fires when the pull
    /// then collapses, which is the release.
    @State private var armed = false
    @State private var spin = false

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    public init(
        threshold: CGFloat = 72,
        showsIndicators: Bool = false,
        action: @escaping () async -> Void,
        @ViewBuilder content: () -> Content
    ) {
        self.threshold = threshold
        self.showsIndicators = showsIndicators
        self.action = action
        self.content = content()
    }

    private var progress: CGFloat { min(max(pull / threshold, 0), 1) }

    public var body: some View {
        ScrollView(.vertical, showsIndicators: showsIndicators) {
            content
                // Holds the content down while refreshing so the spinner has
                // somewhere to sit instead of overlapping the first row.
                .padding(.top, isRefreshing ? threshold : 0)
        }
        // A list shorter than the viewport would otherwise never bounce, and an
        // empty list is exactly when people pull hardest.
        .scrollBounceBehavior(.always, axes: .vertical)
        .onScrollGeometryChange(for: CGFloat.self) { geometry in
            // Distance overscrolled past the top. Positive while pulling down.
            -(geometry.contentOffset.y + geometry.contentInsets.top)
        } action: { _, distance in
            handle(distance)
        }
        .overlay(alignment: .top) { indicator }
        .skAnimation(.settle, value: isRefreshing)
    }

    @ViewBuilder
    private var indicator: some View {
        Group {
            if isRefreshing {
                Circle()
                    .trim(from: 0, to: 0.26)
                    .stroke(SK.Palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
                    .rotationEffect(.degrees(spin ? 360 : 0))
                    .onAppear {
                        guard !reduceMotion else { return }
                        withAnimation(.linear(duration: 0.85).repeatForever(autoreverses: false)) {
                            spin = true
                        }
                    }
                    .onDisappear { spin = false }
            } else if progress > 0.02 {
                Circle()
                    .trim(from: 0, to: progress)
                    .stroke(SK.Palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
                    .rotationEffect(.degrees(-90))
                    .scaleEffect(0.55 + progress * 0.45)
                    .opacity(Double(progress))
            }
        }
        .frame(width: 26, height: 26)
        // Rides down with the finger, then parks at the threshold.
        .offset(y: (isRefreshing ? threshold : min(pull, threshold)) / 2 - 13)
        .accessibilityHidden(true)
    }

    private func handle(_ distance: CGFloat) {
        guard !isRefreshing else { return }
        pull = max(0, distance)

        if pull >= threshold, !armed {
            armed = true
            SK.Haptic.play(.medium)
        }
        // Armed, then collapsing: the finger let go.
        if armed, pull < threshold * 0.55 {
            armed = false
            start()
        }
    }

    private func start() {
        guard !isRefreshing else { return }
        withAnimation(SKMotion.settle.resolved(reduceMotion: reduceMotion)) {
            isRefreshing = true
        }
        Task { @MainActor in
            await action()
            withAnimation(SKMotion.settle.resolved(reduceMotion: reduceMotion)) {
                isRefreshing = false
                pull = 0
            }
        }
    }
}

@available(iOS 18.0, macOS 15.0, *)
#Preview("SKPullToRefresh") {
    @Previewable @State var items = Array(1...14)

    SKRefreshableScrollView {
        try? await Task.sleep(for: .seconds(1.2))
        items = items.map { $0 + 14 }
    } content: {
        VStack(spacing: SK.Space.sm) {
            Text("Pull down to refresh")
                .font(SK.Type_.caption)
                .foregroundStyle(SK.Palette.labelTertiary)
            ForEach(items, id: \.self) { i in
                Text("Item \(i)")
                    .font(SK.Type_.body)
                    .foregroundStyle(SK.Palette.label)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(SK.Space.md)
                    .skSurface(.bordered, radius: SK.Radius.md)
            }
        }
        .padding(SK.Space.lg)
    }
    .background(SK.Palette.surfaceSunken)
}

Questions

Is this SwiftUI pull to refresh free for commercial apps?
Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
What does it depend on?
SKPullToRefresh.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
Which iOS versions does it support?
This component needs iOS 18, because it reads scroll geometry with onScrollGeometryChange. The rest of the library supports iOS 17 and macOS 14.

Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.