如何使SwiftUI列表自动滚动?

86

当我往我的ListView中添加内容时,我希望它自动向下滚动。

我正在使用SwiftUI的List,以及一个BindableObject作为控制器。新数据将被追加到列表中。


我正在使用SwiftUI的List和BindableObject作为控制器。新数据将被追加到该列表中。
List(chatController.messages, id: \.self) { message in
    MessageView(message.text, message.isMe)
}

我希望在我向消息列表添加新数据时,该列表能够自动向下滚动。但是目前我必须手动向下滚动。


你目前尝试了什么? - Stephen
像 onReceive() 或 transformEnvironment() 这样的函数,但我不知道要传递什么参数以及它们的行为如何。我只是根据名称猜测。我对 SwiftUI 和 Swift 一般都非常陌生。 - Seb
说实话(并且尊重地说),我很想将这个标记为重复 - 但我发现另外两个类似的问题都没有答案。(重复需要一个被接受的答案。)这告诉我你可能在问一个目前还没有答案的问题,因此我应该点赞你的问题。我真的希望有一个答案,因为我可能在另一个月需要它。祝好运! - user7014451
12个回答

73

更新:在 iOS 14 中,现在有一种原生的方法来做到这一点。我是这样做的。

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

如果你使用的是iOS 13或更早版本,可以尝试以下方法:

我发现将视图翻转似乎对我很有效。这会将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)
    }
}

2
你救了我一命,我之前使用了@asperi的答案,但那种滚动视图并不是最优化的,在我的使用情况下存在缺陷。我想你也像我一样在处理聊天消息,所以你的答案最适合我。尽管如此,我们仍然无法通过代码随意滚动,但目前这个解决方案可以使用。 - Divyanshu Negi
3
很棒,我很高兴能够帮忙!我还在开发一个类似聊天界面的接口,花了几个小时来寻找一个好的解决方案。这个解决方案来自于另一个关于 UIKit 的 SO 帖子,我只是将其适配到了 SwiftUI 上。我认为,在苹果推出更好的本地实现之前,这应该是相对高效的解决方案。 - cjpais
2
嘿@Evert,我不确定这个。我必须进行实验并可能逐步跟踪SwiftUI正在进行的调用以进行呈现。我猜测有一些事件排序可以解决问题,但是我没有足够的经验来确定。如果您可以使用iOS 14 / XCode beta,则建议通过ScrollViewReader使用官方支持的方式,这可能会解决任何问题(或引起新问题:)) - cjpais
4
嘿,我现在在使用 Xcode 12 工作,发现你的代码在列表更改时无法使 scrollView 滚动。我正在制作一个聊天应用程序,希望每次有新消息到达时都能将其滚动到底部。你有什么建议吗? - Evert
1
只是提供帮助,如果你正在使用某些带有可识别协议的自定义结构体,则必须使用 scrollView.scrollTo(content.data.[value].id, anchor: .center) - Alfredo Luco G
显示剩余12条评论

46

由于当前(无论是List还是ScrollView)没有内置此功能,因此在Xcode 11.2中,我需要使用自定义ScrollView编写具有ScrollToEnd行为的代码。

!!! 受这篇文章的启发。

这是我的实验结果,希望它也能帮助别人。当然,还有更多的参数可以配置,例如颜色等,但这似乎是琐碎和超出范围的。

滚动到末尾 反转内容

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

2
故事板出现在 UIKit 大约 5-10 年后...让我们保持乐观。)) 此外,没有人移除故事板 - 它们仍然存在。 - Asperi
我应该提到xib/nib。但是如果有类似于滚动到内容偏移或相关的东西,那将非常方便。 - Sandeep Rana
我认为你也可以通过将UIScrollView包装在一个UIViewControllerRepresentable类中来实现这一点,尽管我很难弄清楚如何做到这一点(实际上我正在使用相当于NS类的macOS编写 - 这似乎可能会更加复杂)。但如果这样做有效,我敢打赌它会更少出现错误,因为操作系统仍然会管理上面编码的许多方面。 - Dan
1
这对我有效。为了保持一致的垂直间距,我建议将:VStack { content() } 更改为:VStack(spacing: 0) { content() }。 - Brendon
1
这个示例可以用于按钮点击事件吗?比如当视图出现时,我需要CustomScrollView(scrollToEnd:false),但是当按钮被点击时,我需要CustomScrollView(scrollToEnd:true)。我尝试使用@State变量,但视图没有更新。 - Arafin Russell
1
这个解决方案只适用于我的情况下添加新元素。谢谢! - Kenan Begić

