Search code examples
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:

  • The layout collapses to its min-height instead of keeping its 50% + 50% split.
  • When pulling down the top-half with its gradient is also pulled down. The gradient should stick to the top edge and stretch.

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:

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

    Aniimation


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

    Animation