Follow

Keep Up to Date with the Most Important News

By pressing the Subscribe button, you confirm that you have read and are agreeing to our Privacy Policy and Terms of Use
Contact

SwiftUI .refreshable: Can You Trigger Animation Programmatically?

Learn how to programmatically trigger SwiftUI’s .refreshable animation using native or custom methods in your iOS app.
Frustrated iOS developer trying to trigger SwiftUI refreshable animation programmatically with a stuck loading spinner and SwiftUI code on screen Frustrated iOS developer trying to trigger SwiftUI refreshable animation programmatically with a stuck loading spinner and SwiftUI code on screen
  • SwiftUI's .refreshable only 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 .refreshable with timers or other events to run the same code, but the refresh animation won't appear.
  • UIKit's UIRefreshControl gives you complete control with code. It can work alongside SwiftUI by using UIViewControllerRepresentable.
  • 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:

MEDevel.com: Open-source for Healthcare and Education

Collecting and validating open-source software for healthcare, education, enterprise, development, medical imaging, medical records, and digital pathology.

Visit Medevel

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:

  1. A view that scrolls: Most often ScrollView
  2. Find scroll position: Use GeometryReader to check vertical scroll.
  3. Code to start: See if the user has pulled far enough (e.g., -50 pts).
  4. Show loading UI: Use a view like ProgressView only 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 .refreshable with 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 Task control.
  • 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 ViewModel so 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.

Add a comment

Leave a Reply

Keep Up to Date with the Most Important News

By pressing the Subscribe button, you confirm that you have read and are agreeing to our Privacy Policy and Terms of Use

Discover more from Dev solutions

Subscribe now to keep reading and get access to the full archive.

Continue reading