avatar
Published on

Implementing Pull-to-Refresh Without List in SwiftUI

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @0x7fs

SwiftUI's .refreshable() modifier works great with List, but what if you need pull-to-refresh on a custom ScrollView? Maybe you're building a dashboard with cards, a photo grid, or any layout that doesn't fit the List paradigm.

While .refreshable() technically works on ScrollView, it has inconsistent behavior and often doesn't trigger properly. The solution is to bridge to UIKit's battle-tested UIRefreshControl and wrap it in a clean SwiftUI interface.

The Problem with ScrollView + Refreshable

You might try this approach:

ScrollView {
    VStack {
        // Your content
    }
}
.refreshable {
    await refreshData()
}

However, this often fails to show the refresh control or doesn't trigger consistently. The SwiftUI implementation expects specific scroll behaviors that ScrollView doesn't always provide.

Solution: UIRefreshControl Bridge

We'll create a RefreshableScrollView that properly integrates UIKit's refresh control:

import SwiftUI
import UIKit

struct RefreshableScrollView<Content: View>: View {
    let axes: Axis.Set
    let showsIndicators: Bool
    let onRefresh: () async -> Void
    let content: Content

    @State private var isRefreshing = false

    init(
        _ axes: Axis.Set = .vertical,
        showsIndicators: Bool = true,
        onRefresh: @escaping () async -> Void,
        @ViewBuilder content: () -> Content
    ) {
        self.axes = axes
        self.showsIndicators = showsIndicators
        self.onRefresh = onRefresh
        self.content = content()
    }

    var body: some View {
        RefreshableScrollViewRepresentable(
            axes: axes,
            showsIndicators: showsIndicators,
            isRefreshing: $isRefreshing,
            onRefresh: onRefresh,
            content: content
        )
    }
}

struct RefreshableScrollViewRepresentable<Content: View>: UIViewRepresentable {
    let axes: Axis.Set
    let showsIndicators: Bool
    @Binding var isRefreshing: Bool
    let onRefresh: () async -> Void
    let content: Content

    func makeCoordinator() -> Coordinator {
        Coordinator(
            isRefreshing: $isRefreshing,
            onRefresh: onRefresh
        )
    }

    func makeUIView(context: Context) -> UIScrollView {
        let scrollView = UIScrollView()

        // Configure scroll indicators
        scrollView.showsVerticalScrollIndicator = axes.contains(.vertical) && showsIndicators
        scrollView.showsHorizontalScrollIndicator = axes.contains(.horizontal) && showsIndicators

        // Add refresh control
        let refreshControl = UIRefreshControl()
        refreshControl.addTarget(
            context.coordinator,
            action: #selector(Coordinator.handleRefresh),
            for: .valueChanged
        )
        scrollView.refreshControl = refreshControl
        context.coordinator.refreshControl = refreshControl

        // Add SwiftUI content
        let hostingController = UIHostingController(rootView: content)
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        hostingController.view.backgroundColor = .clear

        scrollView.addSubview(hostingController.view)

        // Setup constraints
        let contentLayoutGuide = scrollView.contentLayoutGuide
        let frameLayoutGuide = scrollView.frameLayoutGuide

        NSLayoutConstraint.activate([
            hostingController.view.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor),
            hostingController.view.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),

            // Width constraint for vertical scrolling
            hostingController.view.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor)
        ])

        return scrollView
    }

    func updateUIView(_ scrollView: UIScrollView, context: Context) {
        // Update refresh control state
        if isRefreshing {
            scrollView.refreshControl?.beginRefreshing()
        } else {
            scrollView.refreshControl?.endRefreshing()
        }
    }

    class Coordinator {
        @Binding var isRefreshing: Bool
        let onRefresh: () async -> Void
        weak var refreshControl: UIRefreshControl?

        init(isRefreshing: Binding<Bool>, onRefresh: @escaping () async -> Void) {
            self._isRefreshing = isRefreshing
            self.onRefresh = onRefresh
        }

        @objc func handleRefresh() {
            guard !isRefreshing else { return }

            isRefreshing = true

            Task { @MainActor in
                await onRefresh()
                isRefreshing = false
            }
        }
    }
}

How It Works

This solution creates a proper UIKit scroll view with native pull-to-refresh:

  1. UIScrollView: Uses the real UIKit scroll view for consistent behavior
  2. UIRefreshControl: Apple's native refresh control with all animations
  3. UIHostingController: Embeds your SwiftUI content inside the scroll view
  4. Coordinator: Handles the refresh action and manages async state
  5. Binding: Updates UI when refresh completes

The key advantage is that UIRefreshControl handles all the edge cases: touch gestures, scroll physics, animation timing, and accessibility.

Basic Usage

Use it just like a regular ScrollView:

