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")
}
}
@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