Scroll to Newly Added Item using ScrollViewReader and onChange Modifier in SwiftUI


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 this example 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 new API onChange(of:perform:) that gets called when a specific value changes. So we can set an @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 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 UIScrollView. ScrollViewReader doesn't work with List at the moment, but hopefully this is going to be fixed very soon.