Scaling carousel cards based on how far they've scrolled from center needs each card's live frame. Reading that with GeometryReader meant wrapping the card in a container that ignores its child's intrinsic size, then plumbing the frame back up through a PreferenceKey just to use it on the same view.
#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
PreferenceKey, no per-item@Statefor a frame, no round trip through the view tree — the geometry 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. You cannot call .frame(), .padding(), or anything else that would change layout from inside a visualEffect closure — the type system won't let you, because doing so could create a feedback loop where the effect changes the very geometry it was computed from. If you need layout to actually change, that's still a job for GeometryReader or .onGeometryChange, not visualEffect.