How to make a SwiftUI List scroll automatically?
Asked Answered
D

12

90

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.

Denison answered 29/7, 2019 at 17:36 Comment(3)
What have you tried so far?Stentor
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 generalDenison
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!Rheingold
P
73

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)
    }
}
Pamplona answered 5/4, 2020 at 0:25 Comment(17)
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.Kaoliang
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.Pamplona
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?Parameter
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 simplePamplona
@Pamplona 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?Frisch
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 :) )Pamplona
Thanks, I think I will indeed just switch to the latest Xcode beta so that I can use the native feature instead.Frisch
@Pamplona 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?Frisch
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 upgradePamplona
The iOS 13 solution also works for MacOS 10 (ScrollViewReader is only available in 11+). @cjpais, this is such a clever solution. Kudos.Centrobaric
The first ' iOS 14 solution' is not working for LazyVGrid on iOS 14.4.1Hellgrammite
This approach works fine when you have more than a screen of messages but with the first few messages they appear at the bottom of the screen with empty space above the message rather than the top which is what you would expect from a chat app. Is there anyway around this?Debor
@Debor I have not booted up Xcode to try, but would adding a Spacer to the bottom of the view solve this?Pamplona
As of Xcode 13.1 this solution doesn't workRuiz
@Ruiz could you explain more what doesn't work?Pamplona
@Pamplona I pasted in your code. No scrollingRuiz
Only for help, if you are using some custom structs with identifiable protocol you must use scrollView.scrollTo(content.data.[value].id, anchor: .center)Vainglory
B
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)
        })
    }
}
Batting answered 5/11, 2019 at 9:26 Comment(6)
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.Batting
I should have mentioned xib/nib instead. But it would really handy if there is something like scroll to content offset or relevant.Birdhouse
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.Totally
This works for me. For consistent vertical spacing, I would recommend changing: VStack { content() } to: VStack(spacing: 0) { content() }Squab
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 updateDiabolic
This solution works only for adding new element, in my case. Thank you!Eindhoven
S
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
}
Schell answered 28/7, 2021 at 20:31 Comment(2)
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.Vitriolize
@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.Schell
T
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

Talipes answered 9/7, 2020 at 12:12 Comment(0)
G
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)
        }
    }
}
Ginetteginevra answered 23/6, 2020 at 9:31 Comment(4)
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.Loaves
@StephenLee Can you open an issue on github with some example code of what you are seeing? Maybe we can figure it out.Ginetteginevra
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 neatLoaves
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.Caduceus
C
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)
                        }
                    }
                }
            }
        }
    }
}
Ceciliacecilio answered 27/6, 2020 at 12:11 Comment(0)
T
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)
            }
        }
    }
}
Totally answered 20/11, 2019 at 2:22 Comment(3)
This is the correct solution! I've created a UIKit edition to this, using this answer as a basis: gist.github.com/jfuellert/67e91df63394d7c9b713419ed8e2beb7Nosewheel
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.Mistrust
Try using DispatchQueue.main.async(execute: {...}) on the bit that throws the warning.Totally
S
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)
            }
        }
    }
}
Shanty answered 1/6, 2020 at 8:49 Comment(4)
I recommend you to use ScrollViewReader if you are developing for iOS 14.Shanty
Yeah agree @Shanty but I'm still using 13.4... I think iOS 14 is still in beta and you need to request the dev kit?Loaves
You can develop for iOS 14 using the Xcode 12 beta, it is true that you need a developer account to download for now.Shanty
@93sauu, you can download Xcode 12 beta with your developer account, it's not necessary the enrollment to developer program.Kiruna
S
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

Swamp answered 30/9, 2020 at 17:30 Comment(0)
T
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)
                }
            }
        }

      
Theisen answered 16/12, 2022 at 5:2 Comment(0)
S
0

Сan be simplified.....

.onChange(of: messages) { target in
                withAnimation {
                    scrollView.scrollTo(target.last?.id, anchor: .bottom)
                }
            }
Selmaselman answered 12/2, 2021 at 22:4 Comment(0)
L
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)
        }
    }
}
Lockman answered 23/2, 2022 at 11:1 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.