← 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 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 — 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, no per-item @State for 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 VisualEffectscaleEffect, 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.

swiftuiios17animation

Related shorts