Swiftui Generic Pull to Refresh View

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()
}
}
}

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 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 }.

SwiftUI List not updating inside pull to refresh view

It appears that dynamic property update cannot pass boundary of different hosting controller, so the solution is pass it (in this case observable object) inside explicitly.

Tested with Xcode 11.4 / iOS 13.4 on replicated code

demo

So, custom view is constructed as

CustomScrollView(width: geometry.size.width, height: geometry.size.height, viewModel: self.seeAllViewModel) {
// Separate internals to subview and pass view modal there
RefreshInternalView(seeAllViewModel: self.seeAllViewModel)
}

and here is separated view, nothing special - just extracted everything from above

struct RefreshInternalView: View {
@ObservedObject var seeAllViewModel: SeeAllViewModel
var body: some View {
VStack {
Text("\(self.seeAllViewModel.category.items.count)") // not being updated
List {
ForEach(self.seeAllViewModel.category.items) { (item: Item) in
ItemRowView(itemViewModel: ItemViewModel(item: item))
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle(Text(self.seeAllViewModel.category.title.firstCapitalized))
}
}
}

Introspect library UIRefreshControl with SwiftUI not working

The introspectTableView closure is being called every time the view is refreshed, which is why the code to get new data keeps being executed.

To solve this, what you need to is to call your refreshing code only when the user has been pulled. The only way I could do this was by creating a RefreshHelper class, since the UIRefreshControl still depends on @objc functions and Selectors.

Here's a sample that roughly implements what you want:

struct ContentView: View {
@State var data: [String] = (0...5).map { "Item \($0)" }
let refreshHelper = RefreshHelper()

class RefreshHelper {
var parent: ContentView?
var refreshControl: UIRefreshControl?

@objc func didRefresh() {
guard let parent = parent, let refreshControl = refreshControl else { return }
parent.data.append("Item \(parent.data.count)") // load data here
refreshControl.endRefreshing()
}
}

var body: some View {
List(data, id: \.self) { i in
Text("Item \(i)")
}
.introspectTableView { tableView in
let control = UIRefreshControl()

refreshHelper.parent = self
refreshHelper.refreshControl = control

control.addTarget(refreshHelper, action: #selector(RefreshHelper.didRefresh), for: .valueChanged)
tableView.refreshControl = control
}
}
}

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()

Initialising a SwiftUI View via a Swift generic

You can request it to be injected externally, like

struct GenericRefreshView<T, V>: UIViewRepresentable where T:ObservableObject, T:dataModel, V: View {

@EnvironmentObject var dataModel: T
let contentView: V // here !!
// let builder: () -> V // alternate, as view builder

func makeCoordinator() -> Coordinator {
Coordinator(self, regs: dataModel)
}

let size: CGSize

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

let refreshVC = UIHostingController(rootView: self.contentView) // here !!

...

Update: simplifying to avoid missed parts, here is a demo of usage

struct DemoView: View {
var body: some View {
GenericRefreshView(dataModel: TestViewModel(),
contentView: Text("Demo"),
size: CGSize(width: 100, height: 100))
}
}

struct GenericRefreshView<T, V>: UIViewRepresentable where T:ObservableObject, V: View {

@ObservedObject var dataModel: T // << EnvironmentObject is internal, so cannot be inferred from input arguments, so not usable here
let contentView: V
let size: CGSize


Related Topics



Leave a reply



Submit