SwiftUI ScrollViewReader
I recently undertook a rewrite of most of the user interface in my app Pocket Bot. This presented me with the opportunity to start the adoption of SwiftUI. There were a few stumbling blocks and lessons learned, but overall went rather smoothly. That is until I started looking into updating my tvOS version.
I recently undertook a rewrite of most of the user interface in my app Pocket Bot. This presented me with the opportunity to start the adoption of SwiftUI. There were a few stumbling blocks and lessons learned, but overall went rather smoothly. That is until I started looking into updating my tvOS version.
When running on tvOS, Pocket Bot, runs as a single dashboard, bringing together all of the information from multiple Xcode Servers. Often, the content needing to be displayed is larger than the space available. I originally solved this by implementing a Timer
that would automatically scroll a UITableView
every few seconds as needed. This was one of the biggest hurdles I had to overcome moving to SwiftUI.
Luckily though, Apple released the ScrollViewReader
along with other SwiftUI improvements at WWDC 2020. The ScrollViewReader allows you to interact with the current scroll position of a ScrollView
.
So let’s take a look at how I solved this translation of functionality by taking a look at AutoRevealView
:
First, lets start with a simple source of data.
enum Alphabet: String, CaseIterable, Identifiable {
case a, b, c, d, e, f, g, h, i, j, k, l, m, n, o,
p, q, r, s, t, u, v, w, x, y, z
var id: String { rawValue }
}
Next, we’ll define our view.
struct AutoRevealView: View {
let contents: [Content]
init(_ contents: [Content]) {
self.contents = contents
}
var body: some View {
Text("Hello World")
}
}
Notice here, we are using Swift generics to allow for any content to be presented - as long as it conforms to the Identifiable
protocol (more on that later).
If we were to preview this view, it would display the static text “Hello World” reguardless of the content, so let’s adapt the body
to display our content.
struct AutoRevealView: View {
let contents: [Content]
init(_ contents: [Content]) {
self.contents = contents
}
var body: some View {
VStack {
ForEach(contents, id: \.id) { content in
Text("\(content.id.hashValue)")
}
}
}
}
This will display the correct number of rows for our content, but it’s not very interesting. Right now, all we know is that our Content
conforms to Identifiable
, meaning that it has an id
property. In our ForEach
, we don’t even have a way of determining if the id is a String
, or Int
, or something else.
Since we want to be able to reuse this code for any type of content, we’re going to need a way to provide a View
that knows more about the Content
being provided. Let’s extend our code to add another parameter - a @ViewBuilder - that will take one parameter: an instance of our Content
.
struct AutoRevealView: View {
let contents: [Content]
let contentView: (_ content: Content) -> ContentView
init(
_ contents: [Content],
@ViewBuilder contentView: @escaping (_ content: Content) -> ContentView
) {
self.contents = contents
self.contentView = contentView
}
var body: some View {
ScrollView {
ForEach(contents, id: \.id) { content in
contentView(content)
}
}
}
}
The instantiation of our View would now look something like this:
let view = AutoRevealView(Alphabet.allCases) { content in
VStack {
Text(content.rawValue)
Divider()
}
}
Because of the generics, we know the type of Content
being provided, and that content type can be referenced in the block being provided to construct a view. Now we’re getting somewhere!
Let’s keep going. It’s time to introduce our new friend ScrollViewReader
. Do you remember how Content
had to conform to Identifiable
? Well that’s to our advantage here. We can use unique ids with ScrollViewReader
to have SwiftUI modify the scroll view position. So, we’ll add a way to track the current ID and what happens when that id changes.
struct AutoRevealView: View {
let contents: [Content]
let contentView: (_ content: Content) -> ContentView
@State var id: Content.ID? = nil
init(
_ contents: [Content],
@ViewBuilder contentView: @escaping (_ content: Content) -> ContentView
) {
self.contents = contents
self.contentView = contentView
}
var body: some View {
ScrollViewReader { reader in
ScrollView {
ForEach(contents, id: \.id) { content in
contentView(content)
}
}.onChange(of: id, perform: { _ in
if let contentId = id {
withAnimation() {
reader.scrollTo(contentId, anchor: .top)
}
}
})
}
}
}
Wait, wasn’t this called the Auto RevealView? That’s not very automatic. In order to achieve that, we need one final piece: a Timer to allow for the incrementing of the current id.
struct AutoRevealView: View {
let contents: [Content]
let contentView: (_ content: Content) -> ContentView
@State var id: Content.ID? = nil
let timer: Publishers.Autoconnect
init(
_ contents: [Content],
@ViewBuilder contentView: @escaping (_ content: Content) -> ContentView
) {
self.contents = contents
self.contentView = contentView
timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
}
var body: some View {
ScrollViewReader { reader in
ScrollView {
ForEach(contents, id: \.id) { content in
contentView(content)
}
}.onChange(of: id, perform: { _ in
if let contentId = id {
withAnimation() {
reader.scrollTo(contentId, anchor: .top)
}
}
})
}
.onReceive(timer, perform: { _ in
scrollToNext()
})
}
func scrollToNext() {
guard let contentId = id else {
id = contents.first?.id
return
}
guard let index = contents.firstIndex(where: { $0.id == contentId }) else {
id = contents.first?.id
return
}
if contents.count > index + 1 {
id = contents[index + 1].id
} else {
id = contents[0].id
}
}
}
There we go! .onReceive
will be trigger each time the Timer fires, which in turn updates the @State id
. When a state variable is changed, SwiftUI does it’s thing and re-creates our view.
I hope you learned a little from this. If you have any questions or comments feel free to get in touch. Happy Coding!
Created with Ignite