12

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
}

1
这是正确的方式(感谢你将其写出来)。它完全使用SwiftUI,你可以在ScrollViewReader中包装一个List;你只需要获取列表中最后一个元素的标签并滚动到那里即可。 - green_knight
那么每当有任何消息变化时,您都会自动滚动到底部?我可以看出这还没有投入生产... - Lucas C. Feijo
1
@LucasC.Feijo 这取决于您的要求。例如,如果您不希望在用户浏览旧消息时自动滚动,则可以检查底部偏移量,如果列表似乎已滚动,则不要在 onChange 中运行代码。 - atulkhatri

7

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

预览

预览


6

啊,它只适用于静态数据...当我传入动态数据时,.id()在数据加载之前就被定义了,所以它显示为空视图。 - Stephen Lee
@StephenLee,你能在 Github 上开一个问题(issue),并提供一些你所看到的示例代码吗?也许我们可以找出问题所在。 - Casper Zandbergen
是的,我正在私人仓库中工作,但我可以详细说明一下: https://gist.github.com/Mr-Perfection/937520af1818cd44714f59a4c0e184c4我认为问题出在在加载数据之前调用了id(),因此它会抛出异常。因此,该列表只能看到0个ID,但列表项> 0。虽然如此,我真的很希望能使用你的代码 :P 看起来很整洁 - Stephen Lee
我最终被scrollIId功能难住了。日志一直抛出警告,它只能找到AnyHashable而不是所需的ID。我最幸运的是切换到Introspect并通过经过验证的tableView API进行处理。 - John Gorenfeld

4

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

3

我提供另一种解决方法,使用库Introspect获取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)
            }
        }
    }
}

1
如果你正在开发 iOS 14 应用,我建议你使用 ScrollViewReader。 - 93sauu
是的,我同意@93sauu的观点,但我仍在使用13.4... 我认为iOS 14仍处于测试版阶段,你需要请求开发工具包? - Stephen Lee
1
您可以使用Xcode 12 beta开发iOS 14应用程序,但目前需要开发者账户才能下载。 - 93sauu
@93sauu,您可以使用开发者帐户下载Xcode 12 beta版,无需加入开发者计划。 - iGhost

3

在 macOS 上,可以通过将 NSScrollView 包装在 NSViewControllerRepresentable 对象中来实现此操作(我认为在 iOS 上使用 UIScrollView 和 UIViewControllerRepresentable 也是一样的)。我认为这可能比这里的其他答案更可靠,因为操作系统仍然管理着控件的大部分功能。

我刚刚让它工作了,并计划尝试让一些其他事情起作用,比如获取我的内容中某些行的位置,但这是我到目前为止的代码:

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

这是正确的解决方案!我已经创建了一个UIKit版本,使用这个答案作为基础:https://gist.github.com/jfuellert/67e91df63394d7c9b713419ed8e2beb7 - jfuellert
嗨@jfuellert,我尝试了你的解决方案,但出现了问题,这是警告:“在视图更新期间修改状态,这将导致未定义的行为。”我相信这是因为contentOffSet和scrollViewDidScroll同时尝试更新视图。 - Claudiu
尝试在引发警告的部分使用DispatchQueue.main.async(execute: {...})。 - Dan

1

List现在支持自动滚动:

        ScrollViewReader { proxy in
            VStack {
                Button("Jump to last") {
                    proxy.scrollTo(viewModel.messageViewModels.last?.id)
                }

                List(viewModel.messageViewModels) { 
                    MessageView(viewModel: $0).id($0.id)
                }
            }
        }

      

1
这是我处理动态获取数据的观察对象的工作解决方案,例如聊天中消息数组通过对话逐渐填充。
消息数组的模型:
 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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接