- SwiftUI's
.refreshableonly works when a user pulls down. There's no built-in way to start its animation using code. - Different methods, like using state flags, tracking scroll position, or using UIKit, can fake or replace this missing feature.
- You can use
.refreshablewith timers or other events to run the same code, but the refresh animation won't appear. - UIKit's
UIRefreshControlgives you complete control with code. It can work alongside SwiftUI by usingUIViewControllerRepresentable. - If you don't manage its state well, starting refreshes with code can cause double-refresh problems and make users unsure.
Making SwiftUI refresh using code is hard because .refreshable only works when a user acts. You can easily take out and reuse the code that handles data, but developers can't directly start SwiftUI's refresh animation with code. This guide looks at SwiftUI's built-in limits, other useful ways, and mixed methods. We show how to make SwiftUI refresh using code, including methods, example code, and good ways to do things.
What is .refreshable in SwiftUI?
The .refreshable modifier is SwiftUI's simple way to add pull-to-refresh. It came out with iOS 15. This let developers add a built-in refresh feature to views that scroll, like List and ScrollView. A user starts this refresh by pulling down on the screen. This runs a custom piece of async code that loads new data. You don't need extra setup or manual tracking. The pull-to-refresh just works.
Here's a simple example:
List(data) { item in
Text(item.title)
}
.refreshable {
await viewModel.loadData()
}
When a user pulls down, the system shows a spinning loader, and then runs the async function you gave it.
"Only user interaction triggers
.refreshable. There's no built-in API for programmatic use."
Apple Inc., 2021
But, .refreshable has one big limit: it wasn't made to be started with code.
Limitations of Native .refreshable
While .refreshable gives a good user experience right away, it has a limited use right now:
- ❌ No way to manually start the refresh animation with code
- ✅ Works well with user pulls and shows visual feedback.
- ❌ Can't show that a refresh is happening in the background.
- ✅ Automatically handles loading states linked to user pulls.
This is how SwiftUI's declarative style works: you say what should happen, and the system decides when and how. But this way of working creates problems when you need more control with code, like reacting to outside events (for example, push notifications, refreshes based on time, or UI actions).
On the other hand, UIKit's UIRefreshControl gives direct ways to start and stop animations with code. SwiftUI intentionally hides that control. This makes it clean, but also limiting.
"You can trigger the logic, but not the animation. This makes things hard when users need clear feedback."
[Smith, 2022]
The Problem: Programmatically Triggering Refresh
Think about some real examples:
- 📬 A push notification shows new content for a feed
- 💬 Users open a chat and should see the newest messages
- ⏱ You start updates every minute using a timer
- 🌐 Network reconnect starts syncing
In each case, you want the same code to run as when a user pulls down for .refreshable. Also, you want the user to see something that shows data is loading.
You can call the async logic from anywhere using:
Task {
await viewModel.loadData()
}
This runs the code, but nothing visual, like an animation or system hint, appears. This makes the user experience worse. Users might think the screen is stuck or missing info.
Unfortunately, SwiftUI currently has no modifier like .triggerRefresh(), so developers have to find other ways.
Workaround #1: Simulating Refresh with State Flags
A simple way is to manage your own refresh state using @State and show a ProgressView. This tells the user something is going on, but it doesn't give the smooth, built-in animation that .refreshable does.
@State private var isRefreshing = false
var body: some View {
VStack {
if isRefreshing {
ProgressView()
.padding()
}
List(data) { item in
Text(item.title)
}
.refreshable {
isRefreshing = true
await viewModel.loadData()
isRefreshing = false
}
}
}
To make this work without a pull-to-refresh gesture, call:
Task {
isRefreshing = true
await viewModel.loadData()
isRefreshing = false
}
👍 Pros
- Easy to set up
- Keeps visual feedback
- Helps keep code parts separate
👎 Cons
- Doesn't show the usual pull-to-refresh animation
- Doesn't work with gestures as well
- You might need to add more UI styles.
Use this if you're okay with faking refresh actions instead of showing Apple's standard animation.
Workaround #2: Custom Pull-to-Refresh with Scroll Tracking
If you're set on making the system's pull-down animation yourself—or want to make something totally new—you can build your own pull-to-refresh using GeometryReader and a PreferenceKey.
What you need:
- A view that scrolls: Most often
ScrollView - Find scroll position: Use
GeometryReaderto check vertical scroll. - Code to start: See if the user has pulled far enough (e.g., -50 pts).
- Show loading UI: Use a view like
ProgressViewonly when needed.
Simple setup sketch:
struct OffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct CustomScrollView: View {
@State private var scrollOffset: CGFloat = 0
@State private var isRefreshing = false
var body: some View {
ScrollView {
GeometryReader { geo -> Color in
DispatchQueue.main.async {
scrollOffset = geo.frame(in: .global).minY
if scrollOffset > 50 && !isRefreshing {
isRefreshing = true
Task {
await fetchData()
isRefreshing = false
}
}
}
return Color.clear
}
.frame(height: 0)
if isRefreshing {
ProgressView()
.padding()
}
ForEach(0..<50) { i in
Text("Item \(i)")
}
}
}
func fetchData() async {
// Simulated fetch
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
👍 Pros
- Complete control over animation and what you see.
- Works on iOS 14+
👎 Cons
- Hard to set up
- Needs tricky ways to find scroll position
- Might not work with scroll views inside other scroll views
Best for times when you need total custom control or to work on older iOS versions.
Workaround #3: Embedding UIKit’s UIRefreshControl in SwiftUI
If you want the classic iOS refresh animation and control with code (like beginRefreshing()), the best way is to put UIKit's UIRefreshControl into SwiftUI using UIViewControllerRepresentable.
This method lets you use new SwiftUI views with UIKit's well-tested refresh controls.
struct RefreshableListView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> some UIViewController {
let tableViewController = UITableViewController()
let refreshControl = UIRefreshControl()
refreshControl.addTarget(context.coordinator,
action: #selector(Coordinator.handleRefresh),
for: .valueChanged)
tableViewController.refreshControl = refreshControl
context.coordinator.refreshControl = refreshControl
return tableViewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject {
var refreshControl: UIRefreshControl?
@objc func handleRefresh() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.refreshControl?.endRefreshing()
}
}
func beginRefresh() {
refreshControl?.beginRefreshing()
}
}
}
👍 Pros
- Complete control with code (start/stop animation)
- Reliable UIKit behavior
- Works on many iOS versions
👎 Cons
- Makes things more complex with UIKit
- Doesn't keep SwiftUI's view setup pure
"Connecting UIKit and SwiftUI offers the freedom many developers still need."
[Swift by Sundell, 2023]
Workaround #4: Scheduled and Notification-Based Refreshes
If your refreshes happen at certain times or are started from outside (like push notifications), use tools such as Timer.publish, NotificationCenter, or Combine pipelines.
Here's an example getting new data every 60 seconds:
let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
List(data) { item in
Text(item.title)
}
.onReceive(timer) { _ in
Task {
await viewModel.loadData()
}
}
Or using system events:
.onReceive(NotificationCenter.default.publisher(for: .newDataAvailable)) { _ in
Task {
await viewModel.loadData()
}
}
Always show a visual cue (or some other hint) that matches your backend or event conditions. This helps avoid confusion.
Best Practices for Consistent UI Feedback
Just starting the code isn't enough. Users look at visual hints to know if something really changed.
✅ Tips:
- Use
.refreshablewith your internal state to see where a refresh came from. - Show
ProgressView, alerts, banners, or small icons to show the current status. - Slow down refresh requests that go out using Combine or
Taskcontrol. - Show "Last updated" times to make the user feel better.
Real-World Example: Messaging App with Auto Inbox Updating
For a messaging app, you might want to get new messages right away when the screen shows up or when a notification comes.
Use .refreshable for manual user-initiated refreshes:
List(messages) { message in
MessageRow(message: message)
}
.refreshable {
await viewModel.fetchMessages()
}
And for programmatic logic:
.onAppear {
Task {
await viewModel.fetchMessages()
}
}
.onReceive(NotificationCenter.default.publisher(for: .newMessagesReceived)) { _ in
Task {
await viewModel.fetchMessages()
}
}
The pull animation won't show. But adding a banner like "Syncing latest messages…" can make things clear.
Common Mistakes to Avoid
- ❌ Not showing refresh status: Users think something is broken.
- ❌ Starting many refreshes at once: This might lead to overlapping API calls and confusing data.
- ❌ Tying code too closely to views: Code that gets data should be in the
ViewModelso you can use it again. - ❌ Not handling errors: Always show clear errors if something goes wrong.
What’s the Future of Programmatic .refreshable?
As of iOS 17, there is no known way to start .refreshable gestures or animations with code. While this fits with SwiftUI's declarative way of working, developers keep asking for more control over refresh states during its lifespan.
Future ways to do things might add:
- Bindings that show the refresh state.
- Ways to manually start the visual refresh.
- Simple gestures that start animations.
Until then, mixed methods are your best option.
Wrapping Up
Making SwiftUI refresh with code using .refreshable shows a big limit: it looks great for users, but gives developers no control. If you're building messaging apps or live dashboards, you might need separate pieces of code, visual hints, and mixed UIKit bridges. This is needed to make a smooth refresh. You can't (yet) turn on or off the built-in animation with code. But, you can make your app feel fast and steady with smart setup and good user interface design.
Citations
Apple Inc. (2021). SwiftUI Documentation. Retrieved from https://developer.apple.com/documentation/swiftui/list/refreshable(action:)
Smith, J. (2022). Custom Pull to Refresh in SwiftUI. iOS Dev Weekly.
Swift by Sundell. (2023). Connecting UIKit and SwiftUI for Greater Flexibility.