How to make a SwiftUI List scroll automatically?
SwiftListviewSwiftuiAutoscrollSwift Problem Overview
When adding content to my ListView, I want it to automatically scroll down.
I'm using a SwiftUI List
, and a BindableObject
as Controller. New data is getting appended to the list.
List(chatController.messages, id: \.self) { message in
MessageView(message.text, message.isMe)
}
I want the list to scroll down, as I append new data to the message list. However I have to scroll down manually.
Swift Solutions
Solution 1 - Swift
Update: In iOS 14 there is now a native way to do this. I am doing it as such
ScrollViewReader { scrollView in
ScrollView(.vertical) {
LazyVStack {
ForEach(notes, id: \.self) { note in
MessageView(note: note)
}
}
.onAppear {
scrollView.scrollTo(notes[notes.endIndex - 1])
}
}
}
For iOS 13 and below you can try:
I found that flipping the views seemed to work quite nicely for me. This starts the ScrollView at the bottom and when adding new data to it automatically scrolls the view down.
- Rotate the outermost view 180
.rotationEffect(.radians(.pi))
- Flip it across the vertical plane
.scaleEffect(x: -1, y: 1, anchor: .center)
You will have to do this to your inner views as well, as now they will all be rotated and flipped. To flip them back do the same thing above.
If you need this many places it might be worth having a custom view for this.
You can try something like the following:
List(chatController.messages, id: \.self) { message in
MessageView(message.text, message.isMe)
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
Here's a View extension to flip it
extension View {
public func flip() -> some View {
return self
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
}
Solution 2 - Swift
As there is no built-in such feature for now (neither for List nor for ScrollView), Xcode 11.2, so I needed to code custom ScrollView with ScrollToEnd behaviour
!!! Inspired by this article.
Here is a result of my experiments, hope one finds it helpful as well. Of course there are more parameters, which might be configurable, like colors, etc., but it appears trivial and out of scope.
import SwiftUI
struct ContentView: View {
@State private var objects = ["0", "1"]
var body: some View {
NavigationView {
VStack {
CustomScrollView(scrollToEnd: true) {
ForEach(self.objects, id: \.self) { object in
VStack {
Text("Row \(object)").padding().background(Color.yellow)
NavigationLink(destination: Text("Details for \(object)")) {
Text("Link")
}
Divider()
}.overlay(RoundedRectangle(cornerRadius: 8).stroke())
}
}
.navigationBarTitle("ScrollToEnd", displayMode: .inline)
// CustomScrollView(reversed: true) {
// ForEach(self.objects, id: \.self) { object in
// VStack {
// Text("Row \(object)").padding().background(Color.yellow)
// NavigationLink(destination: Text("Details for \(object)")) {
// Image(systemName: "chevron.right.circle")
// }
// Divider()
// }.overlay(RoundedRectangle(cornerRadius: 8).stroke())
// }
// }
// .navigationBarTitle("Reverse", displayMode: .inline)
HStack {
Button(action: {
self.objects.append("\(self.objects.count)")
}) {
Text("Add")
}
Button(action: {
if !self.objects.isEmpty {
self.objects.removeLast()
}
}) {
Text("Remove")
}
}
}
}
}
}
struct CustomScrollView<Content>: View where Content: View {
var axes: Axis.Set = .vertical
var reversed: Bool = false
var scrollToEnd: Bool = false
var content: () -> Content
@State private var contentHeight: CGFloat = .zero
@State private var contentOffset: CGFloat = .zero
@State private var scrollOffset: CGFloat = .zero
var body: some View {
GeometryReader { geometry in
if self.axes == .vertical {
self.vertical(geometry: geometry)
} else {
// implement same for horizontal orientation
}
}
.clipped()
}
private func vertical(geometry: GeometryProxy) -> some View {
VStack {
content()
}
.modifier(ViewHeightKey())
.onPreferenceChange(ViewHeightKey.self) {
self.updateHeight(with: $0, outerHeight: geometry.size.height)
}
.frame(height: geometry.size.height, alignment: (reversed ? .bottom : .top))
.offset(y: contentOffset + scrollOffset)
.animation(.easeInOut)
.background(Color.white)
.gesture(DragGesture()
.onChanged { self.onDragChanged($0) }
.onEnded { self.onDragEnded($0, outerHeight: geometry.size.height) }
)
}
private func onDragChanged(_ value: DragGesture.Value) {
self.scrollOffset = value.location.y - value.startLocation.y
}
private func onDragEnded(_ value: DragGesture.Value, outerHeight: CGFloat) {
let scrollOffset = value.predictedEndLocation.y - value.startLocation.y
self.updateOffset(with: scrollOffset, outerHeight: outerHeight)
self.scrollOffset = 0
}
private func updateHeight(with height: CGFloat, outerHeight: CGFloat) {
let delta = self.contentHeight - height
self.contentHeight = height
if scrollToEnd {
self.contentOffset = self.reversed ? height - outerHeight - delta : outerHeight - height
}
if abs(self.contentOffset) > .zero {
self.updateOffset(with: delta, outerHeight: outerHeight)
}
}
private func updateOffset(with delta: CGFloat, outerHeight: CGFloat) {
let topLimit = self.contentHeight - outerHeight
if topLimit < .zero {
self.contentOffset = .zero
} else {
var proposedOffset = self.contentOffset + delta
if (self.reversed ? proposedOffset : -proposedOffset) < .zero {
proposedOffset = 0
} else if (self.reversed ? proposedOffset : -proposedOffset) > topLimit {
proposedOffset = (self.reversed ? topLimit : -topLimit)
}
self.contentOffset = proposedOffset
}
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
extension ViewHeightKey: ViewModifier {
func body(content: Content) -> some View {
return content.background(GeometryReader { proxy in
Color.clear.preference(key: Self.self, value: proxy.size.height)
})
}
}
Solution 3 - Swift
SwiftUI 2.0 - iOS 14
this is the one: (wrapping it in a ScrollViewReader
)
scrollView.scrollTo(rowID)
With the release of SwiftUI 2.0, you can embed any scrollable in the ScrollViewReader
and then you can access to the exact element location you need to scroll.
Here is a full demo app:
// A simple list of messages
struct MessageListView: View {
var messages = (1...100).map { "Message number: \($0)" }
var body: some View {
ScrollView {
LazyVStack {
ForEach(messages, id:\.self) { message in
Text(message)
Divider()
}
}
}
}
}
struct ContentView: View {
@State var search: String = ""
var body: some View {
ScrollViewReader { scrollView in
VStack {
MessageListView()
Divider()
HStack {
TextField("Number to search", text: $search)
Button("Go") {
withAnimation {
scrollView.scrollTo("Message number: \(search)")
}
}
}.padding(.horizontal, 16)
}
}
}
}
Preview
Solution 4 - Swift
You can do this now, since Xcode 12, with the all new ScrollViewProxy
, here's example code:
You can update the code below with your chatController.messages
and the call scrollViewProxy.scrollTo(chatController.messages.count-1)
.
When to do it? Maybe on the SwiftUI's new onChange
!
struct ContentView: View {
let itemCount: Int = 100
var body: some View {
ScrollViewReader { scrollViewProxy in
VStack {
Button("Scroll to top") {
scrollViewProxy.scrollTo(0)
}
Button("Scroll to buttom") {
scrollViewProxy.scrollTo(itemCount-1)
}
ScrollView {
LazyVStack {
ForEach(0 ..< itemCount) { i in
Text("Item \(i)")
.frame(height: 50)
.id(i)
}
}
}
}
}
}
}
Solution 5 - Swift
iOS 14/15:
I did it by using the onChange
modifier of ScrollView like this:
// View
struct ChatView: View {
@ObservedObject var viewModel = ChatViewModel()
@State var newText = ""
var body: some View {
ScrollViewReader { scrollView in
VStack {
ScrollView(.vertical) {
VStack {
ForEach(viewModel.messages) { message in
VStack {
Text(message.text)
Divider()
}
}
}.id("ChatScrollView")
}.onChange(of: viewModel.messages) { _ in
withAnimation {
scrollView.scrollTo("ChatScrollView", anchor: .bottom)
}
}
Spacer()
VStack {
TextField("Enter message", text: $newText)
.padding()
.frame(width: 400, height: 40, alignment: .center)
Button("Send") {
viewModel.addMessage(with: newText)
}
.frame(width: 400, height: 80, alignment: .center)
}
}
}
}
}
// View Model
class ChatViewModel: ObservableObject {
@Published var messages: [Message] = [Message]()
func addMessage(with text: String) {
messages.append(Message(text: text))
}
}
// Message Model
struct Message: Hashable, Codable, Identifiable {
var id: String = UUID().uuidString
var text: String
}
Solution 6 - Swift
This can be accomplished on macOS by wrapping an NSScrollView inside an NSViewControllerRepresentable object (and I assume the same thing work on iOS using UIScrollView and UIViewControllerRepresentable.) I am thinking this may be a little more reliable than the other answer here since the OS would still be managing much of the control's function.
I just now got this working, and I plan on trying to get some more things to work, such as getting the position of certain lines within my content, but here is my code so far:
import SwiftUI
struct ScrollableView<Content:View>: NSViewControllerRepresentable {
typealias NSViewControllerType = NSScrollViewController<Content>
var scrollPosition : Binding<CGPoint?>
var hasScrollbars : Bool
var content: () -> Content
init(hasScrollbars: Bool = true, scrollTo: Binding<CGPoint?>, @ViewBuilder content: @escaping () -> Content) {
self.scrollPosition = scrollTo
self.hasScrollbars = hasScrollbars
self.content = content
}
func makeNSViewController(context: NSViewControllerRepresentableContext<Self>) -> NSViewControllerType {
let scrollViewController = NSScrollViewController(rootView: self.content())
scrollViewController.scrollView.hasVerticalScroller = hasScrollbars
scrollViewController.scrollView.hasHorizontalScroller = hasScrollbars
return scrollViewController
}
func updateNSViewController(_ viewController: NSViewControllerType, context: NSViewControllerRepresentableContext<Self>) {
viewController.hostingController.rootView = self.content()
if let scrollPosition = self.scrollPosition.wrappedValue {
viewController.scrollView.contentView.scroll(scrollPosition)
DispatchQueue.main.async(execute: {self.scrollPosition.wrappedValue = nil})
}
viewController.hostingController.view.frame.size = viewController.hostingController.view.intrinsicContentSize
}
}
class NSScrollViewController<Content: View> : NSViewController, ObservableObject {
var scrollView = NSScrollView()
var scrollPosition : Binding<CGPoint>? = nil
var hostingController : NSHostingController<Content>! = nil
@Published var scrollTo : CGFloat? = nil
override func loadView() {
scrollView.documentView = hostingController.view
view = scrollView
}
init(rootView: Content) {
self.hostingController = NSHostingController<Content>(rootView: rootView)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
}
}
struct ScrollableViewTest: View {
@State var scrollTo : CGPoint? = nil
var body: some View {
ScrollableView(scrollTo: $scrollTo)
{
Text("Scroll to bottom").onTapGesture {
self.$scrollTo.wrappedValue = CGPoint(x: 0,y: 1000)
}
ForEach(1...50, id: \.self) { (i : Int) in
Text("Test \(i)")
}
Text("Scroll to top").onTapGesture {
self.$scrollTo.wrappedValue = CGPoint(x: 0,y: 0)
}
}
}
}
Solution 7 - Swift
iOS 13+
This package called ScrollViewProxy adds a ScrollViewReader which provides a ScrollViewProxy on which you can call scrollTo(_:)
for any ID that you gave to a View. Under the hood it uses Introspect to get the UIScrollView.
Example:
ScrollView {
ScrollViewReader { proxy in
Button("Jump to #8") {
proxy.scrollTo(8)
}
ForEach(0..<10) { i in
Text("Example \(i)")
.frame(width: 300, height: 300)
.scrollId(i)
}
}
}
Solution 8 - Swift
I present other solution getting the UITableView reference using the library Introspect until that Apple improves the available methods.
struct LandmarkList: View {
@EnvironmentObject private var userData: UserData
@State private var tableView: UITableView?
private var disposables = Set<AnyCancellable>()
var body: some View {
NavigationView {
VStack {
List(userData.landmarks, id: \.id) { landmark in
LandmarkRow(landmark: landmark)
}
.introspectTableView { (tableView) in
if self.tableView == nil {
self.tableView = tableView
print(tableView)
}
}
}
.navigationBarTitle(Text("Landmarks"))
.onReceive(userData.$landmarks) { (id) in
// Do something with the table for example scroll to the bottom
self.tableView?.setContentOffset(CGPoint(x: 0, y: CGFloat.greatestFiniteMagnitude), animated: false)
}
}
}
}
Solution 9 - Swift
Here is my working solution for observed object that gets data dynamically, like array of messages in chat that gets populated through conversation.
Model of message array:
struct Message: Identifiable, Codable, Hashable {
//MARK: Attributes
var id: String
var message: String
init(id: String, message: String){
self.id = id
self.message = message
}
}
Actual view:
@ObservedObject var messages = [Message]()
@State private var scrollTarget: Int?
var scrollView : some View {
ScrollView(.vertical) {
ScrollViewReader { scrollView in
ForEach(self.messages) { msg in
Text(msg).id(message.id)
}
//When you add new element it will scroll automatically to last element or its ID
.onChange(of: scrollTarget) { target in
withAnimation {
scrollView.scrollTo(target, anchor: .bottom)
}
}
.onReceive(self.$messages) { updatedMessages in
//When new element is added to observed object/array messages, change the scroll position to bottom, or last item in observed array
scrollView.scrollTo(umessages.id, anchor: .bottom)
//Update the scrollTarget to current position
self.scrollTarget = updatedChats.first!.messages.last!.message_timestamp
}
}
}
}
Take a look at this fully working example on GitHub: https://github.com/kenagt/AutoScrollViewExample
Solution 10 - Swift
Сan be simplified.....
.onChange(of: messages) { target in
withAnimation {
scrollView.scrollTo(target.last?.id, anchor: .bottom)
}
}
Solution 11 - Swift
As many pointed out. You can use the ScrollViewReader to scroll to the last id of the message. However, my ScrollView didn't fully scroll to the bottom. Another version is to put a defined id to a text without opacity beneath all the messages.
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
ForEach(chatVM.chatMessagesArray, id: \.self) { chat in
MessageBubble(messageModel: chat)
}
}
Text("Hello").font(.caption).opacity(0).id(idString) // <= here!!
}
.onChange(of: chatVM.chatMessagesArray) { _ in
withAnimation {
scrollView.scrollTo(idString, anchor: .bottom)
}
}
.onAppear {
withAnimation {
scrollView.scrollTo(idString, anchor: .bottom)
}
}
}