Pull down to refresh data in SwiftUI
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 ScrollView
s 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
Related videos on Youtube
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, 2022Comments
-
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
withUIRefreshControl
inUIKit
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 about 5 yearsI suggest PullDownButton should be used for this but it doesn't look to be implemented at this moment
-
-
michasaurus over 4 yearsLooks 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 over 4 yearsVery nice solution. A bit hacky, but should be easy enough to fix shall a future SwiftUI update break it.
-
LetsGoBrandon almost 4 years@michasaurus this “library” holds in one file. So just copy it and you’re done
-
RXP over 3 yearsGood 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 over 3 yearsYou saved me several hours of unnecessary headache with this little gem, thank you!
-
GarySabo over 3 yearsNice 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 over 3 yearsI'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 over 3 yearsLoading indicator is gone when I pull for the 2nd time
-
Petesta about 3 yearsWould 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 almost 3 years.refreshable only works with List in iOS 15, hopefully in later releases we get more support.
-
Arturo over 2 yearsThis only works with Lists at the moment, no use for Scrollviews...
-
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 about 2 yearsIt 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.