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.
In my app, I have a header with two images, no idea how to do it, code below.
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.
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.