Pull down to refresh data in SwiftUI

33,780

Solution 1

I needed the same thing for an app I'm playing around with, and it looks like the SwiftUI API does not include a refresh control capability for ScrollViews at this time.

Over time, the API will develop and rectify these sorts of situations, but the general fallback for missing functionality in SwiftUI will always be implementing a struct that implements UIViewRepresentable. Here's a quick and dirty one for UIScrollView with a refresh control.

struct LegacyScrollView : UIViewRepresentable {
    // any data state, if needed

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UIScrollView {
        let control = UIScrollView()
        control.refreshControl = UIRefreshControl()
        control.refreshControl?.addTarget(context.coordinator, action:
            #selector(Coordinator.handleRefreshControl),
                                          for: .valueChanged)

        // Simply to give some content to see in the app
        let label = UILabel(frame: CGRect(x: 0, y: 0, width: 200, height: 30))
        label.text = "Scroll View Content"
        control.addSubview(label)

        return control
    }


    func updateUIView(_ uiView: UIScrollView, context: Context) {
        // code to update scroll view from view state, if needed
    }

    class Coordinator: NSObject {
        var control: LegacyScrollView

        init(_ control: LegacyScrollView) {
            self.control = control
        }

        @objc func handleRefreshControl(sender: UIRefreshControl) {
            // handle the refresh event

            sender.endRefreshing()
        }
    }
}

But of course, you can't use any SwiftUI components in your scroll view without wrapping them in a UIHostingController and dropping them in makeUIView, rather than putting them in a LegacyScrollView() { // views here }.

Solution 2

here is a simple, small and pure SwiftUI solution i made in order to add pull to refresh functionality to a ScrollView.

struct PullToRefresh: View {
    
    var coordinateSpaceName: String
    var onRefresh: ()->Void
    
    @State var needRefresh: Bool = false
    
    var body: some View {
        GeometryReader { geo in
            if (geo.frame(in: .named(coordinateSpaceName)).midY > 50) {
                Spacer()
                    .onAppear {
                        needRefresh = true
                    }
            } else if (geo.frame(in: .named(coordinateSpaceName)).maxY < 10) {
                Spacer()
                    .onAppear {
                        if needRefresh {
                            needRefresh = false
                            onRefresh()
                        }
                    }
            }
            HStack {
                Spacer()
                if needRefresh {
                    ProgressView()
                } else {
                    Text("⬇️")
                }
                Spacer()
            }
        }.padding(.top, -50)
    }
}

To use it it's simple, just add it at the top of your ScrollView and give it the coordinate space of the ScrollView :

ScrollView {
    PullToRefresh(coordinateSpaceName: "pullToRefresh") {
        // do your stuff when pulled
    }
    
    Text("Some view...")
}.coordinateSpace(name: "pullToRefresh")

Solution 3

Here's an implementation that introspects the view hierarchy and adds a proper UIRefreshControl to a SwiftUI List's table view: https://github.com/timbersoftware/SwiftUIRefresh

Bulk of the introspection logic can be found here: https://github.com/timbersoftware/SwiftUIRefresh/blob/15d9deed3fec66e2c0f6fd1fd4fe966142a891db/Sources/PullToRefresh.swift#L39-L73

Solution 4

from iOS 15+

NavigationView {
    List(1..<100) { row in
     Text("Row \(row)")
    }
    .refreshable {
         print("write your pull to refresh logic here")
    }
}

for more details: Apple Doc

Solution 5

I have tried many different solutions but nothing worked well enough for my case. GeometryReader based solutions had bad performance for a complex layout.

Here is a pure SwiftUI 2.0 View that seems to work well, does not decrease scrolling performance with constant state updates and does not use any UIKit hacks:

import SwiftUI

struct PullToRefreshView: View
{
    private static let minRefreshTimeInterval = TimeInterval(0.2)
    private static let triggerHeight = CGFloat(100)
    private static let indicatorHeight = CGFloat(100)
    private static let fullHeight = triggerHeight + indicatorHeight
    
    let backgroundColor: Color
    let foregroundColor: Color
    let isEnabled: Bool
    let onRefresh: () -> Void
    
    @State private var isRefreshIndicatorVisible = false
    @State private var refreshStartTime: Date? = nil
    
    init(bg: Color = .white, fg: Color = .black, isEnabled: Bool = true, onRefresh: @escaping () -> Void)
    {
        self.backgroundColor = bg
        self.foregroundColor = fg
        self.isEnabled = isEnabled
        self.onRefresh = onRefresh
    }
    
    var body: some View
    {
        VStack(spacing: 0)
        {
            LazyVStack(spacing: 0)
            {
                Color.clear
                    .frame(height: Self.triggerHeight)
                    .onAppear
                    {
                        if isEnabled
                        {
                            withAnimation
                            {
                                isRefreshIndicatorVisible = true
                            }
                            refreshStartTime = Date()
                        }
                    }
                    .onDisappear
                    {
                        if isEnabled, isRefreshIndicatorVisible, let diff = refreshStartTime?.distance(to: Date()), diff > Self.minRefreshTimeInterval
                        {
                            onRefresh()
                        }
                        withAnimation
                        {
                            isRefreshIndicatorVisible = false
                        }
                        refreshStartTime = nil
                    }
            }
            .frame(height: Self.triggerHeight)
            
            indicator
                .frame(height: Self.indicatorHeight)
        }
        .background(backgroundColor)
        .ignoresSafeArea(edges: .all)
        .frame(height: Self.fullHeight)
        .padding(.top, -Self.fullHeight)
    }
    
