【问题标题】:How to scroll List programmatically in SwiftUI?如何在 SwiftUI 中以编程方式滚动列表?
【发布时间】:2020-07-06 09:54:13
【问题描述】:

看起来在当前的工具/系统中,刚刚发布了 Xcode 11.4 / iOS 13.4,List 中将没有对“滚动到”功能的 SwiftUI 原生支持。因此,即使他们,Apple,将在下一个 major 发布时提供它,我也需要对 iOS 13.x 的向后支持。

那么我将如何以最简单和轻松的方式做到这一点?

  • 滚动列表到结尾
  • 将列表滚动到顶部
  • 及其他

(我不喜欢将完整的 UITableView 基础架构包装到 UIViewRepresentable/UIViewControllerRepresentable 中,就像之前在 SO 中提出的那样)。

【问题讨论】:

  • 表单或列表不会滚动,您可以使用 .frame(height: x) 使高度大于可滚动区域,其中 x 是大于内容高度的值。

标签: ios swift swiftui


【解决方案1】:

SWIFTUI 2.0

这是 Xcode 12 / iOS 14 (SwiftUI 2.0) 中可能的替代解决方案,当滚动控件位于滚动区域之外时,可以在相同的场景中使用(因为 SwiftUI2 ScrollViewReader 只能在内部使用 em>ScrollView)

注意:行内容设计不在考虑范围内

使用 Xcode 12b / iOS 14 测试

class ScrollToModel: ObservableObject {
    enum Action {
        case end
        case top
    }
    @Published var direction: Action? = nil
}

struct ContentView: View {
    @StateObject var vm = ScrollToModel()

    let items = (0..<200).map { $0 }
    var body: some View {
        VStack {
            HStack {
                Button(action: { vm.direction = .top }) { // < here
                    Image(systemName: "arrow.up.to.line")
                      .padding(.horizontal)
                }
                Button(action: { vm.direction = .end }) { // << here
                    Image(systemName: "arrow.down.to.line")
                      .padding(.horizontal)
                }
            }
            Divider()
            
            ScrollViewReader { sp in
                ScrollView {
               
                    LazyVStack {
                        ForEach(items, id: \.self) { item in
                            VStack(alignment: .leading) {
                                Text("Item \(item)").id(item)
                                Divider()
                            }.frame(maxWidth: .infinity).padding(.horizontal)
                        }
                    }.onReceive(vm.$direction) { action in
                        guard !items.isEmpty else { return }
                        withAnimation {
                            switch action {
                                case .top:
                                    sp.scrollTo(items.first!, anchor: .top)
                                case .end:
                                    sp.scrollTo(items.last!, anchor: .bottom)
                                default:
                                    return
                            }
                        }
                    }
                }
            }
        }
    }
}

SWIFTUI 1.0+

这是一种可行的方法的简化变体,看起来很合适,并且需要几个屏幕代码。

使用 Xcode 11.2+ / iOS 13.2+ 测试(也使用 Xcode 12b / iOS 14)

使用演示:

struct ContentView: View {
    private let scrollingProxy = ListScrollingProxy() // proxy helper

    var body: some View {
        VStack {
            HStack {
                Button(action: { self.scrollingProxy.scrollTo(.top) }) { // < here
                    Image(systemName: "arrow.up.to.line")
                      .padding(.horizontal)
                }
                Button(action: { self.scrollingProxy.scrollTo(.end) }) { // << here
                    Image(systemName: "arrow.down.to.line")
                      .padding(.horizontal)
                }
            }
            Divider()
            List {
                ForEach(0 ..< 200) { i in
                    Text("Item \(i)")
                        .background(
                           ListScrollingHelper(proxy: self.scrollingProxy) // injection
                        )
                }
            }
        }
    }
}

解决方案:

注入List 的可表示的Light view 可以访问UIKit 的视图层次结构。由于List 重复使用行,因此没有更多的值可以将行放入屏幕。

