Here is my problem: I have a ScrollView with a list of elements:
ScrollView {
VStack(spacing: 3) {
ForEach(testObjects) { obj in
ObjItem(obj: obj)
}
}
}
The ObjItem looks like this:
struct ObjItem: View {
@State var pos: CGFloat = 0
@State var height: CGFloat = 100
var body: some View {
Image(uiImage: UIImage(named: obj.picture)!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: scrollPos: pos, fullHeight: width / 1280 * 800, alignment: .top)
.cornerRadius(5)
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .scrollView)
} action: {
pos = $0.minY
height = calcHeight(scrollPos: pos, fullHeight: $0.width / 1280 * 800)
}
}
}
I want to dynamically change the frame height of the element depending on its position in the ScrollView:
func calcHeight(scrollPos: CGFloat, fullHeight: CGFloat) -> CGFloat {
if scrollPos < 0 {
return fullHeight
} else if scrollPos < fullHeight {
return 100 + (1 - scrollPos / fullHeight) * (fullHeight - 100)
} else {
return 100
}
}
The effect is best seen in the screenshot.
Basically this works fine in the preview, but when I start the app in the emulator or on my iPhone, I get "Geometry action is cycling between duplicate values." messages and after some scrolling the app crashes. Looks like, changing the frame height is also changing the position in the ScrollView, which changes the frame height ... some unwanted loop!
So well, I dismissed .onGeometryChange and use a GeometryReader to get pos and width for the element:
Image(uiImage: wingImage())
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: calcHeight(pos: pos), alignment: .top)
.background {
GeometryReader { proxy in
Color.clear
(...)
}
}
This also works, but is noticeable slow on my iPhone 14. The scrolling is stuttering, but no crashes.
Any ideas how to solve this one? Am I completely wrong using .onGeometryChange or GeometryReader? Is there a simpler solution?
I think the reason why the updates are "cycling between duplicate values" or why the animation is stuttering is because a change in height of the image at the top has a ripple effect on all the images below it. Likewise for the second and even the third images, depending on what the full height computes to.
Also, if there are a lot of items being shown then there will be a lot of updates happening. Every time the scroll position changes, .onGeometryChange
will fire separately for every single item.
As a way to fix, the height for an image can be computed from the absolute scroll offset of the VStack
, instead of the individual scroll position of each image.
In the definition of ObjItem
that you provided, there are some issues with the way the .frame
modifier is being applied:
.frame(height: scrollPos: pos, fullHeight: width / 1280 * 800, alignment: .top)
It looks like there is a typo here (height:
should probably not be there) and it is not clear, where width
is coming from. But assuming that the width is the width of the screen, as opposed to the ideal width of an image, it means the full height is the same for all images.
Below is an updated version of the example to show how the height of an image can be computed from the absolute scroll offset. Some notes:
It works on the assumption that the full height is the same for all images and this is computed from the screen width.
A GeometryReader
is used to measure the screen width.
The scroll offset is measured by applying .onGeometryChange
to the VStack
.
Another way to measure the scroll offset would be to apply .onScrollGeometryChange
to the ScrollView
. However, this would require iOS 18 and, tbh, it is no simpler.
struct Obj {
let picture: String
}
struct ContentView: View {
let spacing: CGFloat = 3
let testObjects: [Obj] // = [ ... ]
@State private var scrollPos = CGFloat.zero
private func heightForImage(index: Int, fullHeight: CGFloat) -> CGFloat {
let nScrolled = Int(-scrollPos / (fullHeight + spacing))
if index <= nScrolled {
return fullHeight
} else if index == nScrolled + 1 {
let imageOffset = scrollPos + (CGFloat(nScrolled) * (fullHeight + spacing))
return 100 + (-imageOffset / fullHeight) * (fullHeight - 100)
} else {
return 100
}
}
var body: some View {
GeometryReader { proxy in
let fullHeight = proxy.size.width / 1280 * 800
ScrollView {
VStack(spacing: spacing) {
ForEach(Array(testObjects.enumerated()), id: \.offset) { index, obj in
ObjItem(obj: obj)
.frame(height: heightForImage(index: index, fullHeight: fullHeight))
}
}
.onGeometryChange(for: CGFloat.self) { geo in
geo.frame(in: .scrollView).minY
} action: { minY in
scrollPos = minY
}
}
}
}
}
struct ObjItem: View {
let obj: Obj
var body: some View {
Color.clear
.overlay(alignment: .top) {
Image(uiImage: UIImage(named: obj.picture)!)
.resizable()
.scaledToFill()
}
.clipShape(.rect(cornerRadius: 5))
}
}
EDIT If you want to change the VStack
to a LazyVStack
and you find that the solution above doesn't work, it might help to use a ZStack
to combine the LazyVStack
with a background placeholder. The placeholder should reserve all the height that is needed when the items are fully expanded. The .onGeometryChange
callback can then be attached to this placeholder.
There is another, more fundamental, issue too. When you scroll to the bottom, the last images never get expanded, because they don't reach the top of the screen. One way to work around this issue is to include some blank space when calculating the height of the background placeholder, so that the last image ends up at the top of the screen:
private func fullyExpandedHeight(fullHeight: CGFloat) -> CGFloat {
let nImages = CGFloat(testObjects.count)
return (nImages * fullHeight) + ((nImages - 1) * spacing)
}
GeometryReader { proxy in
let fullHeight = proxy.size.width / 1280 * 800
let expandedHeight = fullyExpandedHeight(fullHeight: fullHeight)
let blankSpaceBelowLastImage = proxy.size.height - fullHeight
ScrollView {
ZStack(alignment: .top) {
Color.clear
.frame(height: expandedHeight + blankSpaceBelowLastImage)
.onGeometryChange(for: CGFloat.self) { geo in
geo.frame(in: .scrollView).minY
} action: { minY in
scrollPos = minY
}
LazyVStack(spacing: spacing) {
ForEach(Array(testObjects.enumerated()), id: \.offset) { index, obj in
ObjItem(obj: obj)
.frame(height: heightForImage(index: index, fullHeight: fullHeight))
}
}
}
}
}
If you want to keep the last image anchored to the bottom of the screen and still have it expand then this is a more complex problem. If you need help finding a solution, I would suggest making it the topic of a new question.