ScrollView, but better

01

ScrollView got a brand new set of toys for us to play with, including tools to detect scrolling, and look at the content offset. These combined with last years visual effects can give us lots of flexibility, so lets get right in.

Scroll Position

Without a need to use a ScrollViewReader, we can now programatically control the scroll position of our ScrollView, thanks to ScrollPosition.

Adding ScrollPosition is super easy, just add one as a State property.

@State var scrollPosition: ScrollPosition = .init(id: 0)
@State var scrollPosition: ScrollPosition = .init(id: 0)

There's plenty of options for your ScrollPosition. You can initialise with an identifier that takes an ID for a view to scroll to, a particular x/y offset, and more. You can even provide the anchor to customise how the scroll position is set.

Lets look at an example of a scrolling list of items where we'll use ScrollPosition to scroll by specific amounts.

We'll add an empty scrollPosition as a state property, and assign it to the scroll view using the scrollPositon modifier.

Make sure to set the scrollTargetLayout too.

struct ContentView: View {
    @State var scrollPosition: ScrollPosition = .init()

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100, id: \.self) { index in
                    Text(index.formatted())
                        .frame(maxWidth: .infinity, minHeight: 50)
                        .background(Color.teal.gradient, in: Capsule())
                }
                .foregroundColor(.white)
            }
            .scrollTargetLayout()
            .padding()
        }
        .scrollPosition($scrollPosition)
        .overlay(alignment: .bottom) {
            HStack {
                Button(action: {
                    withAnimation {
                        self.scrollPosition.scrollTo(y: 100)
                    }
                }, label: {
                    Text("Scroll to y: 100")
                })

                Button(action: {
                    withAnimation {
                        self.scrollPosition.scrollTo(y: 1000)
                    }
                }, label: {
                    Text("Scroll to y: 1000")
                })
            }
            .padding()
            .frame(maxWidth: .infinity)
            .font(
                .subheadline.weight(.semibold)
            )
            .background(
                Color.white, in: Capsule()
            )
            .compositingGroup()
            .shadow(radius: 6)
            .padding()
        }
    }
}
struct ContentView: View {
    @State var scrollPosition: ScrollPosition = .init()

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100, id: \.self) { index in
                    Text(index.formatted())
                        .frame(maxWidth: .infinity, minHeight: 50)
                        .background(Color.teal.gradient, in: Capsule())
                }
                .foregroundColor(.white)
            }
            .scrollTargetLayout()
            .padding()
        }
        .scrollPosition($scrollPosition)
        .overlay(alignment: .bottom) {
            HStack {
                Button(action: {
                    withAnimation {
                        self.scrollPosition.scrollTo(y: 100)
                    }
                }, label: {
                    Text("Scroll to y: 100")
                })

                Button(action: {
                    withAnimation {
                        self.scrollPosition.scrollTo(y: 1000)
                    }
                }, label: {
                    Text("Scroll to y: 1000")
                })
            }
            .padding()
            .frame(maxWidth: .infinity)
            .font(
                .subheadline.weight(.semibold)
            )
            .background(
                Color.white, in: Capsule()
            )
            .compositingGroup()
            .shadow(radius: 6)
            .padding()
        }
    }
}

Now, tapping the button will scroll by the amount we requested.

If we want to scroll to a given ID, its just as easy, use scrollTo(id: 50)

Button(action: {
    withAnimation {
        self.scrollPosition.scrollTo(id: 50)
    }
}, label: {
    Text("Scroll to ID: 50")
})
Button(action: {
    withAnimation {
        self.scrollPosition.scrollTo(id: 50)
    }
}, label: {
    Text("Scroll to ID: 50")
})

If we want to scroll to a given edge, just use scrollTo(edge: .top).

Button(action: {
    withAnimation {
        self.scrollPosition.scrollTo(edge: .top)
    }
}, label: {
    Text("Scroll to Top")
})
Button(action: {
    withAnimation {
        self.scrollPosition.scrollTo(edge: .top)
    }
}, label: {
    Text("Scroll to Top")
})

ScrollPosition has some tricks up its sleeve beyond just setting the position, it also allows you to detect if a user performed the scroll or not, using isPositionedByUser, and more.

Scroll Phase Change

Scroll phases are a new tool that allow us to understand what is happening with our scroll view. Here's all the cases we have.

