Scaling carousel cards by how far they've scrolled from center needs each card's live frame. GeometryReader made that expensive: wrap the card in a container that ignores its child's intrinsic size, push the frame up through a PreferenceKey, then read it back down to use on the same view you started from.
#The old way
CardView(item: item)
.background(
GeometryReader { proxy in
Color.clear.preference(
key: FramePreferenceKey.self,
value: proxy.frame(in: .named("carousel"))
)
}
)
.onPreferenceChange(FramePreferenceKey.self) { frame = $0 }
.scaleEffect(scale(for: frame))
Every card needs its own frame storage. And GeometryReader's greedy sizing forces an explicit .frame() back onto the card just to undo it.
#The new way
import SwiftUI
struct CardCarousel: View {
let items = Array(1...10)
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(items, id: \.self) { item in
RoundedRectangle(cornerRadius: 20)
.fill(.blue.gradient)
.frame(width: 160, height: 220)
.overlay(
Text("\(item)")
.font(.title)
.foregroundStyle(.white)
)
.visualEffect { content, proxy in
content.scaleEffect(scale(for: proxy))
}
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
}
func scale(for proxy: GeometryProxy) -> CGFloat {
let midX = proxy.frame(in: .scrollView).midX
let containerWidth = proxy.bounds(of: .scrollView)?.width ?? 0
let distance = abs(midX - containerWidth / 2)
let normalized = min(distance / max(containerWidth / 2, 1), 1)
return 1 - (normalized * 0.2)
}
}
#Why it matters
- The card's own layout is untouched.
visualEffecthands you aGeometryProxyfor the view's already-computed geometry without making the view report its size upward or resize to fill a reader. - No
PreferenceKeyand no per-item@Statefor a frame. The geometry never round trips through the view tree, because it and the effect it drives live in one closure. .frame(in: .scrollView)and.bounds(of: .scrollView)give scroll-relative geometry directly, which is exactly what a scroll-driven effect needs.
#Gotcha
The closure only accepts modifiers that conform to VisualEffect: scaleEffect, offset, opacity, blur, rotation3DEffect, and similar non-layout-affecting effects. .frame(), .padding(), or anything else that changes layout won't compile inside a visualEffect closure. That's deliberate, since changing layout there could feed the effect back into the very geometry it was computed from. Need layout to actually change? That's still a job for GeometryReader or .onGeometryChange, not visualEffect.