swiftuipull-to-refreshsticky-header

How to create a layout with PullToRefresh and a stretchy header?


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 .refreshablemodifier.

However, this leads to two problems:

How can this be done?

enter image description here

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()
}

Solution

  • Instead of wrapping the content in a ScrollView, you could try attaching a DragGesture to the ZStack. Then:

    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?()
                            }
                        }
                    }
            )
        }
    }
    

    Aniimation


    EDIT If you want the top section to move over the bottom section when dragged then this is possible with some small changes:

    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
        }
    }
    

    Animation