Pull down to refresh data in SwiftUI
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 }
.
How can you Drag to refresh a Grid View (LazyVGrid) in Swiftui?
Here is the code LazyVStack
:
import SwiftUI
struct PullToRefreshSwiftUI: View {
@Binding private var needRefresh: Bool
private let coordinateSpaceName: String
private let onRefresh: () -> Void
init(needRefresh: Binding<Bool>, coordinateSpaceName: String, onRefresh: @escaping () -> Void) {
self._needRefresh = needRefresh
self.coordinateSpaceName = coordinateSpaceName
self.onRefresh = onRefresh
}
var body: some View {
HStack(alignment: .center) {
if needRefresh {
VStack {
Spacer()
ProgressView()
Spacer()
}
.frame(height: 100)
}
}
.background(GeometryReader {
Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self,
value: $0.frame(in: .named(coordinateSpaceName)).origin.y)
})
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
guard !needRefresh else { return }
if abs(offset) > 50 {
needRefresh = true
onRefresh()
}
}
}
}
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
And here is typical usage:
struct ContentView: View {
@State private var refresh: Bool = false
@State private var itemList: [Int] = {
var array = [Int]()
(0..<40).forEach { value in
array.append(value)
}
return array
}()
var body: some View {
ScrollView {
PullToRefreshSwiftUI(needRefresh: $refresh,
coordinateSpaceName: "pullToRefresh") {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation { refresh = false }
}
}
LazyVStack {
ForEach(itemList, id: \.self) { item in
HStack {
Spacer()
Text("\(item)")
Spacer()
}
}
}
}
.coordinateSpace(name: "pullToRefresh")
}
}
This can be easily adapted for LazyVGrid
, just replace LazyVStack
.
EDIT:
Here is more refined variant:
struct PullToRefresh: View {
private enum Constants {
static let refreshTriggerOffset = CGFloat(-140)
}
@Binding private var needsRefresh: Bool
private let coordinateSpaceName: String
private let onRefresh: () -> Void
init(needsRefresh: Binding<Bool>, coordinateSpaceName: String, onRefresh: @escaping () -> Void) {
self._needsRefresh = needsRefresh
self.coordinateSpaceName = coordinateSpaceName
self.onRefresh = onRefresh
}
var body: some View {
HStack(alignment: .center) {
if needsRefresh {
VStack {
Spacer()
ProgressView()
Spacer()
}
.frame(height: 60)
}
}
.background(GeometryReader {
Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self,
value: -$0.frame(in: .named(coordinateSpaceName)).origin.y)
})
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
guard !needsRefresh, offset < Constants.refreshTriggerOffset else { return }
withAnimation { needsRefresh = true }
onRefresh()
}
}
}
private struct ScrollViewOffsetPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
private enum Constants {
static let coordinateSpaceName = "PullToRefreshScrollView"
}
struct PullToRefreshScrollView<Content: View>: View {
@Binding private var needsRefresh: Bool
private let onRefresh: () -> Void
private let content: () -> Content
init(needsRefresh: Binding<Bool>,
onRefresh: @escaping () -> Void,
@ViewBuilder content: @escaping () -> Content) {
self._needsRefresh = needsRefresh
self.onRefresh = onRefresh
self.content = content
}
var body: some View {
ScrollView {
PullToRefresh(needsRefresh: $needsRefresh,
coordinateSpaceName: Constants.coordinateSpaceName,
onRefresh: onRefresh)
content()
}
.coordinateSpace(name: Constants.coordinateSpaceName)
}
}
SwiftUI Generic Pull to refresh view
Here is possible approach (compiled with Xcode 11.4)
Usage:
CustomScrollView(width: 100, height: 100,
viewModel: HomeViewMode()) {
HomeView()
}
Generic type:
protocol CustomViewModel {
func loadPackages()
}
// It is used generic ViewBuilder pattern for content
struct CustomScrollView<Content: View, VM: CustomViewModel> : UIViewRepresentable {
var width : CGFloat
var height : CGFloat
let viewModel: VM
let content: () -> Content
init(width: CGFloat, height: CGFloat, viewModel: VM, @ViewBuilder content: @escaping () -> Content) {
self.width = width
self.height = height
self.viewModel = viewModel
self.content = content
}
func makeUIView(context: Context) -> UIScrollView {
let control = UIScrollView()
control.refreshControl = UIRefreshControl()
control.refreshControl?.addTarget(context.coordinator, action: #selector(Coordinator.handleRefreshControl), for: .valueChanged)
let childView = UIHostingController(rootView: content())
childView.view.frame = CGRect(x: 0, y: 0, width: width, height: height)
control.addSubview(childView.view)
return control
}
func updateUIView(_ uiView: UIScrollView, context: Context) { }
class Coordinator: NSObject {
var control: CustomScrollView<Content, VM>
var viewModel: VM
init(_ control: CustomScrollView, viewModel: VM) {
self.control = control
self.viewModel = viewModel
}
@objc func handleRefreshControl(sender: UIRefreshControl) {
sender.endRefreshing()
viewModel.loadPackages()
}
}
}
How to use pull to refresh in Swift?
Pull to refresh is built in iOS. You could do this in swift like
let refreshControl = UIRefreshControl()
override func viewDidLoad() {
super.viewDidLoad()
refreshControl.attributedTitle = NSAttributedString(string: "Pull to refresh")
refreshControl.addTarget(self, action: #selector(self.refresh(_:)), for: .valueChanged)
tableView.addSubview(refreshControl) // not required when using UITableViewController
}
@objc func refresh(_ sender: AnyObject) {
// Code to refresh table view
}
At some point you could end refreshing.
refreshControl.endRefreshing()
Related Topics
Nsdateformatter Return Wrong Date + Swift
Swift Cross Compile to Single Linux Binary
In Xcode 6.1. 'Uiimage' Does Not Have a Member Named 'Size' Error
Why Is There No Universal Base Class in Swift
How to Create an Instance of a Class from a String in Swift
Expected to Decode Array<Any> But Found a Dictionary Instead
Get Subdirectories Using Swift
Firebase Get User Uid by Email
How to Print Out the Method Name and Line Number in Swift
Order of Modifiers in Swiftui View Impacts View Appearance
Why Are Doubles Printed Differently in Dictionaries
How to Access Nswindow from @Main App Using Only Swiftui
Swift 2.1 [Uint8] --Utf8--> String
Can Associated Values and Raw Values Coexist in Swift Enumeration
Generating Resource_Bundle_Accessor, Type 'Bundle' Has No Member 'Module'
Sort Array by Calculated Distance in Swift