-
-
Save bradley/7517e6592a3621bf57aa5dbb7395e394 to your computer and use it in GitHub Desktop.
// | |
// 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") | |
} | |
} |
You don't need the
onRefresh
closure. It's completely redundant because you already have theisRefreshing
binding. Just use theonChange(of:perform:)
modifier to perform an action when theisRefreshing
binding changes.
Thank you, Ill play with it right now and post an update if successful. Sounds like a good change.
I'm glad I could improve your code.
Updated, thank you!
Why does this not work with List?
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.
thanks for this great gist! it works like a charm in my project 👍
Hello!
Your gist is amazing! I think it's the best pull-to-refresh for SwiftUI for now! Thank you ❤️
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!
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.
How would customization of the pull to refresh view work? (changing size of indicator, scroll threshold)
Not working properly, this is preventing list cells from being reused, all cells are loaded in advance. Poor performance.
great work
You don't need the
onRefresh
closure. It's completely redundant because you already have theisRefreshing
binding. Just use theonChange(of:perform:)
modifier to perform an action when theisRefreshing
binding changes.