【问题标题】:How to make a SwiftUI List scroll automatically?如何使 SwiftUI 列表自动滚动?
【发布时间】:2019-12-07 02:08:42
【问题描述】:

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

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

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

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

【问题讨论】:

  • 到目前为止你有什么尝试?
  • onReceive() 或 transformEnvironment() 之类的函数,但我不知道如何传递这些函数以及它们的行为方式。我只是从名字猜到的。总的来说,我对 SwiftUI 和 Swift 超级陌生
  • 老实说(并尊重),我很乐意将此标记为重复 - 除了我发现另外两个没有答案的类似问题。 (Dups 需要一个可接受的答案。)这告诉我,您可能会问一些(还没有)没有答案的问题,因此我应该对您的问题投赞成票。我真的希望有一个答案,因为我可能需要再过一个月。祝你好运!

标签: swift listview swiftui autoscroll


【解决方案1】:

更新:在 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)
    }
}

【讨论】:

  • 你拯救了这一天,我使用了@asperi 的上述答案,但是这种滚动视图没有优化并且有问题,特别是在我的用例中,我认为你也在像我一样处理聊天消息也因此您的答案在我的情况下效果最好,但是,我们无法通过代码滚动到任何我们想要的地方,但现在这个解决方案有效。
  • 非常高兴我能帮上忙!我也在开发一个类似聊天的界面,我花了几个小时试图找到一个好的解决方案。这个解决方案来自我刚刚为 SwiftUI 改编的 UIKit 的另一篇 SO 帖子。在 Apple 提供更好的原生实现之前,我认为这应该是一个相对高效的解决方案。
  • 嘿@Evert,我不确定。我必须尝试并可能逐步完成 SwiftUI 为渲染而进行的调用。我的猜测是有一些事件排序会起作用,但我没有足够的经验来肯定地说。如果您可以使用 iOS 14/XCode 测试版,我建议您通过 ScrollViewReader 使用官方支持的方式,这可能会解决任何问题(或提出新问题 :))
  • 谢谢,我想我确实会切换到最新的 Xcode 测试版,这样我就可以使用原生功能了。
  • @cjpais 嘿,我现在在 Xcode 12 中工作,我看到当其中的列表发生更改时,您的代码不会使 scrollView 滚动。我正在制作一个聊天应用程序,我希望每次有新消息到达时它都会滚动到底部。你对如何做到这一点有什么建议吗?
【解决方案2】:

由于目前没有内置的此类功能(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: $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

【讨论】:

  • Storyboards 出现之后,我记不太清了,UIKit 的 5-10 年......让我们保持乐观。 )) 此外 - 没有人删除故事板 - 他们还在这里。
  • 我应该提到 xib/nib。但如果有类似滚动到内容偏移或相关的东西,它真的很方便。
  • 这对我有用。对于一致的垂直间距,我建议将: VStack { content() } 更改为: VStack(spacing: 0) { content() }
  • 这个例子可以用于按钮点击事件吗?就像当视图出现时我需要 CustomScrollView(scrollToEnd: false) 但是当按钮单击时我需要 CustomScrollView(scrollToEnd: true)。我尝试使用 @State 变量,但视图未更新
  • 此解决方案仅适用于添加新元素,就我而言。谢谢!
【解决方案3】:

您现在可以执行此操作,从 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)
                        }
                    }
                }
            }
        }
    }
}

【讨论】:

    【解决方案4】:

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

    预览

    【讨论】:

      【解决方案5】:

      这可以在 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 版本,以这个答案为基础:gist.github.com/jfuellert/67e91df63394d7c9b713419ed8e2beb7
      • 嗨@jfuellert,我已经尝试过您的解决方案,但有一个问题,这里是警告:“在视图更新期间修改状态,这将导致未定义的行为。”。我相信这是因为 contentOffSet 和 scrollViewDidScroll 都试图同时更新视图。
      • 尝试在引发警告的位上使用 DispatchQueue.main.async(execute: {...})。
      【解决方案6】:

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

      【讨论】:

      • 啊,它只适用于静态数据......当我传入动态数据时,.id() 是在加载数据之前定义的,因此它显示了空视图。
      • @StephenLee 你能在 github 上用一些你所看到的示例代码打开一个问题吗?也许我们可以弄清楚。
      • 是的,嗯,我在私人仓库工作,但我可以详细说明:gist.github.com/Mr-Perfection/937520af1818cd44714f59a4c0e184c4 我认为问题是在列表加载数据之前调用了 id()。然后,它抛出了一个异常。所以列表只看到 0 id 但列表项 > 0。我真的希望我可以使用你的代码:P 看起来很整洁
      【解决方案7】:

      我提出了使用库 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 14 开发,我建议您使用 ScrollViewReader。
      • 是的,同意@93sauu,但我仍在使用13.4...我认为iOS 14 仍处于测试阶段,您需要申请开发工具包吗?
      • 您可以使用 Xcode 12 测试版为 iOS 14 开发,您现在确实需要一个开发者帐户才能下载。
      • @93sauu,你可以用你的开发者账号下载Xcode 12 beta,不需要注册开发者计划。
      【解决方案8】:

      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
      }
      

      【讨论】:

        【解决方案9】:

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

        消息数组模型:

         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

        【讨论】:

          【解决方案10】:

          可以简化.....

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

          【讨论】:

            【解决方案11】:

            正如许多人指出的那样。您可以使用 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)
                    }
                }
            }
            

            【讨论】:

              猜你喜欢
              • 1970-01-01
              • 1970-01-01
              • 2021-10-29
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 2020-01-19
              • 1970-01-01
              • 1970-01-01
              相关资源
              最近更新 更多