如何在 SwiftUI 中以编程方式滚动列表?

Posted

技术标签:

【中文标题】如何在 SwiftUI 中以编程方式滚动列表?【英文标题】:How to scroll List programmatically in SwiftUI? 【发布时间】:2020-03-25 19:14:34 【问题描述】:

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

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

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

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

【问题讨论】:

表单或列表不会滚动,您可以使用 .frame(height: x) 使高度大于可滚动区域,其中 x 是大于内容高度的值。 【参考方案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 秒才能获得最佳结果。第一次更新防止视图在添加行时滚动回顶部。第二次更新滚动到最后一行。我是新手,这是我的第一篇 *** 帖子: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)
    

【讨论】:

以上是关于如何在 SwiftUI 中以编程方式滚动列表?的主要内容,如果未能解决你的问题,请参考以下文章

如何在 SwiftUI 中以编程方式计算 MKMapCamera centerCoordinateDistance

如何在android中以编程方式创建多个列表

如何在 iOS 应用程序中以编程方式检测 SwiftUI 的使用情况

如何以编程方式在 SwiftUI 中滚动?

如何使用 vanilla js 在 Cordova / Capacitor / Conic 中以编程方式滚动

如何在IOS中以编程方式滚动UICollectionViewCell?