I am working on a IOS SwiftUI project where I’m trying to replicate this kind of animate on scroll behaviour (not sure if it has a name):
What I want : As the user scroll, multiple views appears, disappears (with fades, animations), multiple text views are scrolling on top, etc.. The exemple has been done in CSS, I want to do it in SwiftUI.
My first attempt is this :
Scrollview
Vstack
for scrolling content (text)
ScrollId
to observe user position
.Id
set manually on the different content
ZStack
for the fixed content .allowsHitTesting(false)
If condition on ScrollId
to make fixed content appear and disappear
(SEE CODE BELOW)
It works, but it becomes messy very fast (manual ID attribution, fixed elements being at the end of the code etc...). Is that really the best way ?
Then, I want those blue charts to be animated on scroll (like in the exemple). Doing that solely by observing the individual ScrollID
changes with if conditions seems extremely heavy.
Animating the blue chart : Nothing triggers the appearance of the blue chart except the ScrollID
change. How can I make each of those rectangle appear gradually in scale for ex? Or animate their opacity etc ? Do I need to create a variable for each of them?
Cant use onappear to animate anything : I read somewhere that in a scrollview
, views are loading before appearing on a screen, therefore, not really working. Indeed, it would have been handy but doesn't work as expected.
Some screenshots :
The scroll content
The fixed content that appears when ScrollID reaches 7
Thanks a lot for the help
import SwiftUI
struct ContentView: View {
@State private var scrollID: Int?
var body: some View {
ZStack {
Color.black
ScrollView (.vertical) {
VStack {
// SCROLL CONTENT HERE
Spacer()
.frame(height: 200)
.id(1)
Text("Curabitur in facilisis ex. Fusce eget molestie ante, eget varius arcu. ")
.font(.caption)
.frame(width: 200)
.padding(20)
.background(Color.white)
.id(2)
Spacer()
.frame(height: 200)
.id(3)
Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque dictum ligula non arcu iaculis bibendum. Curabitur in facilisis ex. Fusce eget molestie ante, eget varius arcu. ")
.font(.caption)
.frame(width: 200)
.padding(20)
.background(Color.white)
.id(4)
Spacer()
.frame(height: 200)
.id(5)
Rectangle()
.fill(Color.gray)
.frame(width:300, height: 300)
.id(6)
// Break in the content to display the fixed element (blue rectangle)
Spacer()
.frame(height: 1200)
.id(7)
// end of the break
Text("Pellentesque dictum ligula non arcu iaculis bibendum. Curabitur in facilisis ex. Fusce eget molestie ante, eget varius arcu. ")
.font(.caption)
.frame(width: 200)
.padding(20)
.background(Color.white)
.id(8)
Spacer()
.frame(height: 200)
.id(9)
Rectangle()
.fill(Color.gray)
.frame(width:300, height: 300)
.id(10)
}
.ignoresSafeArea()
.scrollTargetLayout()
}
.scrollPosition(id: $scrollID)
.onChange(of: scrollID) {
oldValue, newValue in
print(newValue ?? "")}
// FIXED CONTENT HERE (blue chart)
Group {
if scrollID == 7 {
HStack{
ForEach(0..<10) { index in
Rectangle()
.fill(Color.blue)
.frame(width: 20, height: 200)
}
}
}
}
// To prevent scroll to be stuck
.allowsHitTesting(false)
}
.ignoresSafeArea()
}
}
#Preview {
ContentView()
}
If you want the updates to be more granular then I would suggest basing it on the scrolled offset. This can be found by using a GeometryReader
in the background of the scrolled content.
Another GeometryReader
at the outer level allows the screen height to be measured. Using this, the scrolled fraction can be computed.
You can keep the ids and scrollID
for the purpose of progammatic scrolling, if you want to, but they're no longer needed for the graphical effect.
@State private var scrollID: Int?
@State private var scrolledFraction = CGFloat.zero
private let nProgressBars = 10
private func scrollDetector(screenHeight: CGFloat) -> some View {
GeometryReader { proxy in
let minY = proxy.frame(in: .scrollView).minY
Color.clear
.onChange(of: minY) { oldVal, newVal in
scrolledFraction = -minY / (proxy.size.height - screenHeight)
}
}
}
private func opacityForBar(n: Int) -> CGFloat {
let result: CGFloat
let progress = scrolledFraction * CGFloat(nProgressBars)
let nFullBars = Int(progress)
if n < nFullBars {
result = 1
} else if n == nFullBars {
result = progress - CGFloat(nFullBars)
} else {
result = 0
}
return result
}
var body: some View {
GeometryReader { outer in
ZStack {
Color.black
ScrollView(.vertical) {
VStack {
// SCROLL CONTENT HERE (as before)
}
.background(scrollDetector(screenHeight: outer.size.height))
.scrollTargetLayout()
}
.scrollPosition(id: $scrollID)
// FIXED CONTENT HERE (blue chart)
HStack{
ForEach(0..<nProgressBars, id: \.self) { index in
Rectangle()
.fill(Color.blue)
.frame(width: 20, height: 200)
.opacity(opacityForBar(n: index))
}
}
// To prevent scroll to be stuck
.allowsHitTesting(false)
}
}
.ignoresSafeArea()
}
EDIT Following from your comment, what you can do is replace the Spacer
with the scroll detector at the point where you want the graphic to show. Then change the logic for computing the scroll position and the opacity. The outer GeometryReader
needs to be put around the ScrollView
instead of the ZStack
, so that it is delivering the height of the scroll region rather than the height of the screen (although the size might actually be the same).
I had a go:
@State private var scrollID: Int?
@State private var scrolledFraction = CGFloat.zero
private let nProgressBars = 10
private let fullBarHeight: CGFloat = 200
private func computeScrolledFraction(
spacerFrame: CGRect,
spacerHeight: CGFloat,
scrollRegionHeight: CGFloat
) -> CGFloat {
let result: CGFloat
let yBegin = scrollRegionHeight / 2
let yEnd = (scrollRegionHeight + fullBarHeight) / 2
if spacerFrame.minY < yBegin {
if spacerFrame.maxY > yEnd {
// graphic region has been reached, compute the fraction to show
result = (yBegin - spacerFrame.minY) / (spacerHeight - (yEnd - yBegin))
} else {
// reached the end, start to fade out (use a fraction > 1)
let dy = yEnd - spacerFrame.maxY
result = dy < fullBarHeight
? 1 + ((fullBarHeight - dy) / fullBarHeight)
: 0
}
} else {
// graphic region not reached yet
result = 0
}
return result
}
private func scrollDetector(scrollRegionHeight: CGFloat) -> some View {
GeometryReader { proxy in
let frame = proxy.frame(in: .scrollView)
let fraction = computeScrolledFraction(
spacerFrame: frame,
spacerHeight: proxy.size.height,
scrollRegionHeight: scrollRegionHeight
)
Color.clear
.onChange(of: fraction) { oldVal, newVal in
scrolledFraction = newVal
}
}
}
private func opacityForBar(n: Int) -> CGFloat {
let result: CGFloat
if scrolledFraction > 1 {
result = max(0, min(1, scrolledFraction - 1))
} else {
let progress = scrolledFraction * CGFloat(nProgressBars)
let nFullBars = Int(progress)
if n < nFullBars {
result = 1
} else if n == nFullBars {
result = progress - CGFloat(nFullBars)
} else {
result = 0
}
}
return result
}
var body: some View {
ZStack {
Color.black
GeometryReader { outer in
ScrollView(.vertical) {
VStack {
// SCROLL CONTENT HERE
// As before, except for the break:
// Break in the content to display the fixed element (blue rectangle)
scrollDetector(scrollRegionHeight: outer.size.height)
.frame(height: 1200)
.id(7)
// end of the break
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrollID)
}
// FIXED CONTENT HERE (blue chart)
HStack{
ForEach(0..<nProgressBars, id: \.self) { index in
Rectangle()
.fill(Color.blue)
.frame(width: 20, height: fullBarHeight)
.opacity(opacityForBar(n: index))
}
}
// To prevent scroll to be stuck
.allowsHitTesting(false)
}
.ignoresSafeArea()
}
If you want the graphic to start appearing a bit earlier or later, or start fading out either earlier or later, you just need to change the way that yBegin
and yEnd
and being determined in the function computeScrolledFraction
.