struct ContentView: View {
    @State private var items = ["Item 1", "Item 2", "Item 3"]
    @State private var lastUpdated = Date()

    var body: some View {
        RefreshableScrollView {
            await loadData()
        } content: {
            VStack(spacing: 20) {
                Text("Last updated: \(lastUpdated.formatted())")
                    .font(.caption)
                    .foregroundColor(.secondary)

                ForEach(items, id: \.self) { item in
                    CardView(title: item)
                }
            }
            .padding()
        }
    }

    func loadData() async {
        // Simulate network request
        try? await Task.sleep(nanoseconds: 2_000_000_000)

        // Update data
        items.append("Item \(items.count + 1)")
        lastUpdated = Date()
    }
}

struct CardView: View {
    let title: String

    var body: some View {
        VStack(alignment: .leading) {
            Text(title)
                .font(.headline)
            Text("Content for \(title)")
                .font(.subheadline)
                .foregroundColor(.secondary)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(12)
    }
}

Complex Layouts

The beauty of this approach is it works with any SwiftUI layout:

struct DashboardView: View {
    var body: some View {
        RefreshableScrollView {
            await refreshDashboard()
        } content: {
            VStack(spacing: 24) {
                // Hero card
                HStack {
                    VStack(alignment: .leading) {
                        Text("Revenue")
                            .font(.subheadline)
                        Text("$45,231")
                            .font(.title)
                            .bold()
                    }
                    Spacer()
                    Image(systemName: "chart.line.uptrend.xyaxis")
                        .font(.largeTitle)
                        .foregroundColor(.green)
                }
                .padding()
                .background(Color.green.opacity(0.1))
                .cornerRadius(16)

                // Grid of metrics
                LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
                    MetricCard(title: "Users", value: "1,234")
                    MetricCard(title: "Orders", value: "567")
                    MetricCard(title: "Revenue", value: "$8.9k")
                    MetricCard(title: "Growth", value: "+12%")
                }

                // Recent activity
                VStack(alignment: .leading, spacing: 12) {
                    Text("Recent Activity")
                        .font(.headline)

                    ForEach(0..<5) { index in
                        ActivityRow(index: index)
                    }
                }
            }
            .padding()
        }
    }

    func refreshDashboard() async {
        try? await Task.sleep(nanoseconds: 1_500_000_000)
        // Refresh your data here
    }
}

Handling Errors

You can add error handling to your refresh action:

struct ErrorHandlingView: View {
    @State private var items: [String] = []
    @State private var errorMessage: String?

    var body: some View {
        VStack {
            if let error = errorMessage {
                Text(error)
                    .foregroundColor(.red)
                    .padding()
            }

            RefreshableScrollView {
                await loadItems()
            } content: {
                VStack(spacing: 16) {
                    ForEach(items, id: \.self) { item in
                        Text(item)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(Color.blue.opacity(0.1))
                            .cornerRadius(8)
                    }
                }
                .padding()
            }
        }
    }

    func loadItems() async {
        errorMessage = nil

        do {
            // Your async data loading
            let newItems = try await fetchItemsFromAPI()
            items = newItems
        } catch {
            errorMessage = "Failed to load: \(error.localizedDescription)"
        }
    }

    func fetchItemsFromAPI() async throws -> [String] {
        try await Task.sleep(nanoseconds: 2_000_000_000)
        return ["New Item 1", "New Item 2", "New Item 3"]
    }
}

Customizing the Refresh Control

You can extend the implementation to customize the refresh control's appearance:

// In makeUIView, after creating refreshControl:
refreshControl.tintColor = UIColor.systemBlue
refreshControl.attributedTitle = NSAttributedString(
    string: "Pull to refresh",
    attributes: [.foregroundColor: UIColor.systemBlue]
)

Programmatic Refresh

You can trigger refresh programmatically by modifying the implementation:

struct ProgrammaticRefreshView: View {
    @State private var shouldRefresh = false

    var body: some View {
        VStack {
            Button("Refresh Now") {
                shouldRefresh = true
            }
            .padding()

            RefreshableScrollView {
                await loadData()
            } content: {
                // Your content
            }
        }
    }

    func loadData() async {
        try? await Task.sleep(nanoseconds: 2_000_000_000)
        shouldRefresh = false
    }
}

Why This Approach Works Better

Compared to SwiftUI's .refreshable() on ScrollView:

  • Reliable: UIRefreshControl is battle-tested across millions of apps
  • Consistent: Same behavior users expect from native apps
  • Flexible: Works with any SwiftUI layout structure
  • Accessible: Full VoiceOver and accessibility support built-in
  • Customizable: Easy to style and configure

This solution gives you the best of both worlds: SwiftUI's declarative syntax with UIKit's proven refresh control implementation.