Skip to content

Instantly share code, notes, and snippets.

@bradley
Last active November 28, 2021 00:46
Show Gist options
  • Save bradley/7517e6592a3621bf57aa5dbb7395e394 to your computer and use it in GitHub Desktop.
Save bradley/7517e6592a3621bf57aa5dbb7395e394 to your computer and use it in GitHub Desktop.
Pull-to-Refresh: SwiftUI, UIKit-Backed, Actually-Usable
//
// RefreshableScrollView.swift
// --
//
// Created by Bradley on 3/24/21.
//
import Combine
import SwiftUI
import UIKit
struct RefreshableScrollOptions {
var refreshControlTintColor: UIColor? = nil
}
fileprivate struct RefreshableScrollViewRepresentable<Content: View>: UIViewControllerRepresentable {
@Binding var isRefreshing: Bool
let options: RefreshableScrollOptions
let content: () -> Content
@inlinable init(
isRefreshing: Binding<Bool>,
options: RefreshableScrollOptions = RefreshableScrollOptions(),
@ViewBuilder content: @escaping () -> Content
) {
self._isRefreshing = isRefreshing
self.options = options
self.content = content
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(
context: Context
) -> UIScrollViewViewController<Content> {
// Create scroll view passed to UIScrollViewViewController. We do so here so
// that this view representable may manage the scroll view's refresh
// control.
let scrollView = UIScrollView(frame: .zero)
// Create scroll view's wrapping view controller.
let scrollViewController = UIScrollViewViewController<Content>(
scrollView: scrollView
)
// Create refresh control. Note that we could customize this in the future.
let refreshControl = UIRefreshControl()
refreshControl.tintColor = options.refreshControlTintColor
// Add delegate to watch for when the user scrolls.
refreshControl.addTarget(
context.coordinator,
action: #selector(Coordinator.didPullToRefresh),
for: .valueChanged
)
// Attach the refresh control to the scroll view.
scrollView.refreshControl = refreshControl
return scrollViewController
}
func updateUIViewController(
_ scrollViewController: UIScrollViewViewController<Content>,
context: Context
) {
// Complete refreshing. This just works.
if !self.isRefreshing {
scrollViewController.scrollView.refreshControl?.endRefreshing()
}
// Update scroll view contents.
scrollViewController.updateContent(content: content())
}
class Coordinator: NSObject {
var parent: RefreshableScrollViewRepresentable
init(_ parent: RefreshableScrollViewRepresentable) {
self.parent = parent
}
@objc func didPullToRefresh() {
parent._isRefreshing.wrappedValue = true
}
}
}
fileprivate class UIScrollViewViewController<Content: View>: UIViewController {
var hostingController: UIHostingController<Content?> =
UIHostingController(rootView: nil)
var scrollView: UIScrollView
init(
scrollView: UIScrollView
) {
self.scrollView = scrollView
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.scrollView)
self.pinEdges(of: self.scrollView, to: self.view)
// Make hosting controller's background transparent. This will allow
// SwiftUI to handle view styling without interference.
hostingController.view.backgroundColor = UIColor(white: 1.0, alpha: 0.0)
// Begin adding content view to scroll view.
hostingController.willMove(toParent: self)
addChild(hostingController)
// Set content view within scroll view.
scrollView.addSubview(hostingController.view)
pinEdges(of: hostingController.view, to: scrollView)
// Finalize adding content view to scroll view.
hostingController.didMove(toParent: self)
}
func updateContent(content: Content) {
hostingController.rootView = content
}
func pinEdges(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor)
])
}
}
// SwiftUI wrapper that will also handle necessary GeometryReader required to
// fit the SwiftUI contents (`content`) within the UIKit UIScrollView
// appropriately.
struct RefreshableScrollView<Content: View>: View {
@Binding var isRefreshing: Bool
let options: RefreshableScrollOptions
let content: () -> Content
init(
isRefreshing: Binding<Bool>,
options: RefreshableScrollOptions = RefreshableScrollOptions(),
@ViewBuilder content: @escaping () -> Content
) {
self._isRefreshing = isRefreshing
self.options = options
self.content = content
}
var body: some View {
GeometryReader { geometry in
RefreshableScrollViewRepresentable(
isRefreshing: $isRefreshing,
options: options
) {
ZStack {
content()
}
.frame(
width: geometry.size.width,
alignment: .top
)
}
}
}
}
// Example Usage and Preview Content for xCode.
fileprivate struct PreviewView: View {
@State var isRefreshing = false
@State var now = Date()
@State var rowCount = 1
var body: some View {
// Wrapped in NavigationView as example, as a NavigationView causes many
// bugs in other solutions found.
NavigationView {
VStack {
RefreshableScrollView(
isRefreshing: $isRefreshing,
options: RefreshableScrollOptions(
refreshControlTintColor: .white
)
) {
VStack(spacing: 10.0) {
ForEach(0..<rowCount, id: \.self) {
Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)")
.frame(
minWidth: 0.0,
maxWidth: .infinity,
minHeight: 60.0,
maxHeight: 60.0,
alignment: .center
)
.background(Color.purple)
}
.padding(
EdgeInsets(
top: 5.0,
leading: 10.0,
bottom: 5.0,
trailing: 10.0
)
)
}
}
}
.onChange(of: isRefreshing) { newIsRefreshing in
if newIsRefreshing {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.isRefreshing = false
self.now = Date()
self.rowCount += 1
}
}
}
.frame(
maxWidth: .infinity,
maxHeight: 400.0,
alignment: .center
)
.background(Color.green400)
}
}
}
struct RefreshableScrollView_Previews: PreviewProvider {
static var previews: some View {
PreviewView()
.previewDevice(PreviewDevice(rawValue: "iPhone 12 Pro Max"))
.previewDisplayName("iPhone 12 Pro Max")
}
}
@Peter-Schorn
Copy link