    private var indicator: some View
    {
        ProgressView()
            .progressViewStyle(CircularProgressViewStyle(tint: foregroundColor))
            .opacity(isRefreshIndicatorVisible ? 1 : 0)
    }
}

It uses a LazyVStack with negative padding to call onAppear and onDisappear on a trigger view Color.clear when it enters or leaves the screen bounds.

Refresh is triggered if the time between the trigger view appearing and disappearing is greater than minRefreshTimeInterval to allow the ScrollView to bounce without triggering a refresh.

To use it add PullToRefreshView to the top of the ScrollView:

import SwiftUI

struct RefreshableScrollableContent: View
{
    var body: some View
    {
        ScrollView
        {
            VStack(spacing: 0)
            {
                PullToRefreshView { print("refreshing") }
                
                // ScrollView content
            }
        }
    }
}

Gist: https://gist.github.com/tkashkin/e5f6b65b255b25269d718350c024f550

Share:
33,780

Related videos on Youtube

PinkeshGjr
Author by

PinkeshGjr

Ping me here if you need any help LinkedIn Top 15 User in Surat My name is Pinkesh. I believe my strong points are my creativity and punctuality. My main goal is always to meet your needs and deadlines. As a responsible person, skilled, ambitious, and professional developer, I would like to use all my knowledge, experience, and strong technical background to help you bring your ideas to iOS Platforms. I know what it is and know how to work hard, learn quickly and be a part of a team if needed. I have 5+ experience in the following languages, platforms, and frameworks: Languages: • Swift - Expert • Objective-C - Expert • SwiftUI - Intermediate Platforms: • iOS • iPadOS • WatchOS • TvOS Architectures: • VIPER • MVVM • MVC Frameworks: • UIKit • CoreData • CoreBluetooth • CoreMotion • CoreLocation • AutoLayout • SpriteKit • Notifications • Firebase • SocketIO Programs: • Xcode • Git • SVN • Jira • Trello Last but not the least, when I work with any client, I make sure that I do whatever it takes to make him/her satisfied with my work.

Updated on April 24, 2022

Comments

  • PinkeshGjr
    PinkeshGjr about 2 years

    i have used simple listing of data using List. I would like to add pull down to refresh functionality but i am not sure which is the best possible approach.

    Pull down to refresh view will only be visible when user tries to pull down from the very first index same like we did in UITableView with UIRefreshControl in UIKit

    Here is simple code for listing data in SwiftUI.

    struct CategoryHome: View {
        var categories: [String: [Landmark]] {
            .init(
                grouping: landmarkData,
                by: { $0.category.rawValue }
            )
        }
    
        var body: some View {
            NavigationView {
                List {
                    ForEach(categories.keys.sorted().identified(by: \.self)) { key in
                        Text(key)
                    }
                }
                .navigationBarTitle(Text("Featured"))
            }
        }
    }
    
    • DenFav
      DenFav about 5 years
      I suggest PullDownButton should be used for this but it doesn't look to be implemented at this moment
  • michasaurus
    michasaurus over 4 years
    Looks great, but I can't import your package through the Swift Package Manager. Would you mind adding a Package.swift manifest to the repo?
  • Andy Ibanez
    Andy Ibanez over 4 years
    Very nice solution. A bit hacky, but should be easy enough to fix shall a future SwiftUI update break it.
  • LetsGoBrandon
    LetsGoBrandon almost 4 years
    @michasaurus this “library” holds in one file. So just copy it and you’re done
  • RXP
    RXP over 3 years
    Good simple implementation but does not work when you have a URLSession data task where the background thread (task.resume()) returns immediately and the UI update kicks in after the data is fetched from the session and is getting updated with the results. The progress control ends quickly while the UI is still updating making the app unresponsive during the update. App updates after a few seconds and during that time the ProgressView is not visible
  • LJ White
    LJ White over 3 years
    You saved me several hours of unnecessary headache with this little gem, thank you!
  • GarySabo
    GarySabo over 3 years
    Nice solution but doesn't // do your stuff when pulled get called (1) when the view loads and again (2) when pulled? Wouldn't we want it to only execute for (2)?
  • Jason Campbell
    Jason Campbell over 3 years
    I'm not seeing the second onAppear be called, or the ProgressView disappear, unless I change the test to maxY < 55. That makes that onAppear fires immediately upon loading as well. It's not hard to work around the extra onAppear, but the maxY threshold doesn't seem to be working as expected
  • LilaQ
    LilaQ over 3 years
    Loading indicator is gone when I pull for the 2nd time
  • Petesta
    Petesta about 3 years
    Would that work with ForEach in say a LazyVGrid? If not, what’s the best way to get pull down refresh to work on a LazyVGrid?
  • Entalpi
    Entalpi almost 3 years
    .refreshable only works with List in iOS 15, hopefully in later releases we get more support.
  • Arturo
    Arturo over 2 years
    This only works with Lists at the moment, no use for Scrollviews...
  • adamsfamily
    adamsfamily about 2 years
    @GarySabo It does for me, too. I added a simply counter to avoid the first call but it feels to me too hackish (potentially unreliable) to do it so.
  • adamsfamily
    adamsfamily about 2 years
    It is a nice implementation but there is one thing I don't like: when the user scrolls up by swiping in the list, a refresh is triggered when scrolling hits the top of the page. That's not what the user really wanted. It should be possible to improve this, though, making it perfect.