Last active
November 28, 2021 00:46
-
-
Save bradley/7517e6592a3621bf57aa5dbb7395e394 to your computer and use it in GitHub Desktop.
Pull-to-Refresh: SwiftUI, UIKit-Backed, Actually-Usable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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") | |
} | |
} |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I had to add
hostingController.view.setNeedsUpdateConstraints()
right afterhostingController.rootView = content
for making it work under Xcode 13 (Beta 4). Without that the top of the content inside theUIScrollView
was cut off for me.Thank you for this great gist!