I'm glad I could improve your code.

@bradley
Copy link
Author

bradley commented Mar 25, 2021

Updated, thank you!

@Peter-Schorn
Copy link

Peter-Schorn commented Mar 28, 2021

Why does this not work with List?

@bradley
Copy link
Author

bradley commented Mar 30, 2021

Hey Peter, this code was a reply to your post in this issue thread, and my own need (also) for a pull-to-refresh ScrollView. However, I believe this could be easily modified by updating the struct RefreshableScrollViewRepresentable and class UIScrollViewViewController, to provide UITableView or UICollectionView instead of a UIScrollView and should mostly just work (you may need to also update how the contents are set therein as a UIScrollView works a little differently, as you probably know). Again though, this is an implementation of a ScrollView for my needs and in response to your original question regarding the same.

As for the other question about the white refresh control, the code has an option for refreshControlTintColor and you can set it to whatever you wish - it will use the OS's default if not. You may have just copied it from the example code which uses refreshControlTintColor: .white due to its own color needs.

@flkrnr
Copy link

flkrnr commented Apr 14, 2021

thanks for this great gist! it works like a charm in my project 👍

@maxtomczyk
Copy link

Hello!
Your gist is amazing! I think it's the best pull-to-refresh for SwiftUI for now! Thank you ❤️

@RobertChals
Copy link

I had to add hostingController.view.setNeedsUpdateConstraints() right after hostingController.rootView = content for making it work under Xcode 13 (Beta 4). Without that the top of the content inside the UIScrollView was cut off for me.

Thank you for this great gist!

@ashtoncofer
Copy link

ashtoncofer commented Aug 4, 2021

I have been looking for a solution to this for months. Thank you Bradley!

I did find one issue though. If you have an alert or action sheet on an element inside of the scroll view, the alert/action sheet dismisses immediately.

@ashtoncofer
Copy link

How would customization of the pull to refresh view work? (changing size of indicator, scroll threshold)

@Futskito
Copy link

Not working properly, this is preventing list cells from being reused, all cells are loaded in advance. Poor performance.

@lacasaprivata2
Copy link

lacasaprivata2 commented Nov 28, 2021

great work

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment