ScrollView, but better
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.