swiftui

Make a header in ScrollView stick to the top when pulled down, optionally, also make it stretch when pulled down


I would like to recreate a behaviour from an app called Gentler Streaks in SwiftUI. There seems to be a header represented by an image and when pulled down, it sticks to the top of the view and the image stretches.

Desired outcome

In my app, I have a header with two images, no idea how to do it, code below.

My app


import SwiftUI
import MapKit

struct PredatorDetail: View {
    let predator: ApexPredator
    @State var position: MapCameraPosition

    
    var body: some View {
        GeometryReader {geo in
            ScrollView {
                Section {
                    LazyVStack(alignment: .leading) {
                        //dino name
                        Text(predator.name)
                            .font(.largeTitle)
                        
                        //current location
                        NavigationLink {
                            PredatorMap(position: .camera(MapCamera(centerCoordinate: predator.location, distance: 1000, heading: 250, pitch: 80)))
                        } label: {
                            Map(position: $position) {
                                Annotation(predator.name, coordinate: predator.location) {
                                    Image(systemName: "mappin.and.ellipse")
                                        .font(.largeTitle)
                                        .imageScale(.large)
                                        .symbolEffect(.pulse)
                                }
                                .annotationTitles(.hidden)
                            }
                            .frame(height: 125)
                            
                            .overlay(alignment: .trailing) {
                                Image(systemName: "greaterthan")
                                    .imageScale(.large)
                                    .font(.title3)
                                    .padding(.trailing, 5)
                            }
                            .overlay(alignment: .topLeading) {
                                Text("Current Location")
                                    .padding([.leading, .bottom], 5)
                                    .padding(.trailing, 8)
                                    .background(.black.opacity(0.7))
                                    .clipShape(.rect(bottomTrailingRadius:15))
                            }
                            .clipShape(.rect(cornerRadius: 15))
                        }
                        
                        // apears in
                        Text("Appears In:")
                            .font(.title3)
                        
                        ForEach(predator.movies, id: \.self) { movie in
                            Text("•" + movie)
                                .font(.subheadline)
                        }
                        // move momens
                        Text("Movie Moments:")
                            .font(.title)
                            .padding(.top, 15)
                        
                        ForEach(predator.movieScenes) { scene in
                            Text(scene.movie)
                                .font(.title2)
                                .padding(.vertical, 1)
                            Text(scene.sceneDescription)
                                .padding(.bottom, 15)
                            
                            
                            
                        }
                        Text("Read More")
                            .font(.caption)
                        
                        Link(predator.link, destination: URL(string: predator.link)!)
                            .font(.caption)
                            .foregroundStyle(.blue)
                        
                        
                        // link to webpage
                    }
                    .padding()
                    .padding(.bottom, 30)
                    .frame(width: geo.size.width, alignment: .leading)
                } header: {
                    ZStack (alignment: .bottomTrailing) {
                        // backgorund image
                        Image(predator.type.rawValue)
                            .resizable()
                            .scaledToFit()
                            .overlay {
                                LinearGradient(stops: [Gradient.Stop(color: .clear, location: 0.8), Gradient.Stop(color: .black, location: 1)], startPoint: .top, endPoint: .bottom)
                            }
                        // dino image
                        Image(predator.image)
                            .resizable()
                            .scaledToFit()
                            .frame(width: geo.size.width/1.5, height: geo.size.height/3)
                            .scaleEffect(x: -1)
                            .shadow(color: .black, radius: 7)
                            .offset(y: 30)
                        
                    }
                    .padding(.bottom, 10)
                }
            }
            .ignoresSafeArea()
        }
        .toolbarBackground(.automatic)
    }
}

#Preview {
    PredatorDetail(predator: Predators().apexPredators[10], position: .camera(MapCamera(centerCoordinate: Predators().apexPredators[10].location, distance: 30000)))
        .preferredColorScheme(.dark)
}

I'm actually clueless, haven't found a solution.


Solution

  • You can use onScrollGeometryChange to detect when the scroll view is "overscrolled". This is when contentOffset.y + contentInsets.top is less than 0. You can use this value to determine how much larger the image should be.

    For example:

    struct ContentView: View {
        @State var scale: CGFloat = 1
        @State var offset: CGFloat = 0
        var body: some View {
            ScrollView {
                Section {
                    ForEach(0..<100) { _ in
                        Text("Scrollable Content")
                            .frame(maxWidth: .infinity)
                    }
                } header: {
                    Image("some image")
                        .resizable()
                        .frame(width: 100, height: 100)
                        .scaleEffect(scale, anchor: .top)
                        .offset(y: offset)
                }
            }
            .onScrollGeometryChange(for: CGFloat.self) { geo in
                -min(0, geo.contentOffset.y + geo.contentInsets.top)
            } action: { _, overscrolledAmount in
                scale = 1 + (overscrolledAmount / 150)
                offset = -overscrolledAmount
            }
        }
    }
    

    Here I used 1 + (overscrolledAmount / 150) so the scale changes linearly with the overscrolled amount. Try experimenting with different kinds of relations (logarithmic, square root, etc).

    I also offseted the image upwards by the overscrolled amount, so that it doesn't get scrolled down along with the other content.