enum ScrollPhase {
    case idle
    case tracking
    case interacting
    case decelerating
    case animating
}
enum ScrollPhase {
    case idle
    case tracking
    case interacting
    case decelerating
    case animating
}

We can now tell if we're animating due to programatic scrolling, if we're accellerating or declerating, or if we're fully idle.

To get access to these phases, we have a new modifier onScrollPhaseChange, which gives us the old phase, new phase, and the context.

.onScrollPhaseChange({ oldPhase, newPhase, context in

})
.onScrollPhaseChange({ oldPhase, newPhase, context in

})

The Context contains a reference to the velocity which is optional, and a reference to ScrollGeometry too ( see the next section for how cool this is ).

Lets see how we can use these phases to show an overlay whilst scrolling.

All we'll do is set a boolean based on newPhase in our onScrollPhaseChange block.

struct ScrollViewDemo: View {
    @State var scrolling = false

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100, id: \.self) { index in
                    ItemView(index: index)
                }
            }
            .padding()
            .scrollTargetLayout()
        }
        .onScrollPhaseChange({ oldPhase, newPhase, context in
            scrolling = newPhase != .idle
        })
        .overlay(alignment: .bottom) {
            Text("Weeeeeee!")
                .font(.largeTitle.weight(.semibold))
                .padding()
                .background(Color.white, in: Capsule())
                .compositingGroup()
                .shadow(radius: 4)
                .opacity(scrolling ? 1 : 0)
                .scaleEffect(scrolling ? 1 : 0.5)
                .animation(.bouncy, value: scrolling)
        }
        .scrollClipDisabled()
        .background {
            Color(UIColor.systemGroupedBackground)
                .ignoresSafeArea()
        }
    }
}
struct ScrollViewDemo: View {
    @State var scrolling = false

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100, id: \.self) { index in
                    ItemView(index: index)
                }
            }
            .padding()
            .scrollTargetLayout()
        }
        .onScrollPhaseChange({ oldPhase, newPhase, context in
            scrolling = newPhase != .idle
        })
        .overlay(alignment: .bottom) {
            Text("Weeeeeee!")
                .font(.largeTitle.weight(.semibold))
                .padding()
                .background(Color.white, in: Capsule())
                .compositingGroup()
                .shadow(radius: 4)
                .opacity(scrolling ? 1 : 0)
                .scaleEffect(scrolling ? 1 : 0.5)
                .animation(.bouncy, value: scrolling)
        }
        .scrollClipDisabled()
        .background {
            Color(UIColor.systemGroupedBackground)
                .ignoresSafeArea()
        }
    }
}

A possible example of using the context is looking at the content offset when the scroll view is idle, and using it to work out if a go to top button should be shown.

This would be done by simply reading the geometry in the block, and checking if it hits a threshold you're happy with to show the button.

struct ScrollViewDemo: View {
    @State var position: ScrollPosition = .init()
    @State var showButton = false

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100, id: \.self) { index in
                    ItemView(index: index)
                }
            }
            .padding()
            .scrollTargetLayout()
        }
        .onScrollPhaseChange({ oldPhase, newPhase, context in
            showButton = newPhase == .idle && context.geometry.contentOffset.y > 200
        })
        .scrollPosition($position)
        .overlay(alignment: .bottom) {
            Button(action: {
                withAnimation {
                    position.scrollTo(edge: .top)
                }
            }, label: {
                Text("Go Back")
                    .font(.largeTitle.weight(.semibold))
                    .padding()
                    .background(Color.white, in: Capsule())
                    .compositingGroup()
                    .shadow(radius: 4)
                    .opacity(showButton ? 1 : 0)
                    .scaleEffect(showButton ? 1 : 0.5)
                    .animation(.bouncy, value: showButton)
            })
        }
        .scrollClipDisabled()
        .background {
            Color(UIColor.systemGroupedBackground)
                .ignoresSafeArea()
        }
    }
}
struct ScrollViewDemo: View {
    @State var position: ScrollPosition = .init()
    @State var showButton = false

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100, id: \.self) { index in
                    ItemView(index: index)
                }
            }
            .padding()
            .scrollTargetLayout()
        }
        .onScrollPhaseChange({ oldPhase, newPhase, context in
            showButton = newPhase == .idle && context.geometry.contentOffset.y > 200
        })
        .scrollPosition($position)
        .overlay(alignment: .bottom) {
            Button(action: {
                withAnimation {
                    position.scrollTo(edge: .top)
                }
            }, label: {
                Text("Go Back")
                    .font(.largeTitle.weight(.semibold))
                    .padding()
                    .background(Color.white, in: Capsule())
                    .compositingGroup()
                    .shadow(radius: 4)
                    .opacity(showButton ? 1 : 0)
                    .scaleEffect(showButton ? 1 : 0.5)
                    .animation(.bouncy, value: showButton)
            })
        }
        .scrollClipDisabled()
        .background {
            Color(UIColor.systemGroupedBackground)
                .ignoresSafeArea()
        }
    }
}

