SwiftUI: Layout & Interfaces

Nov 18 2021 · Swift 5.5, iOS 15, Xcode 13

Part 1: Dynamic View Layout

03. ScrollViewReader

Episode complete

Play next episode

Next
About this episode

Leave a rating/review

See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 02. Lazy Stacks Next episode: 04. Challenge: Nested Scrolling Stacks

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.

Transcript: 03. ScrollViewReader

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.