SwiftUI automatically scroll to bottom in ScrollView (Bottom first)

Viewed 21211

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())
        }
      }
    }
  }
5 Answers

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

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.

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.

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

}

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.

Related