Leave a rating/review
Notes: 03. ScrollViewReader
Update Notes: This course was originally recorded in 2020. It has been reviewed and all content and materials updated as of October 2021.
As you witnessed in the previous episode, sometimes, you’re going to have scrollviews with a TON of children to scroll through. And, maybe, your users would like to access a view that’s somewhere deep into the recesses of your collection.
You don’t want them to have to spend a lot of time scrolling to that point. There’s a better way: it’s called “ScrollViewReader”. It’s a view that uses a “ScrollViewProxy” to allow programmatic control over scroll position.
But you don’t just give a position as a numeric value. Instead, SwiftUI allows for you to target the scroll to specific points, on specific views.
- In this exercise, we’ll be programmatically scrolling to a random subgenre view, from each genre.
This is not an example of a giant scroll view, but we’ll get to those in later episodes. Don’t worry. This will be a simpler example, to learn from.
Hit shift-Command-L, make sure you’re on the Views tab and drag out a ScrollViewReader.
var body: some View {
ScrollViewReader { proxy in
/*@START_MENU_TOKEN@*//*@PLACEHOLDER=Code@*/Text("Placeholder")/*@END_MENU_TOKEN@*/
}
ScrollView {
ScrollViewReaders can encompass a ScrollView.
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(Genre.list) { genre in
genre.subgenres.randomElement()!.view
}
}
}
}
But they also can work as the content of a ScrollView.
ScrollView {
ScrollViewReader { proxy in
And that’s what we’ll use. Because the scroll view won’t need this scroll view proxy, but everything within it will. So that’s better scoping. What we’ll be scrolling to is a “selected genre”, which we’ll store as a State variable.
struct ContentView: View {
@State private var selectedGenre
var body: some View {
And let’s start that off as being the first one in the list.
@State private var selectedGenre = Genre.list.first
We can respond to changes in the selection using the onChange modifier. Drag that out from the modifiers tab.
ForEach(Genre.list) { genre in
genre.subgenres.randomElement()!.view
}
}
.onChange(of: /*@START_MENU_TOKEN@*/"Value"/*@END_MENU_TOKEN@*/) { value in
/*@START_MENU_TOKEN@*//*@PLACEHOLDER=Code@*/ /*@END_MENU_TOKEN@*/
}
You can use this to respond to whatever changes you might need. We need selectedGenre
.
.onChange(of: selectedGenre) { value in
And this proxy is only good for one thing. Scrolling to…
.onChange(of: selectedGenre) { value in
proxy.scrollTo(<#T##id: Hashable##Hashable#>)
}
some kind of “ID”. That really can be anything, but it does need to be Hashable, as you can see.
Genres are hashable, so we’ll use the new selected value. I don’t think the word “value” is helpful, so I’ll switch to dollar-sign-zero.
.onChange(of: selectedGenre) {
proxy.scrollTo($0)
}
And now, we have to match up views with genres, in a way that Scroll View Proxies understand. That is with the id modifier — which we’ll add to the random subgenre view.
genre.subgenres.randomElement()!.view
.id(genre)
}
By default, the “scroll To ID” method will only scroll just enough to make an identified view visible. Instead, if you like, you can the anchor
argument, to tell the scroll view exactly where to target, on the view you want.
proxy.scrollTo($0, anchor: .)
Let’s go with top
.
proxy.scrollTo($0, anchor: .top)
All that’s left now, is to add UI to change the selected Genre. Let’s do that with a menu button in a Navigation View. To add that view, you can use that trick of embedding the ScrollView in anything, and then changing to a NavigationView.
NavigationView {
ScrollView {
The “inline” title display style will be fine, for this.
}
.navigationBarTitleDisplayMode(.inline)
}
Add a toolbar…
.navigationBarTitleDisplayMode(.inline)
.toolbar {
/*@START_MENU_TOKEN@*//*@PLACEHOLDER=Content@*/Text("Placeholder")/*@END_MENU_TOKEN@*/
}
…with a single item…
.toolbar {
ToolbarItem {
}
}
…that is a “Menu”, titled “Genre”.
ToolbarItem {
Menu("Genre") {
}
}
Then, for each genre…
Menu("Genre") {
ForEach(Genre.list) { genre in
}
}
…add a button with its name…
ForEach(Genre.list) { genre in
Button(genre.name) {
}
}
…that sets the selected genre accordingly.
Button(genre.name) {
selectedGenre = genre
}
Try it out! It works. But, if you programmatically scroll to one of the views, manually scroll away, and select the same genre again… nothing happens.
So, set the selection to nil, when it changes, and that will solve that issue.
selectedGenre = nil
proxy.scrollTo($0, anchor: .top)
And this list is small enough that animated scrolling might be nice. Put the scroll method inside of a “with animation” closure, to do that.
withAnimation {
proxy.scrollTo($0, anchor: .top)
}
But you’ll need to give the argument a real name now, because the shorthand doesn’t propagate to the nested closure. It’s a “genre”.
.onChange(of: selectedGenre) { genre in
proxy.scrollTo(genre, anchor: .top)
And although scrolling to the top of some views is nice, it’s not appropriate for the ones at the bottom, for this layout, so let’s just delete that argument.
proxy.scrollTo(genre)
And there we go. As you’ve seen here, ScrollViewReaders aren’t very complicated. Most of getting them to work involves setting up a system for providing something that their proxies can work with.