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.
If you want to programmatically make SwiftUI's List move to show a specific row, you should embed it inside a ScrollViewReader . This provides a scrollTo() method on its proxy that can move to any row inside the list, just by providing its ID and optionally also an anchor.
Thanks to a new set of Accessibility features that were introduced in Android Nougat (7.0), the Easy Scroll app automatically scrolls through your screen at regular intervals of time. Using Easy Scroll is, like the name suggests, quite easy.
1. Manual scroll to a position with the click of a button. 2. Auto-scroll to the bottom as soon as the view appears.
A view that arranges its children in a line that grows vertically, creating items only as needed.
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.
.rotationEffect(.radians(.pi))
.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)
}
}
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.
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)
})
}
}
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)
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With