← All shorts
15iOS 17+2 min read

visualEffect: Geometry-Driven Effects, No GeometryReader

Scale scroll-carousel cards by their position using visualEffect, without wrapping each card in a GeometryReader.

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. visualEffect hands you a GeometryProxy for the view's already-computed geometry without making the view report its size upward or resize to fill a reader.
  • No PreferenceKey and no per-item @State for 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.

swiftuiios17animation

Related shorts