# Scroll Views

Scroll view guidance for the `swiftui-layout-components` skill.

## When to use ScrollView

Pair `ScrollView` with `LazyVStack`, `LazyHStack` or `LazyVGrid` when you need
a custom layout, mixed content, or horizontal or grid scrolling.

- Chat and custom feeds: `ScrollView` + `LazyVStack`.
- Chips, tags, avatars, media strips: `ScrollView(.horizontal)` + `LazyHStack`.
- Grids: `LazyVGrid`, with adaptive columns as the first choice.
- Jumping to an item, to an edge or to a point: `ScrollPosition`.
- An input bar that stays above the keyboard: `safeAreaInset(edge:)`.

## Conversation screen

```swift
struct SupportChat: View {
    let lines: [ChatLine]
    @State private var draft = ""
    @State private var position = ScrollPosition(edge: .bottom)

    var body: some View {
        ScrollView {
            LazyVStack(alignment: .leading) {
                ForEach(lines) { ChatLineView(line: $0) }
            }
            .scrollTargetLayout()
            .padding(.horizontal)
        }
        .scrollPosition($position)
        .onChange(of: lines.last?.id) {
            withAnimation { position.scrollTo(edge: .bottom) }
        }
        .safeAreaInset(edge: .bottom) {
            HStack {
                TextField("Reply", text: $draft)
                Button("Send", systemImage: "paperplane.fill") { send() }
            }
            .padding()
            .background(.bar)
        }
    }

    private func send() {}
}
```

## ScrollPosition

`ScrollPosition` arrived in iOS 18 and takes over from `ScrollViewReader`. It
is a value you bind, it works in both directions, and there is no reader
closure to wrap your content in.

1. Store it: `@State private var position = ScrollPosition(idType: Track.ID.self)`.
2. Put `.scrollTargetLayout()` on the lazy stack so item identities are tracked.
3. Bind it on the scroll view: `.scrollPosition($position)`.

| Goal | Call |
|------|------|
| Scroll to an item | `position.scrollTo(id: track.id, anchor: .top)` |
| Scroll to an edge | `position.scrollTo(edge: .bottom)` |
| Read the item at the anchor | `position.viewID(type: Track.ID.self)` |
| Know the user moved it | `position.isPositionedByUser` |

```swift
.onChange(of: position.isPositionedByUser) { _, byUser in
    showJumpToLatest = byUser
}
```

Keep ids stable; if they change, the position jumps. Wrap `scrollTo(id:)` in
`withAnimation` when the move should be visible.

## More shapes

```swift
ScrollView(.horizontal, showsIndicators: false) {
    LazyHStack(spacing: 8) {
        ForEach(tags) { TagChip(tag: $0) }
    }
    .padding(.horizontal)
}

ScrollView {
    LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
        ForEach(albums) { AlbumTile(album: $0) }
    }
    .padding()
}
```

Choose lazy stacks for large or unknown counts, plain stacks for small fixed
content where lazy bookkeeping is wasted.

## Scroll edge effects (iOS 26)

`.scrollEdgeEffectStyle(_:for:)` sets the effect per edge:

```swift
ScrollView { ArticleBody() }
    .scrollEdgeEffectStyle(.soft, for: .top)
    .scrollEdgeEffectStyle(.hard, for: .bottom)
```

| `ScrollEdgeEffectStyle` | Look |
|-------------------------|------|
| `.automatic` | Platform default |
| `.soft` | Content fades out at the edge |
| `.hard` | Sharp cutoff with a dividing line |

`.scrollEdgeEffectHidden(true, for: .top)` removes the effect on that edge.
Apply these modifiers to the scroll view itself, not to views inside it.

### Background extension

`.backgroundExtensionEffect()` (iOS 26) copies a view, mirrors it and blurs it
into the area behind safe-area edges, for example a hero image under the
sidebar of a split view:

```swift
NavigationSplitView {
    SidebarList()
} detail: {
    Image("coastline")
        .resizable()
        .aspectRatio(contentMode: .fill)
        .backgroundExtensionEffect()
}
```

Apple advises a single instance per screen for clarity and performance. The
modifier clips the view so the mirrored copies do not overlap it.

### Safe area bars

`.safeAreaBar(edge:)` (iOS 26) attaches a bar that takes part in the scroll
edge effect, unlike a plain inset:

```swift
ScrollView { ResultsGrid() }
    .safeAreaBar(edge: .top) { FilterStrip() }
```

## Pitfalls

- Two scroll views on the same axis, one inside the other.
- `List` and `ScrollView` combined without a strong reason.
- `LazyVStack` for three rows: extra machinery for nothing.
- `scrollEdgeEffectStyle` placed on inner content instead of the scroll view.
- More than one `backgroundExtensionEffect()` on a screen.
