93

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.

3
  • What have you tried so far? Commented Jul 29, 2019 at 17:41
  • Functions like onReceive() or transformEnvironment(), but I don't know what to pass these functions and how they behave. I just guessed by the names. Im super new to SwiftUI and Swift in general Commented Jul 29, 2019 at 17:47
  • Honestly (and with respect), I'd love to mark this as a duplicate - except I found two other similar questions with no answers. (Dups need an accepted answer.) This tells me that you may be asking something that there is no answer for (yet), and thus I should upvote your question instead. I really hope there is an answer, as I may be needing this in another month. Good luck! Commented Jul 29, 2019 at 17:51

12 Answers 12

78

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.

  1. Rotate the outermost view 180 .rotationEffect(.radians(.pi))
  2. 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)
    }
}
Sign up to request clarification or add additional context in comments.

17 Comments

You saved the day, I was using the above answer by @asperi but that kind of scroll view is not optimized and buggy particularly in my use case, I think you are also working with chat messages as me too so your answers work best in my case, still, we cannot scroll wherever we want by code but for now this solution works.
Awesome glad I could help! I am also working on a chat-like interface and I spent a couple hours trying to find a good solution. This solution came from another SO post for UIKit that I just adapted for SwiftUI. I think this should be a relatively performant solution until Apple gives a better native implementation.
Hey @Evert, I am not sure about this. I would have to experiment and potentially step through the calls that SwiftUI is making in order to render. My guess is there is some event ordering that will work, but I am not experienced enough to say definitely. If you can use the iOS 14/XCode beta, I would suggest using the officially supported way via ScrollViewReader, likely this will solve any issues (or bring up new ones :) )
Thanks, I think I will indeed just switch to the latest Xcode beta so that I can use the native feature instead.
@cjpais hey I'm working in Xcode 12 now and I see that your code doesn't make the scrollView scroll when list inside it changes. I'm making a chat app and I'd like it to scroll to the bottom every time a new message arrives. Do you have any advice on how to do that?
|
47

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.

scroll to end reverse content

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)
        })
    }
}

6 Comments

Storyboards appeared after, I don't remember exactly, 5-10 years of UIKit... let's be optimistic. )) Moreover - nobody removed storyboards - they are still here.
I should have mentioned xib/nib instead. But it would really handy if there is something like scroll to content offset or relevant.
This works for me. For consistent vertical spacing, I would recommend changing: VStack { content() } to: VStack(spacing: 0) { content() }
Can this example be used for a button click event ? Like when the view appear I need CustomScrollView(scrollToEnd: false) but when button click I need CustomScrollView(scrollToEnd: true). I tried with a @State variable but view dose not update
This solution works only for adding new element, in my case. Thank you!
|
12

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
}

2 Comments

This is the way (& thank you for writing it out). It's wholly SwiftUI, you can wrap a List in a ScrollViewReader; you will just need to get the tag for the last element in your list and scroll to that.
@LucasC.Feijo It depends what your requirements are. For example, if you don't want to auto-scroll when user is browsing older messages, you can check the bottom offset, if the list appears to be scrolled, don't run the code in onChange.
7

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

Preview

Comments

6

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)
        }
    }
}

4 Comments

Ah it only worked with static data.... When I passed in dynamic data .id() was defined before data is loaded so it displayed the empty view.
@StephenLee Can you open an issue on github with some example code of what you are seeing? Maybe we can figure it out.
yeah hmm im working in the private repo but I can elaborate on that: gist.github.com/Mr-Perfection/937520af1818cd44714f59a4c0e184c4 I think the issue was id() was invoked before list is loaded with data. Then, it threw an exception. So the list was only seeing 0 id but list items are > 0. I really wished i could use your code though :P looks neat
I ended up being stumped by the scrollIId feature. The log kept throwing a warning that it could only find AnyHashable instead of the needed ID. I had the most luck switching to Introspect and handling it through the tried-and-true tableView API.
4

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)
            }
        }
    }
}

3 Comments

This is the correct solution! I've created a UIKit edition to this, using this answer as a basis: gist.github.com/jfuellert/67e91df63394d7c9b713419ed8e2beb7
hi @jfuellert, I've tried your solution but there is a problem, here is the warning: "Modifying state during view update, this will cause undefined behavior.". I believe this happens because both contentOffSet and scrollViewDidScroll try to update the views at the same time.
Try using DispatchQueue.main.async(execute: {...}) on the bit that throws the warning.
4

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)
                        }
                    }
                }
            }
        }
    }
}

Comments

3

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)
            }
        }
    }
}

4 Comments

I recommend you to use ScrollViewReader if you are developing for iOS 14.
Yeah agree @93sauu but I'm still using 13.4... I think iOS 14 is still in beta and you need to request the dev kit?
You can develop for iOS 14 using the Xcode 12 beta, it is true that you need a developer account to download for now.
@93sauu, you can download Xcode 12 beta with your developer account, it's not necessary the enrollment to developer program.
1

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

Comments

1

Automatic scrolling is now supported for List:

        ScrollViewReader { proxy in
            VStack {
                Button("Jump to last") {
                    proxy.scrollTo(viewModel.messageViewModels.last?.id)
                }

                List(viewModel.messageViewModels) { 
                    MessageView(viewModel: $0).id($0.id)
                }
            }
        }

      

1 Comment

Note this isn't going to work, as you're asking the scroller to scroll to an 'optional' element, which is identified differently. i.e. last?.id won't work, but last!.id would work.
0

Сan be simplified.....

.onChange(of: messages) { target in
                withAnimation {
                    scrollView.scrollTo(target.last?.id, anchor: .bottom)
                }
            }

Comments

0

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)
        }
    }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.