Greetings, traveler!
SwiftUI provides a built-in .refreshable modifier, which covers the majority of use cases. At some point, product requirements start to demand more control over the refresh interaction. Custom animations, branded indicators, precise control over gesture thresholds, and coordination with complex layouts are typical examples. In these cases, a custom pull-to-refresh component becomes a practical solution.
This article walks through the design and implementation of a reusable PullToRefreshScrollView. The goal is to expose the refresh lifecycle to the caller and allow full control over the indicator view, while keeping the interaction predictable and close to native behavior.
When a Custom Solution Makes Sense
A custom pull-to-refresh is useful when the default API does not provide enough flexibility. Common scenarios include:
- A branded or animated refresh indicator
- Synchronization with other layout elements
- Fine-tuned gesture thresholds and animation timing
- Embedding the indicator into scroll content instead of overlaying it
- Supporting intermediate states such as pull progress or finishing animation
The component described here focuses on exposing the refresh lifecycle as state and letting the caller render UI accordingly.
Modeling the Refresh Lifecycle
The core idea is to represent the interaction as a state machine. Instead of hiding internal transitions, the component exposes them through a public enum.
public enum PullToRefreshScrollViewState: Equatable {
case idle
case pulling(progress: CGFloat)
case armed(progress: CGFloat)
case refreshing
case finishing(progress: CGFloat)
}This model allows the caller to render different visuals depending on the phase of the interaction. The progress values are normalized to the 0...1 range, which simplifies animation logic.
Component Structure
The main component is a generic View that accepts two builders: one for the refresh indicator and one for the scrollable content.
public struct PullToRefreshScrollView<RefreshContent: View, Content: View>: View {
private var threshold: CGFloat = 96
private var refreshViewHeight: CGFloat = 80
private var showsIndicators: Bool = true
private var isEnabled: Bool = true
private var animationDuration: Duration = .milliseconds(350)
private let onRefresh: () -> Void
private let refreshContent: (PullToRefreshScrollViewState) -> RefreshContent
private let content: () -> Content
@Binding private var isRefreshing: Bool
@State private var scrollPhase: ScrollPhase = .idle
@State private var pullDistance: CGFloat = .zero
@State private var displayProgress: CGFloat = .zero
@State private var isArmed = false
@State private var isFinishing = false
@State private var finishTask: Task<Void, Never>?
}The component manages gesture tracking and animation, while delegating rendering to the caller.
Layout and Indicator Placement
The indicator is part of the scroll content. This approach ensures that it behaves naturally when scrolling and remains visible during refresh.
ScrollView {
VStack(spacing: 0) {
refreshContent(currentState)
.frame(height: indicatorVisibleHeight, alignment: .bottom)
.frame(height: effectiveRefreshViewHeight, alignment: .bottom)
.frame(maxWidth: .infinity)
.clipped()
.allowsHitTesting(false)
content()
}
.padding(.top, contentTopInset)
}The indicator is initially hidden using a negative top inset. During refresh, the inset is removed so the indicator remains visible.
Tracking Pull Gesture
The component relies on scroll geometry to detect overscroll.
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y + geometry.contentInsets.top
} action: { _, offset in
handleScrollOffsetChange(offset)
}The handler computes overscroll and maps it to a normalized progress value.
let overscroll = max(-offset, 0)
let progress = min(overscroll / threshold, 1)
let isNowArmed = progress >= 1This data drives the internal state and is later exposed to the indicator.
Triggering Refresh
Refresh is triggered when the user releases the gesture after crossing the threshold.
.onScrollPhaseChange { oldPhase, newPhase in
if oldPhase == .interacting,
newPhase != .interacting,
isArmed,
!isRefreshing {
triggerRefresh()
}
}The triggerRefresh method updates internal state and notifies the caller.
private func triggerRefresh() {
guard !isRefreshing else { return }
isArmed = false
isFinishing = false
pullDistance = refreshViewHeight
displayProgress = 1
isRefreshing = true
onRefresh()
}The component sets isRefreshing to true, while the caller is responsible for setting it back to false once loading finishes.
Finishing Animation
When isRefreshing becomes false, the component starts a finishing animation that gradually hides the indicator.
private func startFinishingAnimation() {
isFinishing = true
displayProgress = 1
finishTask = Task { @MainActor in
let frameDelay: Duration = .milliseconds(16)
let step = frameDelay.timeInterval / animationDuration.timeInterval
while displayProgress > 0 {
try? await Task.sleep(for: frameDelay)
displayProgress = max(displayProgress - step, 0)
}
reset()
}
}This explicit control over progress allows the indicator to animate smoothly while preserving access to intermediate values.
Custom Indicator Example
The indicator can react to the state and render different visuals.
private struct DemoRefreshHeader: View {
let state: PullToRefreshScrollViewState
@State private var spinningRotation: Double = .zero
private let indicatorSize: CGFloat = 40
var body: some View {
Circle()
.trim(from: 0, to: arcProgress)
.stroke(
AngularGradient(
colors: [.cyan, .mint, .white, .cyan],
center: .center
),
style: .init(lineWidth: 8, lineCap: .round)
)
.frame(width: indicatorSize, height: indicatorSize)
.rotationEffect(.degrees(rotation))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.drawingGroup()
.accessibilityHidden(true)
.onAppear {
updateAnimationState()
}
.onChange(of: state) { _, _ in
updateAnimationState()
}
}
private var progress: CGFloat {
switch state {
case .idle:
0
case .pulling(let progress), .armed(let progress):
progress
case .refreshing:
1
case .finishing(let progress):
progress
}
}
private var arcProgress: CGFloat {
switch state {
case .refreshing:
0.999
default:
progress
}
}
private var rotation: Double {
switch state {
case .idle:
0
case .pulling(let progress):
Double(progress) * 150
case .armed:
180
case .refreshing:
spinningRotation
case .finishing(let progress):
Double(progress) * 360
}
}
private func updateAnimationState() {
guard state == .refreshing else {
spinningRotation = 0
return
}
spinningRotation = 0
Task { @MainActor in
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: false)) {
spinningRotation = 360
}
}
}
}This example demonstrates how the indicator can evolve through the interaction phases without any knowledge of gesture handling.
Putting It All Together
The following demo shows how the component is used in practice.
private struct PullToRefreshDemoView: View {
@State private var items = Array(1...16).map { "Row \($0)" }
@State private var isRefreshing = false
var body: some View {
PullToRefreshScrollView(
isRefreshing: $isRefreshing,
onRefresh: reload
) { state in
DemoRefreshHeader(state: state)
} content: {
LazyVStack(spacing: 14) {
ForEach(items, id: \.self) { item in
RoundedRectangle(cornerRadius: 24)
.fill(.white.opacity(0.14))
.frame(height: 78)
.overlay(alignment: .leading) {
Text(item)
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, 20)
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 18)
}
.threshold(110)
.refreshViewHeight(88)
.showsIndicators(false)
.background(
LinearGradient(
colors: [
Color(red: 0.05, green: 0.08, blue: 0.16),
Color(red: 0.10, green: 0.22, blue: 0.35),
Color(red: 0.03, green: 0.10, blue: 0.15)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
private func reload() {
Task {
try? await Task.sleep(for: .seconds(2))
await MainActor.run {
items.insert("Updated \(Date.now.formatted(date: .omitted, time: .standard))", at: 0)
isRefreshing = false
}
}
}
}This example highlights the intended usage pattern:
- The component controls gesture detection and lifecycle.
- The caller provides UI and handles data loading.
- State flows through
isRefreshing.
Conclusion
A custom pull-to-refresh component introduces more control over interaction and presentation. The approach described here keeps responsibilities clearly separated: gesture handling and lifecycle management live inside the component, while rendering remains fully customizable.
Available on my GitHub.
