Andrei Herford
Andrei Herford

Reputation: 18765

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

Upvotes: 0

Views: 69

Answers (1)

Benzy Neez
Benzy Neez

Reputation: 21720

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

Upvotes: 2

Related Questions