Greetings, traveler!
SwiftUI’s .refreshable looks deceptively simple: attach it to a List or ScrollView, implement a refresh closure, and you get pull-to-refresh for free.
However, there is a detail that often surprises developers the first time they build more complex view hierarchies: .refreshable is not scoped to a single scroll view. Under the hood, it is implemented as an environmental action, which means it can be inherited by child views — including nested ScrollViews and even views presented via .sheet.
This post explains why it happens, when it matters, and how to reliably prevent refresh propagation in complex compositions.
Why .refreshable “leaks” into child views
SwiftUI uses the environment to propagate behavior down the view tree. Some examples are obvious (locale, colorScheme), and some are more subtle: actions like opening URLs, dismissing, or refreshing.
.refreshable belongs to the latter category. When you write:
ScrollView {
...
}
.refreshable {
await reload()
}SwiftUI stores the refresh action in the environment. Any child scroll view that supports pull-to-refresh can discover that action and trigger it.
Preferred fix: change the modifier placement
In many cases, no workaround is required. The cleanest solution is to attach .refreshable only to the root view so it applies only to the correct container and does not accidentally become visible to unrelated scroll views.
A common example is refresh + modal presentation. Otherwise, SwiftUI may propagate the refresh action into the sheet subtree.
The recommended structure is:
- Keep
.sheeton the container - Place
.refreshableon the container itself (above the modal content)
Example:
struct ParentView: View {
@State private var isPresented: Bool = false
var body: some View {
ScrollView {
Button("Open sheet") {
isPresented.toggle()
}
.font(.title2)
.fontWeight(.bold)
.frame(maxWidth: .infinity, alignment: .center)
.padding()
}
.sheet(isPresented: $isPresented) {
ChildView()
.presentationDetents([.medium])
}
.refreshable {
print("Refreshing...")
}
}
}This layout is straightforward and usually enough.
When modifier placement is not enough
Sometimes the view composition is not under your control. In those cases, moving modifiers around may be expensive or simply unrealistic. You need a local way to “cut off” refresh propagation.
Since refresh is stored in the environment, the most direct solution is to override the refresh action for a subtree.
In other words:
- parent defines
.refreshable - child subtree explicitly sets the refresh action to
nil - nested scroll views no longer find a refresh action to trigger
Here is a minimal implementation as a custom modifier:
extension View {
func refreshDisabled() -> some View {
modifier(RefreshActionDisablingModifier())
}
}
private struct RefreshActionDisablingModifier: ViewModifier {
func body(content: Content) -> some View {
if let refreshKeyPath = \EnvironmentValues.refresh as? WritableKeyPath<EnvironmentValues, RefreshAction?> {
content.environment(refreshKeyPath, nil)
} else {
content
}
}
}What this code does:
- SwiftUI exposes
EnvironmentValues.refresh, which represents the refresh action. .environment(_: _:)requires aWritableKeyPath.- The cast:
\EnvironmentValues.refresh as? WritableKeyPath<EnvironmentValues, RefreshAction?>is a compatibility guard. If it succeeds, the modifier overrides refresh with nil. If not, it falls back to returning content unchanged.
Demo: block refresh inside a sheet
Here is the full working demo:
import SwiftUI
struct ParentView: View {
@State private var isPresented: Bool = false
var body: some View {
ScrollView {
Button("Hello, World!") {
isPresented.toggle()
}
.font(.title2)
.fontWeight(.bold)
.padding()
}
.sheet(isPresented: $isPresented) {
ChildView()
.presentationDetents([.medium])
.refreshDisabled()
}
.refreshable {
print("Refreshing...")
}
}
}
struct ChildView: View {
var body: some View {
ScrollView {
Text("Child View")
.font(.title2)
.fontWeight(.bold)
}
}
}
extension View {
func refreshDisabled() -> some View {
modifier(RefreshActionDisablingModifier())
}
}
private struct RefreshActionDisablingModifier: ViewModifier {
func body(content: Content) -> some View {
if let refreshKeyPath = \EnvironmentValues.refresh as? WritableKeyPath<EnvironmentValues, RefreshAction?> {
content.environment(refreshKeyPath, nil)
} else {
content
}
}
}With this modifier applied to the sheet content, the ScrollView inside ChildView will no longer trigger the parent refresh action.
Summary
.refreshableis implemented through SwiftUI’s environment.- That makes it inheritable by child views, including nested
ScrollViews. - In many cases, correct modifier placement solves the issue.
- When it does not, clearing the environment refresh action in a subtree is a reliable workaround.
This behavior is not a bug — it is an implementation detail of SwiftUI’s environment model.
