67

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.

sonique
  • 3,402
  • 2
  • 20
  • 33
Seb
  • 1,153
  • 1
  • 7
  • 14
  • What have you tried so far? – Stephen Jul 29 '19 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 – Seb Jul 29 '19 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! – dfd Jul 29 '19 at 17:51

9 Answers9

54

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)
    }
}
Ferologics
  • 498
  • 8
  • 21
cjpais
  • 658
  • 6
  • 9
  • 2
    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. – Divyanshu Negi Apr 07 '20 at 11:05
  • 3
    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. – cjpais Apr 10 '20 at 16:35
  • I second @DivyanshuNegi: this is a great solution, and should be the accepted answer. Recreating a scrollview that scrolls in reverse is a fun learning exercise, but the scrollview has over a decade's worth of behavior and lessons built into it, so I’d rather not try to compete with that. I did have a follow-up question: Did you ever manage to elegantly get a flipped scrollview to honor `edgesIgnoringSafeArea`? – rainypixels May 19 '20 at 01:30
  • Hey @rainypixels, I don't think in my implementation I was specifically concerned about `edgesIgnoringSafeArea`. However, I think you can implement this on your own using a `GeometryReader` and adjusting the frame height or offset with `geometry.safeAreaInsets`. Also note I think the answer from Dan above is probably the most correct and robust solution. For my purpose, it seemed overkill to bring in UIKit elements for something simple – cjpais May 22 '20 at 16:39
  • @cjpais I noticed that the flipping trick falls apart when I add a context menu to all the list items. Because the context menu flips it back when you activate it. Do you have any idea how to prevent that from happening? – Evert Jul 21 '20 at 19:07
  • 1
    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 :) ) – cjpais Jul 22 '20 at 17:29
  • 1
    Thanks, I think I will indeed just switch to the latest Xcode beta so that I can use the native feature instead. – Evert Jul 23 '20 at 06:40
  • 1
    @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? – Evert Jul 23 '20 at 09:23
  • Hey @Evert, I have noticed some strange behavior relative to this too. What I notice is that sometimes it will scroll and sometimes not. I am guessing this has to do with your view hierarchy and how the data is being updated. I will take a closer look and see if I can work out something that resolves this issue as well. It might require a publisher. Will update when I come back to fix bugs in this project related to XCode 12 upgrade – cjpais Aug 07 '20 at 22:46
  • The iOS 13 solution also works for MacOS 10 (ScrollViewReader is only available in 11+). @cjpais, this is such a clever solution. Kudos. – Lee Fastenau Feb 02 '21 at 05:44
  • The first ' iOS 14 solution' is not working for LazyVGrid on iOS 14.4.1 – Vincent Gigandet Mar 28 '21 at 23:38
40

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

#if DEBUG
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
#endif
Asperi
  • 123,447
  • 8
  • 131
  • 245
  • 2
    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. – Asperi Nov 12 '19 at 05:34
  • I should have mentioned xib/nib instead. But it would really handy if there is something like scroll to content offset or relevant. – Sandeep Rana Nov 12 '19 at 17:01
  • I think you could also accomplish this by wrapping a UIScrollView in a UIViewControllerRepresentable class, although I'm having a hard time figuring out how (actually I'm writing for macOS using the equivalent NS classes - which seems like it may be a little more complicated). But if this works, I would bet it would be less prone to bugs since the OS would still be managing a lot of the aspects that are coded above. – Dan Nov 17 '19 at 20:09
  • This works for me. For consistent vertical spacing, I would recommend changing: VStack { content() } to: VStack(spacing: 0) { content() } – Brendon Feb 29 '20 at 17:31
  • 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 – Arafin Russell Aug 19 '20 at 17:30
  • This solution works only for adding new element, in my case. Thank you! – Kenan Begić Sep 30 '20 at 14:26
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)
                        }
                    }
                }
            }
        }
    }
}
Sajjon
  • 5,608
  • 2
  • 41
  • 77
4

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

Mojtaba Hosseini
  • 47,708
  • 12
  • 157
  • 176
3

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)
            }
        }
    }
}
Dan
  • 2,459
  • 1
  • 14
  • 13
  • This is the correct solution! I've created a UIKit edition to this, using this answer as a basis: https://gist.github.com/jfuellert/67e91df63394d7c9b713419ed8e2beb7 – jfuellert Jan 16 '20 at 19:31
  • 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. – Claudiu Jan 23 '20 at 14:34
  • Try using DispatchQueue.main.async(execute: {...}) on the bit that throws the warning. – Dan Jan 24 '20 at 00:06
3

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)
        }
    }
}
Casper Zandbergen
  • 2,667
  • 20
  • 37
  • 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. – Stephen Lee Jul 11 '20 at 07:01
  • @StephenLee Can you open an issue on github with some example code of what you are seeing? Maybe we can figure it out. – Casper Zandbergen Jul 16 '20 at 07:03
  • yeah hmm im working in the private repo but I can elaborate on that: https://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 – Stephen Lee Jul 17 '20 at 02:20
2

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)
            }
        }
    }
}
93sauu
  • 2,762
  • 3
  • 19
  • 39
  • 1
    I recommend you to use ScrollViewReader if you are developing for iOS 14. – 93sauu Jul 13 '20 at 07:52
  • 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? – Stephen Lee Jul 14 '20 at 21:40
  • 1
    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 Jul 15 '20 at 08:31
  • @93sauu, you can download Xcode 12 beta with your developer account, it's not necessary the enrollment to developer program. – iGhost Oct 18 '20 at 03:29
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

Kenan Begić
  • 1,150
  • 10
  • 21
-1

Сan be simplified.....

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