# Scroll-driven effects: scrollTransition and visualEffect

Companion to `../SKILL.md` for the `swiftui-animation` skill. Both APIs ship
in iOS 17 (macOS 14, tvOS 17, watchOS 10, visionOS 1).

## Contents

1. [Which one](#1-which-one)
2. [scrollTransition](#2-scrolltransition)
3. [Configurations and thresholds](#3-configurations-and-thresholds)
4. [visualEffect](#4-visualeffect)
5. [What a VisualEffect can do](#5-what-a-visualeffect-can-do)
6. [Concurrency: the closures are Sendable](#6-concurrency-the-closures-are-sendable)
7. [Reduce Motion](#7-reduce-motion)
8. [Mistakes](#8-mistakes)

## 1. Which one

| Need | API |
|---|---|
| Items fade, scale or tilt as they enter and leave the visible area of a scroll view | `scrollTransition(_:axis:transition:)` |
| An effect that is a continuous function of the view's position or size (parallax, a header that shrinks, a stretch at the top) | `visualEffect(_:)` with the `GeometryProxy` |
| React to scroll position in state (show a button after 300 pt) | `onScrollGeometryChange(for:of:action:)` (iOS 18), not an effect |

Both apply effects that do not change layout, so they are cheap to run every
frame and safe inside lazy stacks.

## 2. scrollTransition

```swift
ScrollView {
    LazyVStack(spacing: 16) {
        ForEach(destinations) { destination in
            DestinationCard(destination: destination)
                .scrollTransition { content, phase in
                    content
                        .opacity(phase.isIdentity ? 1 : 0.4)
                        .scaleEffect(phase.isIdentity ? 1 : 0.92)
                }
        }
    }
    .padding(.horizontal)
}
```

`ScrollTransitionPhase` has three cases:

- `.topLeading`: the view is entering or leaving at the top (vertical) or
  leading (horizontal) edge.
- `.identity`: the view is in the visible region.
- `.bottomTrailing`: entering or leaving at the bottom or trailing edge.

`phase.value` is -1 at `.topLeading`, 0 at `.identity` and 1 at
`.bottomTrailing`, interpolated in between, so effects can scale with it:

```swift
.scrollTransition(axis: .horizontal) { content, phase in
    content
        .rotation3DEffect(.degrees(phase.value * -25), axis: (x: 0, y: 1, z: 0))
        .offset(x: phase.value * -20)
}
```

In the `.identity` phase the transition should leave the view unchanged,
because whatever it applies there stays on for as long as the view is visible.

Different behaviour per edge:

```swift
.scrollTransition(topLeading: .interactive, bottomTrailing: .animated(.smooth)) { content, phase in
    content.opacity(phase.isIdentity ? 1 : 0)
}
```

## 3. Configurations and thresholds

| `ScrollTransitionConfiguration` | Behaviour |
|---|---|
| `.interactive` (default) | Tracks the scroll position directly |
| `.interactive(timingCurve:)` | Same, with a `UnitCurve` shaping the progress |
| `.animated`, `.animated(_:)` | Animates to the new phase when the threshold is crossed |
| `.identity` | No transition |

`.threshold(_:)` decides when a view counts as visible:
`.visible` (any part visible), `.visible(0.9)` (90% visible), `.centered`,
`.hidden`, and `.inset(by:)` or `.interpolated(towards:amount:)` to adjust.

```swift
.scrollTransition(.interactive.threshold(.visible(0.9))) { content, phase in
    content.blur(radius: phase.isIdentity ? 0 : 6)
}
```

## 4. visualEffect

`visualEffect(_:)` hands the closure an empty effect and the view's
`GeometryProxy`. Read position in the scroll view's coordinate space
(`.scrollView`, iOS 17) and return effects:

```swift
struct ParallaxHeader: View {
    let image: Image

    var body: some View {
        image
            .resizable()
            .scaledToFill()
            .frame(height: 280)
            .clipped()
            .visualEffect { content, proxy in
                let minY = proxy.frame(in: .scrollView).minY
                let stretch = minY > 0 ? 1 + minY / 600 : 1
                return content
                    .offset(y: minY > 0 ? -minY / 2 : 0)
                    .scaleEffect(x: stretch, y: stretch, anchor: .bottom)
            }
    }
}
```

The same pattern fades a title as it reaches the top bar, or dims rows that
sit under a floating control.

## 5. What a VisualEffect can do

Effects returned from either closure: `offset`, `scaleEffect`,
`rotationEffect`, `rotation3DEffect`, `opacity`, `blur(radius:opaque:)`,
`brightness`, `contrast`, `saturation`, `grayscale`, `hueRotation`,
`blendMode`, and the shader effects `colorEffect`, `distortionEffect` and
`layerEffect`. Frames, padding and other layout modifiers are not available,
by design.

## 6. Concurrency: the closures are Sendable

Both closures are `@Sendable`. Do not read main-actor state or non-Sendable
properties inside them; capture plain values first:

```swift
let strength = isEmphasized ? 1.0 : 0.5
Card()
    .scrollTransition { content, phase in
        content.scaleEffect(1 - abs(phase.value) * 0.1 * strength)
    }
```

## 7. Reduce Motion

Scroll-linked motion is still motion. With
`@Environment(\.accessibilityReduceMotion)` set, keep opacity changes and drop
movement, rotation and parallax, or pass `.identity`:

```swift
@Environment(\.accessibilityReduceMotion) private var reduceMotion

var body: some View {
    let configuration: ScrollTransitionConfiguration = reduceMotion ? .identity : .interactive
    PosterCard(poster: poster)
        .scrollTransition(configuration) { content, phase in
            content
                .opacity(phase.isIdentity ? 1 : 0.5)
                .scaleEffect(phase.isIdentity ? 1 : 0.9)
        }
}
```

For `visualEffect` parallax, return `content` unchanged when the setting is on;
capture `reduceMotion` in a local constant before the closure (see section 6).

## 8. Mistakes

1. **Layout work in the closure.** It runs every frame; compute constants
   outside.
2. **A visible change in the `.identity` phase.** It stays on permanently.
3. **Reaching for `GeometryReader` plus `@State` offsets** to build parallax.
   `visualEffect` does it without invalidating the view on every scroll tick.
4. **Effects that hide content or make text unreadable** at rest; transitions
   belong to the edges.
5. **Ignoring Reduce Motion** for 3D rotation and parallax.

Sources:
[scrollTransition(_:axis:transition:)](https://developer.apple.com/documentation/swiftui/view/scrolltransition(_:axis:transition:)),
[ScrollTransitionPhase](https://developer.apple.com/documentation/swiftui/scrolltransitionphase),
[ScrollTransitionConfiguration](https://developer.apple.com/documentation/swiftui/scrolltransitionconfiguration),
[visualEffect(_:)](https://developer.apple.com/documentation/swiftui/view/visualeffect(_:)),
[VisualEffect](https://developer.apple.com/documentation/swiftui/visualeffect).
