如何使 SwiftUI 列表自动滚动?

How to make a SwiftUI List scroll automatically?

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

我正在使用 SwiftUI ListBindableObject 作为控制器。新数据正在附加到列表中。

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

我希望列表向下滚动,因为我将新数据添加到消息列表。但是我必须手动向下滚动。

由于目前没有内置此类功能(List 和 ScrollView 都没有),Xcode 11.2,所以我需要使用 ScrollToEnd 行为编写自定义 ScrollView

!!!灵感来自 this 文章。

这是我的实验结果,希望对您有所帮助。当然还有更多参数,这些参数可能是可配置的,比如颜色等,但它看起来微不足道且超出范围。

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: [=10=], 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([=10=]) }
            .onEnded { self.onDragEnded([=10=], 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)
        })
    }
}

backup

这可以在 macOS 上通过将 NSScrollView 包装在 NSViewControllerRepresentable 对象中来完成(我假设使用 UIScrollView 和 UIViewControllerRepresentable 在 iOS 上也可以实现同样的效果。)我认为这可能是比此处的其他答案更可靠一些,因为 OS 仍将管理控件的大部分功能。

我刚刚开始工作,我计划尝试让更多的东西工作,例如在我的内容中获取某些行的位置,但这是我目前的代码:

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

更新:在 iOS 14 中,现在有一种本机方法可以执行此操作。 我就是这样做的

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

我提出了使用库 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)
            }
        }
    }
}

iOS 13+

This package called ScrollViewProxy 添加了一个 ScrollViewReader,它提供了一个 ScrollViewProxy,您可以在其上调用 scrollTo(_:) 以获取您提供给视图的任何 ID。在引擎盖下它使用 Introspect 来获取 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 - iOS14

这是一个:(将其包装在 ScrollViewReader

scrollView.scrollTo(rowID)

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

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

// A simple list of messages
struct MessageListView: View {
    var messages = (1...100).map { "Message number: \([=11=])" }

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

预览

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

消息数组模型:

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