Scroll Geometry Change

We can now react to changes in the geometry of our scroll view. This includes changes to content offsets, content size, and more. Think being able to make a change based on the fact the user scrolled, like hiding your toolbar.

This is all thanks to the new onScrollGeometryChange modifier, which lets us provide a type we're going to use in our logic, a block to compute that type based on the ScrollGeometry, and finally an action block to do something with it.

Here's a quick example of that in action, which will generate a boolean based on the scroll view having been scrolled a little bit.

.onScrollGeometryChange(for: Bool.self) { geometry in
    geometry.contentOffset.y < geometry.contentInsets.top
} action: { wasScrolledToTop, isScrolledToTop in
    withAnimation {
        didScroll = !isScrolledToTop
    }
}
.onScrollGeometryChange(for: Bool.self) { geometry in
    geometry.contentOffset.y < geometry.contentInsets.top
} action: { wasScrolledToTop, isScrolledToTop in
    withAnimation {
        didScroll = !isScrolledToTop
    }
}

Our first closure actually does the math, and then the second sets a value based on it. Lets see a full example of that in a ScrollView.

struct ScrollViewDemo: View {
    @State private var position: ScrollPosition = .init(y: 0)
    @State var didScroll = false

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100, id: \.self) { index in
                    ItemView(index: index)
                }
            }
            .padding()
            .scrollTargetLayout()
        }
        .onScrollGeometryChange(for: Bool.self) { geometry in
            geometry.contentOffset.y < geometry.contentInsets.top
          } action: { wasScrolledToTop, isScrolledToTop in
            withAnimation {
                didScroll = !isScrolledToTop
            }
          }
        .scrollPosition($position)
        .overlay(alignment: .bottom) {
            Text("You Scrolled šŸ‘€")
                .padding()
                .background(Color.white, in: Capsule())
                .compositingGroup()
                .shadow(radius: 4)
                .opacity(didScroll ? 1 : 0)
                .scaleEffect(didScroll ? 1 : 0.4)
                .animation(.bouncy, value: didScroll)
        }
        .scrollClipDisabled()
        .background {
            Color(UIColor.systemGroupedBackground)
                .ignoresSafeArea()
        }
    }
}
struct ScrollViewDemo: View {
    @State private var position: ScrollPosition = .init(y: 0)
    @State var didScroll = false

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100, id: \.self) { index in
                    ItemView(index: index)
                }
            }
            .padding()
            .scrollTargetLayout()
        }
        .onScrollGeometryChange(for: Bool.self) { geometry in
            geometry.contentOffset.y < geometry.contentInsets.top
          } action: { wasScrolledToTop, isScrolledToTop in
            withAnimation {
                didScroll = !isScrolledToTop
            }
          }
        .scrollPosition($position)
        .overlay(alignment: .bottom) {
            Text("You Scrolled šŸ‘€")
                .padding()
                .background(Color.white, in: Capsule())
                .compositingGroup()
                .shadow(radius: 4)
                .opacity(didScroll ? 1 : 0)
                .scaleEffect(didScroll ? 1 : 0.4)
                .animation(.bouncy, value: didScroll)
        }
        .scrollClipDisabled()
        .background {
            Color(UIColor.systemGroupedBackground)
                .ignoresSafeArea()
        }
    }
}

There's plenty more for you to do with ScrollGeometry, just look at how much we get to play with.

public var contentOffset: CGPoint
public var contentSize: CGSize
public var contentInsets: EdgeInsets
public var containerSize: CGSize
public var visibleRect: CGRect
public var bounds: CGRect { get }
public var contentOffset: CGPoint
public var contentSize: CGSize
public var contentInsets: EdgeInsets
public var containerSize: CGSize
public var visibleRect: CGRect
public var bounds: CGRect { get }

