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.
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)
})
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
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)
}
}
}
}