struct ListScrollingHelper: UIViewRepresentable {
    let proxy: ListScrollingProxy // reference type

    func makeUIView(context: Context) -> UIView {
        return UIView() // managed by SwiftUI, no overloads
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
    }
}

找到封闭UIScrollView 的简单代理(需要执行一次),然后将所需的“滚动到”操作重定向到存储的滚动视图

class ListScrollingProxy {
    enum Action {
        case end
        case top
        case point(point: CGPoint)     // << bonus !!
    }

    private var scrollView: UIScrollView?

    func catchScrollView(for view: UIView) {
        if nil == scrollView {
            scrollView = view.enclosingScrollView()
        }
    }

    func scrollTo(_ action: Action) {
        if let scroller = scrollView {
            var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
            switch action {
                case .end:
                    rect.origin.y = scroller.contentSize.height +
                        scroller.contentInset.bottom + scroller.contentInset.top - 1
                case .point(let point):
                    rect.origin.y = point.y
                default: {
                    // default goes to top
                }()
            }
            scroller.scrollRectToVisible(rect, animated: true)
        }
    }
}

extension UIView {
    func enclosingScrollView() -> UIScrollView? {
        var next: UIView? = self
        repeat {
            next = next?.superview
            if let scrollview = next as? UIScrollView {
                return scrollview
            }
        } while next != nil
        return nil
    }
}

【讨论】:

  • 我对这个解决方案有一些疑问。它似乎仅在您之前与列表交互时才有效。你注意到了还是11.4.1 的事情?
  • @Błażej,我还没有迁移到 11.4.1,所以可能是更新问题。 11.4 一切正常。
  • 我在滚动视图中使用它。只有在我与滚动视图进行一些交互后,它才有效。 "if let scroller = scrollView {" 不立即符合
  • 我通过在 ScrollView 内容完全加载后重新声明 self.scrollingProxy = ListScrollingProxy() 解决了这个问题
  • @AmineArous 在我的例子中,我使用来自网络的 JSON 填充一个列表,在我获取数据并将数据添加到列表之后,我这样做: DispatchQueue.main.async { self.scrollingProxy = ListScrollingProxy( ) }
【解决方案2】:

只需滚动到 id:

scrollView.scrollTo(ROW-ID)

由于 SwiftUI 结构化设计的数据驱动,您应该知道您所有的项目 ID。因此,您可以在 iOS 14Xcode 12

中使用 ScrollViewReader 滚动到任何 id
struct ContentView: View {
    let items = (1...100)

    var body: some View {
        ScrollViewReader { scrollProxy in
            ScrollView {
                ForEach(items, id: \.self) { Text("\($0)"); Divider() }
            }

            HStack {
                Button("First!") { withAnimation { scrollProxy.scrollTo(items.first!) } }
                Button("Any!") { withAnimation { scrollProxy.scrollTo(50) } }
                Button("Last!") { withAnimation { scrollProxy.scrollTo(items.last!) } }
            }
        }
    }
}

注意 ScrollViewReader 应该支持所有可滚动的内容,但现在它只支持ScrollView


预览

