The following, basic SwiftUI layout divides a page into a top and bottom part, where the top has some gradient background.
I would like to keep this layout intact while adding a pull-to-refresh feature. This can be easily done by wrapping the layout inside a ScrollView
and adding the .refreshable
modifier.
However, this leads to two problems:
How can this be done?
import SwiftUI
struct PullToRefresh: View {
var body: some View {
ZStack(alignment: .topLeading) {
// Background
Color(.lightGray)
.ignoresSafeArea()
//ScrollView {
VStack(spacing: 0) {
// Top
VStack {
Text("Top")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color(.green),
Color(.red),
]),
startPoint: .bottom,
endPoint: .top
)
.cornerRadius(20)
.shadow(
color: Color(white: 0, opacity: 0.4),
radius: 5
)
.ignoresSafeArea()
)
// Bottom
VStack {
Text("Bottom")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
//}
//.refreshable {
//...
//}
}
}
}
#Preview {
PullToRefresh()
}
Instead of wrapping the content in a ScrollView
, you could try attaching a DragGesture
to the ZStack
. Then:
Add padding to the top/bottom sections, corresponding to the drag height. Use negative padding for the top section, positive padding for the bottom section.
If the padding is applied after the gradient background then the gradient automatically stretches as the height of the top section changes.
The refresh callback could be called when the drag ends. Alternatively, you could add an .onChanged
callback and call the refresh action as soon as the drag height exceeds a threshold.
See the Apple documentation for notes on implementing a custom refreshable view.
Btw, the modifier .cornerRadius
is deprecated. So another way to implement the background is to use a RoundedRectangle
. This can then be filled with the linear gradient. In fact, it is probably better to use an UnevenRoundedRectangle
, so that the top corners are not rounded. Rounded top corners might not look right on a device with square screen corners, such as an iPhone SE.
struct PullToRefresh: View {
@GestureState private var dragHeight = CGFloat.zero
@Environment(\.refresh) private var refresh
var body: some View {
ZStack(alignment: .topLeading) {
// Background
Color(.lightGray)
.ignoresSafeArea()
VStack(spacing: 0) {
// Top
VStack {
Text("Top")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
UnevenRoundedRectangle(bottomLeadingRadius: 20, bottomTrailingRadius: 20)
.fill(.linearGradient(
colors: [.green, .red],
startPoint: .bottom,
endPoint: .top
))
.shadow(
color: Color(white: 0, opacity: 0.4),
radius: 5
)
.ignoresSafeArea()
}
.padding(.bottom, -dragHeight)
// Bottom
VStack {
Text("Bottom")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, dragHeight)
}
}
.animation(.easeInOut(duration: 0.1), value: dragHeight)
.gesture(
DragGesture()
.updating($dragHeight) { val, state, trans in
state = max(0, val.translation.height)
}
.onEnded { val in
if val.translation.height > 20 {
print("performing refresh")
Task {
await refresh?()
}
}
}
)
}
}
EDIT If you want the top section to move over the bottom section when dragged then this is possible with some small changes:
Use a separate VStack
for each of the two sections, so that each section is a separate layer in the ZStack
.
In each VStack
, use Color.clear
to fill the "other half" of the screen.
The top section needs to be after the bottom section in the ZStack
, to make it the higher layer.
Attach the drag gesture to the top section only.
Don't apply any drag-padding to the bottom section.
Here it is working this way:
ZStack(alignment: .topLeading) {
// Background
Color(.lightGray)
.ignoresSafeArea()
// Bottom
VStack(spacing: 0) {
Color.clear
VStack {
Text("Bottom")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// Top
VStack(spacing: 0) {
VStack {
Text("Top")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
// ... UnevenRoundedRectangle, as before
}
.padding(.bottom, -dragHeight)
.animation(.easeInOut(duration: 0.1), value: dragHeight)
.gesture(
DragGesture()
// ... modifiers as before
)
Color.clear
}
}