If we want to read the offset, we could simply switch our call to geometry change to use a CGFloat, and instead read the offset.

.onScrollGeometryChange(for: CGFloat.self) { geometry in
    geometry.contentOffset.y
} action: { oldOffset, newOffset in
    yOffset = newOffset
    // Do something with this offset šŸ‘€
}
.onScrollGeometryChange(for: CGFloat.self) { geometry in
    geometry.contentOffset.y
} action: { oldOffset, newOffset in
    yOffset = newOffset
    // Do something with this offset šŸ‘€
}

Alternatively, you can just use the offset in your logic, like so.

.onScrollGeometryChange(for: Bool.self) { geometry in
    geometry.contentOffset.y > 100
} action: { oldDidScroll, newDidScroll in
    didScroll = newDidScroll
}
.onScrollGeometryChange(for: Bool.self) { geometry in
    geometry.contentOffset.y > 100
} action: { oldDidScroll, newDidScroll in
    didScroll = newDidScroll
}

Scroll Visibility Change

There's a brand new modifier that lets us detect when something is actually visible to the user in a scroll view, called onScrollVisibilityChange. You can say how much of the view must be visible to count as visible, and then proceed to perform something as a result of that changing.

.onScrollVisibilityChange(threshold: 0.2) { visible in
    // Do something šŸ‘€
}
.onScrollVisibilityChange(threshold: 0.2) { visible in
    // Do something šŸ‘€
}

You might want to play a video when a particular card is visible in a list, or even fade in a dark background when you get to a specific card for a fancy effect.

Using this modifier is trivial. Just provide the threshold, which is a percentage of the view that must be visible, and then the action you want to perform.

In my example, I'm going to make it so when the bottom item is visible, I show a scroll to top button.

struct ScrollViewDemo: View {
    @State private var position: ScrollPosition = .init(y: 0)
    @State var isBottom = false

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100, id: \.self) { index in
                    ItemView(index: index)
                }
                // Invisble view to allow for detection
                Rectangle().foregroundStyle(Color.clear)
                    .onScrollVisibilityChange(threshold: 0.2) { visible in
                        isBottom = visible
                    }
            }
            .padding()
            .scrollTargetLayout()
        }
        .scrollPosition($position)
        .overlay(alignment: .bottom) {
            Button(action: {
                withAnimation {
                    position.scrollTo(edge: .top)
                }
            }, label: {
                Text("Scroll to Top")
                    .padding()
                    .background(Color.white, in: Capsule())
            })
            .compositingGroup()
            .shadow(radius: 4)
            .opacity(isBottom ? 1 : 0)
            .scaleEffect(isBottom ? 1 : 0.4)
            .animation(.bouncy, value: isBottom)
        }
        .scrollClipDisabled()
        .background {
            Color(UIColor.systemGroupedBackground)
                .ignoresSafeArea()
        }
    }
}
struct ScrollViewDemo: View {
    @State private var position: ScrollPosition = .init(y: 0)
    @State var isBottom = false

    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100, id: \.self) { index in
                    ItemView(index: index)
                }
                // Invisble view to allow for detection
                Rectangle().foregroundStyle(Color.clear)
                    .onScrollVisibilityChange(threshold: 0.2) { visible in
                        isBottom = visible
                    }
            }
            .padding()
            .scrollTargetLayout()
        }
        .scrollPosition($position)
        .overlay(alignment: .bottom) {
            Button(action: {
                withAnimation {
                    position.scrollTo(edge: .top)
                }
            }, label: {
                Text("Scroll to Top")
                    .padding()
                    .background(Color.white, in: Capsule())
            })
            .compositingGroup()
            .shadow(radius: 4)
            .opacity(isBottom ? 1 : 0)
            .scaleEffect(isBottom ? 1 : 0.4)
            .animation(.bouncy, value: isBottom)
        }
        .scrollClipDisabled()
        .background {
            Color(UIColor.systemGroupedBackground)
                .ignoresSafeArea()
        }
    }
}

Im so glad I can finally remove introspection in my apps thanks to these changes, and I can't wait to see what fancy effects folks come up with now.

If you fancy reading a little more, or sharing your experiences, Iā€™m @SwiftyAlex on twitter.