【讨论】:

    【解决方案3】:

    感谢 Asperi,非常棒的提示。当在视图之外添加新条目时,我需要向上滚动一个列表。重新设计以适应 macOS。

    我将状态/代理变量带到环境对象中,并在视图外部使用它来强制滚动。我发现我必须更新它两次,第二次延迟 0.5 秒才能获得最佳结果。第一次更新防止视图在添加行时滚动回顶部。第二次更新滚动到最后一行。我是新手,这是我的第一篇 stackoverflow 帖子:o

    已针对 MacOS 更新:

    struct ListScrollingHelper: NSViewRepresentable {
    
        let proxy: ListScrollingProxy // reference type
    
        func makeNSView(context: Context) -> NSView {
            return NSView() // managed by SwiftUI, no overloads
        }
    
        func updateNSView(_ nsView: NSView, context: Context) {
            proxy.catchScrollView(for: nsView) // here NSView is in view hierarchy
        }
    }
    
    class ListScrollingProxy {
        //updated for mac osx
        enum Action {
            case end
            case top
            case point(point: CGPoint)     // << bonus !!
        }
    
        private var scrollView: NSScrollView?
    
        func catchScrollView(for view: NSView) {
            //if nil == scrollView { //unB - seems to lose original view when list is emptied
                scrollView = view.enclosingScrollView()
            //}
        }
    
        func scrollTo(_ action: Action) {
            if let scroller = scrollView {
                var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
                switch action {
                    case .end:
                        rect.origin.y = scroller.contentView.frame.minY
                        if let documentHeight = scroller.documentView?.frame.height {
                            rect.origin.y = documentHeight - scroller.contentSize.height
                        }
                    case .point(let point):
                        rect.origin.y = point.y
                    default: {
                        // default goes to top
                    }()
                }
                //tried animations without success :(
                scroller.contentView.scroll(to: NSPoint(x: rect.minX, y: rect.minY))
                scroller.reflectScrolledClipView(scroller.contentView)
            }
        }
    }
    extension NSView {
        func enclosingScrollView() -> NSScrollView? {
            var next: NSView? = self
            repeat {
                next = next?.superview
                if let scrollview = next as? NSScrollView {
                    return scrollview
                }
            } while next != nil
            return nil
        }
    }
    

    【讨论】:

    • 谢谢,macOS 需要它:)
    【解决方案4】:

    这是一个适用于 iOS13&14 的简单解决方案:
    使用Introspect.
    我的情况是初始滚动位置。

    ScrollView(.vertical, showsIndicators: false, content: {
            ...
        })
        .introspectScrollView(customize: { scrollView in
            scrollView.scrollRectToVisible(CGRect(x: 0, y: offset, width: 100, height: 300), animated: false)
        })
    

    如果需要,可以根据屏幕大小或元素本身计算高度。 此解决方案适用于垂直滚动。对于水平,您应该指定 x 并将 y 保留为 0

    【讨论】:

    • 这是针对 SwiftUI 1.0 的最佳答案
    【解决方案5】:

    现在可以使用 Xcode 12 中的所有新 ScrollViewProxy 来简化这一点,如下所示:

    struct ContentView: View {
        let itemCount: Int = 100
        var body: some View {
            ScrollViewReader { value in
                VStack {
                    Button("Scroll to top") {
                        value.scrollTo(0)
                    }
                    
                    Button("Scroll to buttom") {
                        value.scrollTo(itemCount-1)
                    }
                    
                    ScrollView {
                        LazyVStack {
                            ForEach(0 ..< itemCount) { i in
                                Text("Item \(i)")
                                    .frame(height: 50)
                                    .id(i)
                            }
                        }
                    }
                }
            }
        }
    }
    

    【讨论】:

      【解决方案6】:

      我的两分钱用于根据其他逻辑在任何时候删除和重新定位列表..例如在删除之后,敌人的例子会进入顶部。 (这是一个超精简的示例,我习惯在网络调用后重新定位:在网络调用后我更改 previousIndex )

      结构内容视图:查看{

      @State private var previousIndex : Int? = nil
      @State private var items = Array(0...100)
      
      func removeRows(at offsets: IndexSet) {
          items.remove(atOffsets: offsets)
          self.previousIndex = offsets.first
      }
      
      var body: some View {
          ScrollViewReader { (proxy: ScrollViewProxy) in
              List{
                  ForEach(items, id: \.self) { Text("\($0)")
                  }.onDelete(perform: removeRows)
              }.onChange(of: previousIndex) { (e: Equatable) in
                  proxy.scrollTo(previousIndex!-4, anchor: .top)
                  //proxy.scrollTo(0, anchor: .top) // will display 1st cell
              }
      
          }
          
      }
      

      }

      【讨论】:

      • 我只想指出这是唯一的解决方案,它使用列表,因为问题的标题被问到了。在 iOS 15.1 上测试,并且可以工作。谢谢。
      【解决方案7】:

      MacOS 11:如果您需要根据视图层次结构之外的输入滚动列表。我使用新的 scrollViewReader 遵循了原始的滚动代理模式:

      struct ScrollingHelperInjection: NSViewRepresentable {
          
          let proxy: ScrollViewProxy
          let helper: ScrollingHelper
      
          func makeNSView(context: Context) -> NSView {
              return NSView()
          }
      
          func updateNSView(_ nsView: NSView, context: Context) {
              helper.catchProxy(for: proxy)
          }
      }
      
      final class ScrollingHelper {
          //updated for mac os v11
      
          private var proxy: ScrollViewProxy?
          
          func catchProxy(for proxy: ScrollViewProxy) {
              self.proxy = proxy
          }
      
          func scrollTo(_ point: Int) {
              if let scroller = proxy {
                  withAnimation() {
                      scroller.scrollTo(point)
                  }
              } else {
                  //problem
              }
          }
      }
      

      环境对象: @Published var scrollingHelper = ScrollingHelper()

      在视图中:ScrollViewReader { reader in .....

      在视图中注入: .background(ScrollingHelperInjection(proxy: reader, helper: scrollingHelper)

      在视图层次结构之外使用:scrollingHelper.scrollTo(3)

      【讨论】:

        【解决方案8】:

        正如@lachezar-todorov 的回答中提到的Introspect 是一个很好的库,可以访问SwiftUI 中的UIKit 元素。但请注意,您用于访问UIKit 元素的块被多次调用。这真的会弄乱您的应用程序状态。在我的 cas CPU 使用率达到 %100 并且应用程序变得无响应。我不得不使用一些前置条件来避免它。

        ScrollView() {
            ...
        }.introspectScrollView { scrollView in
            if aPreCondition {
                //Your scrolling logic
            }
        }
        

        【讨论】:

          【解决方案9】:

          另一种很酷的方法是只使用命名空间包装器:

          一种动态属性类型,允许访问由包含该属性的对象(例如视图)的持久标识定义的命名空间。

          struct ContentView: View {
              
              @Namespace private var topID
              @Namespace private var bottomID
              
              let items = (0..<100).map { $0 }
              
              var body: some View {
                  
                  ScrollView {
                      
                      ScrollViewReader { proxy in
                          
                          Section {
                              LazyVStack {
                                  ForEach(items.indices, id: \.self) { index in
                                      Text("Item \(items[index])")
                                          .foregroundColor(.black)
                                          .frame(maxWidth: .infinity, alignment: .leading)
                                          .padding()
                                          .background(Color.green.cornerRadius(16))
                                  }
                              }
                          } header: {
                              HStack {
                                  Text("header")
                                  
                                  
                                  Spacer()
                                  
                                  Button(action: {
                                      withAnimation {
                                          proxy.scrollTo(bottomID)
                                          
                                      }
                                  }
                                  ) {
                                      Image(systemName: "arrow.down.to.line")
                                          .padding(.horizontal)
                                  }
                              }
                              .padding(.vertical)
                              .id(topID)
                              
                          } footer: {
                              HStack {
                                  Text("Footer")
                                  
                                  
                                  Spacer()
                                  
                                  Button(action: {
                                      withAnimation {
                                          proxy.scrollTo(topID) }
                                  }
                                  ) {
                                      Image(systemName: "arrow.up.to.line")
                                          .padding(.horizontal)
                                  }
                                  
                              }
                              .padding(.vertical)
                              .id(bottomID)
                              
                          }
                          .padding()
                          
                          
                      }
                  }
                  .foregroundColor(.white)
                  .background(.black)
              }
          }
          

          【讨论】:

            猜你喜欢
            • 1970-01-01
            • 2020-04-08
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2014-09-26
            • 1970-01-01
            相关资源
            最近更新 更多