I have tried many different solutions but nothing worked well enough for my case.
GeometryReader
based solutions had bad performance for a complex layout.
Here is a pure SwiftUI 2.0 View that seems to work well, does not decrease scrolling performance with constant state updates and does not use any UIKit hacks:
import SwiftUI
struct PullToRefreshView: View
{
private static let minRefreshTimeInterval = TimeInterval(0.2)
private static let triggerHeight = CGFloat(100)
private static let indicatorHeight = CGFloat(100)
private static let fullHeight = triggerHeight + indicatorHeight
let backgroundColor: Color
let foregroundColor: Color
let isEnabled: Bool
let onRefresh: () -> Void
@State private var isRefreshIndicatorVisible = false
@State private var refreshStartTime: Date? = nil
init(bg: Color = .white, fg: Color = .black, isEnabled: Bool = true, onRefresh: @escaping () -> Void)
{
self.backgroundColor = bg
self.foregroundColor = fg
self.isEnabled = isEnabled
self.onRefresh = onRefresh
}
var body: some View
{
VStack(spacing: 0)
{
LazyVStack(spacing: 0)
{
Color.clear
.frame(height: Self.triggerHeight)
.onAppear
{
if isEnabled
{
withAnimation
{
isRefreshIndicatorVisible = true
}
refreshStartTime = Date()
}
}
.onDisappear
{
if isEnabled, isRefreshIndicatorVisible, let diff = refreshStartTime?.distance(to: Date()), diff > Self.minRefreshTimeInterval
{
onRefresh()
}
withAnimation
{
isRefreshIndicatorVisible = false
}
refreshStartTime = nil
}
}
.frame(height: Self.triggerHeight)
indicator
.frame(height: Self.indicatorHeight)
}
.background(backgroundColor)
.ignoresSafeArea(edges: .all)
.frame(height: Self.fullHeight)
.padding(.top, -Self.fullHeight)
}
private var indicator: some View
{
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: foregroundColor))
.opacity(isRefreshIndicatorVisible ? 1 : 0)
}
}
It uses a LazyVStack
with negative padding to call onAppear
and onDisappear
on a trigger view Color.clear
when it enters or leaves the screen bounds.
Refresh is triggered if the time between the trigger view appearing and disappearing is greater than minRefreshTimeInterval
to allow the ScrollView
to bounce without triggering a refresh.
To use it add PullToRefreshView
to the top of the ScrollView
:
import SwiftUI
struct RefreshableScrollableContent: View
{
var body: some View
{
ScrollView
{
VStack(spacing: 0)
{
PullToRefreshView { print("refreshing") }
// ScrollView content
}
}
}
}
Gist: https://gist.github.com/tkashkin/e5f6b65b255b25269d718350c024f550