SwiftUI automatically scroll to bottom in ScrollView (Bottom first)
Asked Answered
D

8

57

My problem is that i have (in SwiftUI) a ScrollView with an foreach inside. Know when the foreach loads all of my entries i want that the last entry is focused.

I did some google research, but i didn't find any answer.

  ScrollView {
    VStack {
      ForEach (0..<self.entries.count) { index in
        Group {
          Text(self.entries[index].getName())
        }
      }
    }
  }
Drool answered 14/10, 2019 at 12:20 Comment(2)
Possible duplicate of How to make a SwiftUI List scroll automatically?Danseur
I think this guy has a solution for you github.com/mremond/SwiftUI-ScrollView-Demo . I'll try this tomorrow.Mogador
K
47

Scrolling to the bottom on change

I don't have enough reputation to post a comment yet, so here you go @Dam and @Evert

To scroll to the bottom whenever the number of entries in your ForEach changes you can also use the same method with a ScrollViewReader, as mentioned in the answer above, by adding the view modifier onChange like so:

struct ContentView: View {
    let colors: [Color] = [.red, .blue, .green]
    var entries: [Entry] = Array(repeating: Entry(), count: 10)

    var body: some View {
        ScrollView {
            ScrollViewReader { value in
                            
                ForEach(0..<entries.count) { i in
                    Text(self.entries[i].getName())
                        .frame(width: 300, height: 200)
                        .background(colors[i % colors.count])
                        .padding(.all, 20)
                }
                .onChange(of: entries.count) { _ in
                    value.scrollTo(entries.count - 1)
                }
            }
        }
    }
}
Kreiner answered 20/12, 2020 at 16:6 Comment(2)
Maybe you don't have (had) enough reputation but you deserve(d) to got the medal. Very nice solution avoiding to have an array of elements confirming to Hashable (my array is [AnyView].Toggle
Simplest so the best answer as don't need to play with the id or anchor. Works with horizontal ScrollView of course. I didn't notice any difference but maybe better to follow Apple documentation and take out the ScrollViewReader from nested position to wrapping.Hoarsen
T
47

According to Apple's documentation on ScrollViewReader, it should wrap the scroll view, instead of being nested in.

Example from Apple docs:

@Namespace var topID
@Namespace var bottomID

var body: some View {
    ScrollViewReader { proxy in
        ScrollView {
            Button("Scroll to Bottom") {
                withAnimation {
                    proxy.scrollTo(bottomID)
                }
            }
            .id(topID)

            VStack(spacing: 0) {
                ForEach(0..<100) { i in
                    color(fraction: Double(i) / 100)
                        .frame(height: 32)
                }
            }

            Button("Top") {
                withAnimation {
                    proxy.scrollTo(topID)
                }
            }
            .id(bottomID)
        }
    }
}

func color(fraction: Double) -> Color {
    Color(red: fraction, green: 1 - fraction, blue: 0.5)
}

This other article also shows how to use ScrollViewReader to wrap a List of elements, which can be also handy. Code example from the article:

ScrollViewReader { scrollView in
    List {
        ForEach(photos.indices) { index in
            Image(photos[index])
                .resizable()
                .scaledToFill()
                .cornerRadius(25)
                .id(index)
        }
    }
 
    .
    .
    .
}

For me, using value.scrollTo(entries.count - 1) does not work. Furthermore, using value.scrollTo(entries.last?.id) didn't work either until I used the .id(...) view modifier (maybe because my entries do not conform to Identifiable).

Using ForEach, here is the code I'm working on:

ScrollViewReader { value in
    ScrollView {
        ForEach(state.messages, id: \.messageId) { message in
            MessageView(message: message)
                .id(message.messageId)
        }
    }
    .onAppear {
        value.scrollTo(state.messages.last?.messageId)
    }
    .onChange(of: state.messages.count) { _ in
        value.scrollTo(state.messages.last?.messageId)
    }
}

One important consideration when using onAppear is to use that modifier on the ScrollView/List and not in the ForEach. Doing so in the ForEach will cause it to trigger as elements on the list appear when manually scrolling through it.

Treadle answered 27/5, 2021 at 21:52 Comment(3)
This should be the correct answer for Xcode 13.4.1 and iOS 15.5 - (1) The Reader wrapping the ScrollView. (2) The key element overlooked in other answers is the use of .id(message.id)... (3) in my case, the .onAppear {} is not neededSeptemberseptembrist
Can confirm this, it will only scroll if the view is tagged with .id() modifierSanguinaria
When I first came across posts about ScrollViewReader, I was confused why the ScrollView was the outer parent instead of the reader. Thank you for confirming this.Nickerson
H
26

Solution for iOS14 with ScrollViewReader

in iOS14 SwiftUI gains a new ability. To be able to scroll in a ScrollView up to a particular item with a given id. There is a new type called ScrollViewReader which works just like Geometry Reader.

The code below will scroll to the last item in your View.

So this is your struct for 'Entry' I guess:

struct Entry {
    let id = UUID()
    
    func getName() -> String {
         return "Entry with id \(id.uuidString)"
    }
}

And the main ContentView:

struct ContentView: View {
    var entries: [Entry] = Array(repeating: Entry(), count: 10)

