Skip to content

Instantly share code, notes, and snippets.

@IanKeen
Last active August 19, 2021 23:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save IanKeen/8ca9a9562a68f1cbaac2ceada7c706a6 to your computer and use it in GitHub Desktop.
Save IanKeen/8ca9a9562a68f1cbaac2ceada7c706a6 to your computer and use it in GitHub Desktop.
SwiftUI: Drop-in Pull to Refresh mechanism
struct Usage: View {
@StateObject var viewModel = ViewModel()
var body: some View {
ScrollView {
LoadingIndicator(isLoading: $viewModel.isLoading) {
viewModel.refresh()
}
Button("Refresh") {
withAnimation { viewModel.refresh() }
}
LazyVStack {
ForEach(viewModel.items, id: \.self) { value in
Text("\(value)")
}
}
}
}
}
class ViewModel: ObservableObject {
@Published var isLoading = false
@Published var items: [Int] = [1,2,3,4]
func refresh() {
isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
var newItems: Set<Int> = []
while newItems.count != 5 {
let new = Int.random(in: 1...100)
if !self.items.contains(new) {
newItems.insert(new)
}
}
self.items.append(contentsOf: newItems)
self.isLoading = false
}
}
}
public struct LoadingIndicator: View {
@State private var startingOffset: CGFloat = 0
@State private var space: CGSize = .zero
@State private var size: CGSize = .zero
@State private var progress: Double = 0
@Binding private var isLoading: Bool {
didSet {
if isLoading { action() }
else { progress = 0 }
}
}
private let action: () -> Void
private var spinnerY: CGFloat {
let start = -size.height
let end = size.height / 2
if isLoading {
return end
} else {
let distance = start.distance(to: end)
let value = start + (distance * CGFloat(progress / 2))
let clamped = min(max(start, value), end)
return clamped
}
}
public init(isLoading: Binding<Bool>, action: @escaping () -> Void) {
self._isLoading = isLoading
self.action = action
}
public var body: some View {
Color.clear
.background(
GeometryReader { proxy in
Color.clear
.onAppear {
startingOffset = proxy.frame(in: .global).origin.y
}
.onChange(of: proxy.frame(in: .global).origin.y) { y in
let movement = y - startingOffset
let percent = Double(movement / 100)
progress = min(max(0, percent), 1)
if progress == 1 && !isLoading {
isLoading = true
}
}
}
)
.frame(height: isLoading ? size.height : 0)
.overlay(
Spinner(style: .medium, spinning: isLoading)
.opacity(isLoading ? 1 : progress)
.padding([.top, .bottom], 8)
.background(GeometryReader { proxy in
Color.clear.onAppear { self.size = proxy.size }
})
.position(
x: space.width / 2,
y: spinnerY
)
)
.background(GeometryReader { proxy in
Color.clear.onAppear { self.space = proxy.size }
})
}
}
private struct Spinner: UIViewRepresentable {
var style: UIActivityIndicatorView.Style = .medium
let spinning: Bool
func makeUIView(context: Context) -> UIActivityIndicatorView {
let uiView = UIActivityIndicatorView(style: style)
uiView.hidesWhenStopped = false
return uiView
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {
if spinning { uiView.startAnimating() }
else { uiView.stopAnimating() }
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment