Changing the default behaviour of a scroll view to center content only when it’s smaller than the scroll view container.
Adapting To Content Size Changes With A Scroll View
I have a view that shows the state and progress of a database update process:
struct ContentView: View {
let state: UpdateState
var body: some View {
NavigationStack {
UpdaterView(state: state)
}
}
}
Here’s how it looks at the default dynamic type size on an iPhone SE:
My UpdaterView
is a VStack
containing a label, progress view and an error view. The error view is empty unless there is an error:
private struct UpdaterView: View {
let state: UpdateState
var body: some View {
VStack(alignment: .center) {
Label("Database updates", systemImage: "gear")
.labelStyle(UpdaterLabelStyle())
.padding()
ProgressView(value: state.value) {
Text(state.title)
.padding(.bottom)
}
.padding()
ErrorView(error: state.error)
.padding()
}
.multilineTextAlignment(.center)
.background(.regularMaterial,
in: RoundedRectangle(cornerRadius: 12))
}
}
The vertical size of the view can change dramatically depending on the dynamic type size and the size of any error message. At the larger contents sizes it quickly becomes too big for some devices:
This is familiar territory when adapting for dynamic type sizes. Embedding the whole view in a scroll view solves that problem.
ScrollView {
UpdaterView(state: state)
}
Here’s how that looks at the largest dynamic type sizes:
Unfortunately, there’s a new problem. When the content height fits in the scroll view container I want it vertically centered in the screen. The default scroll view behaviour puts the content at the top of the screen:
Default Scroll Anchor (iOS 17)
Apple added the defaultScrollAnchor(_:)
modifier to scroll views in iOS 17. The documentation suggests we use this to change how the scroll view initially positions the content:
Use this modifier to specify an anchor to control both which part of the scroll view’s content should be visible initially and how the scroll view handles content size changes.
The modifier takes an anchor point on the content (.top
, .center
, .bottom
, etc.). So with a default anchor of .center
our content starts in the center of the scroll views container:
ScrollView {
UpdaterView(state: state)
}
.defaultScrollAnchor(.center)
That helps until our content grows larger than the scroll view container. The top of the content is now initially offscreen:
We only want to center the content when it’s smaller than the scroll view container size. When the content is larger we want to start with the top of the content. There’s no obvious way to do that with the defaultScrollAnchor
as provided by iOS 17.
Scroll Anchor Role (iOS 18)
In iOS 18, Apple gaves us a new scroll view modifier, defaultScrollAnchor(_:for:)
with finer grained controls. This has a role parameter that allows us to customize the default behaviour for three different situations (roles):
- .initialOffset: Where a scroll view should initially position content.
- .sizeChanges: How to handle content or container size changes.
- .alignment: How to align content smaller than the container size.
It’s the last role, .alignment
, that fits our needs. Applying the modifier to center the content only for the alignment role without changing the default initial offset.
ScrollView {
UpdaterView(state: state)
}
.defaultScrollAnchor(.center, for: .alignment)
Now when the content is larger than the scroll view container it starts at the top, but when it’s smaller it’s centered.
I don’t need it but you can apply the two variants of the view modifier together to first change the default and then override the anchor for specific roles. For example, to default to using a bottom anchor but keep a center anchor for when the content is smaller than the container:
ScrollView {
UpdaterView(state: state)
}
.defaultScrollAnchor(.bottom)
.defaultScrollAnchor(.center, for: .alignment)