Use ScrollViewReader in SwiftUI to scroll to a new item
In iOS 14 we got a new API for programmatic scrolling: ScrollViewReader. It provides us with a ScrollViewProxy
that has a function to scroll to a view with a particular id. This API works out of the box with a ScrollView
with content that doesn't get added or removed. You can check out how to make a scroll view move to a location on Hacking with Swift.
But we often use ScrollView
with dynamic content and there are scenarios when we would want to automatically scroll to the new item within the view. We can consider progressive loading of images from the internet, when we want to scroll to the images as they arrive. Or, for example, a chat application where we have messages within a ScrollView
and we want to scroll to newly arrived messages.
In this case we can't just add an item to the ScrollView data source and tell the ScrollViewProxy
to scroll to it. This is because the view has to be re-rendered with that new item first so that there is a location to scroll to.
Luckily we have another API onChange(of:perform:) that gets called when a specific value changes. So we can set @State
property with the id to scroll to and use onChange()
with that value.
I made an example project that demos a chat application and uses this idea to scroll to newly added messages. You can get the full project for this article from GitHub. In this article I would like to go over the most important bits of the code and explain them.
The ChatRoom view has the ScrollViewReader
and the ScrollView
. It seems like the ScrollViewReader
can be either a parent or a child of the ScrollView
and it will work. It's important to set an id on each of the messages so that we can scroll to them.
struct ChatRoom: View {
@State private var messages: [Message] = []
...
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { scrollProxy in
ScrollView {
VStack(spacing: 10) {
ForEach(messages) { message in
MessageCell(message: message)
.id(message.id)
}
}
}
...
}
...
}
...
}
}
We also have a MessageComposeView
that is responsible for the user input and calls appendMessage()
with the text entered when the user clicks the Send
button. And there is Incoming Message
button in the navigation bar to simulate an incoming message. The appendMessage()
function creates a new Message
object, appends it to the messages, then sets the messageIdToSetVisible
to the new message id.
struct ChatRoom: View {
@State private var messages: [Message] = []
@State var messageIdToSetVisible: UUID?
var body: some View {
VStack(spacing: 0) {
...
MessageComposeView(sendMessage: appendMessage)
}
.navigationBarItems(trailing: Button("Incoming Message") {
self.appendMessage(text: RandomSentenceGenerator.generateSentence())
})
}
func appendMessage(text: String) {
let newMessage = Message(text: text)
messages.append(newMessage)
messageIdToSetVisible = newMessage.id
}
}
After the messages
array is updated with the new message and the messageIdToSetVisible
to the new id, the body
property gets called with the changes. It will first update the ScrollView
adding the new message to it, then call onChange()
with the new value of the id to scroll to. Here we can tell the ScrollViewProxy
to scroll the view to the new message.
struct ChatRoom: View {
@State private var messages: [Message] = []
@State var messageIdToSetVisible: UUID?
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { scrollProxy in
ScrollView {
VStack(spacing: 10) {
ForEach(messages) { message in
MessageCell(message: message)
.id(message.id)
}
}
}
.onChange(of: self.messageIdToSetVisible) { id in
guard id != nil else { return }
withAnimation {
scrollProxy.scrollTo(id)
}
}
}
}
}
}
ScrollViewReader
is a nice addition to SwiftUI, before it the only way to control the scroll position was to reach to the underlying UIKit's UIScrollView
.