如何让 SwiftUI 列表自动滚动?

向 ListView 添加内容时,我希望它能自动向下滚动。

我使用了一个 SwiftUI List和一个 BindableObject作为控制器。

List(chatController.messages, id: \.self) { message in
MessageView(message.text, message.isMe)
}

当我向消息列表追加新数据时,我希望该列表向下滚动。但是我必须手动向下滚动。

41022 次浏览

由于目前还没有这样的内置特性(不管是 List 还是 ScrollView) ,Xcode 11.2,所以我需要用 ScrollToEnd 行为编写定制的 ScrollView 代码

这个文章的启发。

这是我的实验结果,希望对大家有所帮助。当然还有更多的参数可以配置,比如颜色等等,但是它看起来很琐碎,超出了范围。

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

这可以在 macOS 上通过将 NSScrollView 封装在 NSViewControllerRepesable 对象中来实现(我假设在 iOS 上使用 UIScrollView 和 UIViewControllerRepesable 也可以实现相同的功能)我认为这可能比另一个答案更可靠一些,因为操作系统仍然会管理控制器的大部分功能。

我现在才让这个工作,我计划尝试让一些更多的东西工作,如获得我的内容中的某些行的位置,但这里是我的代码到目前为止:

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

更新: 在 iOS14中,现在有一种本地化的方法可以做到这一点。 我就是这么做的

        ScrollViewReader { scrollView in
ScrollView(.vertical) {
LazyVStack {
ForEach(notes, id: \.self) { note in
MessageView(note: note)
}
}
.onAppear {
scrollView.scrollTo(notes[notes.endIndex - 1])
}
}
}

对于 iOS13及以下版本,你可以尝试:

我发现翻转视图对我来说似乎非常有效。这将启动底部的 ScrollView,并在向其添加新数据时自动向下滚动视图。

  1. 旋转最外面的视图180.rotationEffect(.radians(.pi))
  2. 在垂直平面上翻转 .scaleEffect(x: -1, y: 1, anchor: .center)

你将不得不这样做,以及你的内部意见,因为现在他们都将被旋转和翻转。把它们翻回来,做同样的事情上面。

如果您需要这么多位置,那么为此提供一个自定义视图可能是值得的。

你可以试试下面的方法:

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)

这里有一个视图扩展来翻转它

extension View {
public func flip() -> some View {
return self
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
}

我提出了其他解决方案,使用库 自省获取 UITableView 引用,直到 Apple 改进了可用的方法。

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

IOS13 +

这个名为 ScrollViewProxy 的包添加了一个 ScrollViewReader,它提供了一个 ScrollViewProxy,您可以在其上调用 scrollTo(_:),以获取您给视图的任何 ID。在引擎盖下,它使用内省来获得 UIScrollView。

例如:

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

现在可以这样做了,因为 Xcode 12使用了全新的 ScrollViewProxy,下面是示例代码:

你可以用你的 chatController.messages和调用 scrollViewProxy.scrollTo(chatController.messages.count-1)来更新下面的代码。

什么时候做? 也许在 SwiftUI 的新 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)
}
}
}
}
}
}
}

SwiftUI 2.0-iOS 14

这是一个: (包装在 ScrollViewReader)

scrollView.scrollTo(rowID)

随着 SwiftUI 2.0的发布,您可以在 ScrollViewReader中嵌入任何可滚动元素,然后您可以访问滚动元素所需的确切位置。

下面是一个完整的演示应用程序:

// 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

下面是我的动态获取数据的观察对象的工作解决方案,比如聊天中通过对话填充的消息数组。

消息数组模型:

 struct Message: Identifiable, Codable, Hashable {
        

//MARK: Attributes
var id: String
var message: String
        

init(id: String, message: String){
self.id = id
self.message = message
}
}

实际观点:

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

看看 GitHub 上这个完全可行的例子: Https://github.com/kenagt/autoscrollviewexample

一个被简化的... ..。

.onChange(of: messages) { target in
withAnimation {
scrollView.scrollTo(target.last?.id, anchor: .bottom)
}
}

IOS 14/15:

我是这样使用 ScrollView 的 onChange修饰符的:

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

正如很多人指出的那样。您可以使用 ScrollViewReader 滚动到消息的最后一个 id。但是,我的 ScrollView 并没有完全滚动到底部。另一个版本是将一个已定义的 id 放到一个文本中,而且在所有消息下面都没有不透明性。

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