- Published on
Implementing Pull-to-Refresh Without List in SwiftUI
- Authors

- Name
- Mick MacCallum
- @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:
- UIScrollView: Uses the real UIKit scroll view for consistent behavior
- UIRefreshControl: Apple's native refresh control with all animations
- UIHostingController: Embeds your SwiftUI content inside the scroll view
- Coordinator: Handles the refresh action and manages async state
- 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.
Continue Learning
Detect Successful Share Sheet Completion in SwiftUI
Learn how to detect when a user successfully shares content using a share sheet in SwiftUI by wrapping UIActivityViewController.
Adding an App Delegate to a SwiftUI App
Learn how to integrate UIKit's App Delegate into your SwiftUI app for handling app lifecycle events and system callbacks.
Preventing Screenshot Capture in SwiftUI Views
Learn how to prevent users from taking screenshots of sensitive content in your SwiftUI app using field-level security and UIKit bridging for financial, medical, or private data protection.