    var body: some View {
        ScrollView {
            ScrollViewReader { value in
                ForEach(entries, id: \.id) { entry in
                    Text(entry.getName())
                        .frame(width: 300, height: 200)
                        .padding(.all, 20)
                }
                .onAppear {
                    value.scrollTo(entries.last?.id, anchor: .center)
                }
            }
        }
    }
}

Try to run this in the new version of SwiftUI announced at WWDC20. I think it is a great enhancement.

Hospitalet answered 3/7, 2020 at 16:50 Comment(3)
Hey, do you have any idea on how to make it scroll every time a new entry is added? I'm making a chat app and I'd like it to scroll to the bottom every time a new message arrives.Stanstance
I have the same problem, when I open the view, the ForEach is empty, and when I add content, it doesn't scroll. Is that possible with an horizontal ScrollView?Downturn
@Downturn you need to explore View.onPreferenceChangeEmulation
N
10

If you have only ONE VIEW as the child view in your ScrollView.

All solutions provided here assume to have multiple child views, each with an id. But what if you have only one child, e.g. a multiline Text, that grows over time and you want to have this automatic scroll feature to the bottom?

You then simply can add an identifier (in my case "1"):

VStack {
   ScrollViewReader { proxy in
       ScrollView {
           Text(historyText)
               .id(1)            // this is where to add an id
               .multilineTextAlignment(.leading)
               .padding()
       }
       .onChange(of: historyText) { _ in
            proxy.scrollTo(1, anchor: .bottom)
       }
    }
 }
Neckerchief answered 14/1, 2023 at 17:46 Comment(0)
S
10

There is a feature that comes with iOS 17: defaultScrollAnchor(_:)

ScrollView {
    ForEach(0..<50) { i in
        Text("Item \(i)")
            .frame(maxWidth: .infinity)
            .padding()
            .background(.blue)
            .clipShape(.rect(cornerRadius: 25))
    }
}
.defaultScrollAnchor(.bottom)
Storax answered 28/9, 2023 at 9:30 Comment(1)
People need to vote and make this go higher because this is the right answer for xcode 15.4 as well. Thank you, Burak!Technique
N
6

Add a textview with empty string and an id at the bottom of scroll view then use scrollTo to scroll to this view whenever something changes. Here is the code

struct ContentView: View {
@Namespace var bottomID
@State var data : [Int] = []

init(){
    var temp : [Int] = []
    for i in 0...100 {
        temp.append(i)
    }
    _data = .init(initialValue: temp)
}

var body: some View {
    
    ScrollViewReader { value in
        ScrollView {
        
            ForEach(data, id: \.self) { i in
                Text("\(i)")
            }
            
            // Empty text view with id
            Text("").id(bottomID)
        }
        .onAppear{
            withAnimation{
                // scrolling to the text value present at bottom
                value.scrollTo(bottomID)
            }
        }
    }
}

}

Nathanialnathaniel answered 2/7, 2022 at 22:7 Comment(0)
D
5

I imagine we are all making chat apps here!

This is how I ended up doing, mostly inspired by answers here with a combo twist (after lots of trying)

struct ChatItemListView: View {
    
    @StateObject var lesson: Lesson

    var body: some View {
        
        ScrollViewReader { scrollViewProxy in

            List(lesson.safeChatItems) { chatItem in
                ChatItemRowView(chatItem: chatItem).id(chatItem.id)
            }
            .onReceive(lesson.objectWillChange) { _ in
                guard !lesson.chatItems.isEmpty else { return }
                Task {
                    scrollViewProxy.scrollTo(
                        lesson.chatItems.last!.id, 
                        anchor: .top
                    )
                }
            }

        }
    }
}

The two important details here are:

  • force passing the id to the view via .id(chatItem.id)
  • putting the scrollTo action into a Task

Without those, it did not work, even if my chatItem implements Identifiable protocol.

Doom answered 19/8, 2022 at 20:22 Comment(1)
Can you please provide explanation as to why this solution works and other solutions didn't?Lisette
D
0

Here is the example, make sure to set the anchor then you can achieve your behavior easily

 import SwiftUI

  struct ContentView: View {

    @Namespace private var topID
    @Namespace private var bottomID
    
    var body: some View {
        VStack(spacing: 20) {
            HStack {
                Button("Scroll to Top") {
                    withAnimation {
                        proxy.scrollTo(topID, anchor: .top)
                    }
                }
                
                Button("Scroll to Bottom") {
                    withAnimation {
                        proxy.scrollTo(bottomID, anchor: .bottom)
                    }
                }
            }
            
            ScrollViewReader { proxy in
                ScrollView {
                    VStack(spacing: 20) {
                        Text("Top Text")
                            .id(topID)
                            .background(Color.yellow)
                            .padding()
                        
                        ForEach(0..<30) { i in
                            Text("Dynamic Text \(i)")
                                .padding()
                                .background(i % 2 == 0 ? Color.blue : Color.green)
                                .foregroundColor(.white)
                        }
                        
                        Text("Bottom Text")
                            .id(bottomID)
                            .background(Color.red)
                            .padding()
                    }
                }
            }
        }
        .padding()
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
Donothingism answered 16/10, 2023 at 1:49 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.