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

44
在当前工具/系统中,比如刚发布的Xcode 11.4/iOS 13.4,似乎没有SwiftUI原生支持"scroll-to"功能在List中。所以即使他们,苹果公司,在下一个主要版本中提供了它,我仍然需要向后支持iOS 13.x。
那么我应该用最简单、最轻量的方式来实现呢? - 滚动列表到末尾 - 滚动列表到顶部 - 其他
(我不喜欢将完整的UITableView基础设施包装成UIViewRepresentable / UIViewControllerRepresentable,这是之前在SO上提出的建议)。

如果使用.frame(height: x)将高度设置为大于可滚动区域的值(其中x是大于内容高度的值),则表单或列表将无法滚动。 - Alex Brown
11个回答

62

SWIFTUI 2.0

在Xcode 12 / iOS 14(SwiftUI 2.0)中,这里是一个可能的备选方案,可在滚动控件位于滚动区域之外的相同场景中使用(因为SwiftUI 2的ScrollViewReader仅可在ScrollView内部使用)。

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

已在Xcode 12b / iOS 14上进行测试

demo2

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

demo

解决方案:

List中注入可视化轻量级控件可以访问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
    }
}

1
@Błażej,我还没有迁移到11.4.1,所以可能是更新问题。在11.4上一切正常。 - Asperi
我正在一个滚动视图中使用它。只有在我与滚动视图进行一些交互后才能正常工作。“if let scroller = scrollView {”不能立即符合要求。 - Filipe Sá
1
在 ScrollView 内容完全加载后,通过重新声明 self.scrollingProxy = ListScrollingProxy() 来解决它。 - Filipe Sá
1
在我的情况下,我使用从网络获取的JSON填充一个列表,当我获取并将数据添加到列表后,我执行以下操作:DispatchQueue.main.async { self.scrollingProxy = ListScrollingProxy() }。 - Filipe Sá
苹果的SwiftUI 2文档显示ScrollViewReader被声明为ScrollView的父级。不管怎样似乎都可以工作。 - Adam
显示剩余5条评论

18

只需滚动到id位置:

scrollView.scrollTo(ROW-ID)

由于SwiftUI的设计结构是数据驱动的,您应该知道所有项目的ID。因此,使用iOS 14和Xcode 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


预览

预览


嘿,感谢您提供的好解决方案。我正在尝试做类似的事情。但是,我想控制动画时间。将位置1到7移动总共需要1秒钟。我尝试了这种方式withAnimation (.linear(duration: 1)){ reader.scrollTo(7) }。但是没有对自定义动画时间进行控制。它使用默认时间进行动画处理。您有任何想法吗? - Shourob Datta

14

首选方法

这个答案引起了更多的关注,但我应该指出 ScrollViewReader 是正确的方法来实现这一点。Introspect 方法只有在阅读器/代理因版本限制而无法工作时才适用。

ScrollViewReader { proxy in
    ScrollView(.vertical) {
        TopView().id("TopConstant")
        ...
        MiddleView().id("MiddleConstant")
        ...
        Button("Go to top") {
            proxy.scrollTo("TopConstant", anchor: .top)
        }
        .id("BottomConstant")
    }
    .onAppear{
        proxy.scrollTo("MiddleConstant")
    }
    .onChange(of: viewModel.someProperty) { _ in
        proxy.scrollTo("BottomConstant")
    }
}

字符串应该在body属性之外的一个地方定义。

遗留答案

这里有一个简单的解决方案,适用于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。

3
针对 SwiftUI 1.0,最佳答案如下。 - Dot Freelancer
我需要使用.onChange来滚动到顶部,这帮助我成功了。谢谢。 - codingCartooningCPA

9

感谢Asperi提供的好建议。我需要在视图之外添加新条目时使列表向上滚动。我将状态/代理变量转移到环境对象中,并在视图之外使用它来强制滚动。我发现我需要更新两次,第二次需要延迟0.5秒才能获得最佳结果。第一次更新操作可以防止视图在添加行时滚动到顶部。第二次更新则将其滚动到最后一行。作为一个新手,这也是我的第一篇Stack Overflow帖子 :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
    }
}

你可以将此用于动画:NSAnimationContext.beginGrouping() NSAnimationContext.current.duration = 6.0 let clipView = scroller.contentView var newOrigin = clipView.bounds.origin newOrigin.x = rect.minX newOrigin.y = rect.minY clipView.animator().setBoundsOrigin(newOrigin) NSAnimationContext.endGrouping() - Mihriban Minaz

7

我对删除和重新定位列表的建议是:根据其他逻辑,在任意位置进行操作。例如,在删除/更新后,将其移动到顶部。 (这只是一个极简的示例,我在网络回调后使用此代码重新定位:在网络调用后,我更改了previousIndex)

结构体 ContentView: View {

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

    }
    
}

}


3
我想指出的是,这是唯一使用List的解决方案,正如问题标题所问。在iOS 15.1上进行了测试,并且有效。谢谢。 - Tom GODDARD
1
谢谢Tom!在iOS 16上进行测试。 - ingconti

3

现在,使用 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)
                        }
                    }
                }
            }
        }
    }
}

2

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

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

enter image description here

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

2
如 @lachezar-todorov 的回答中所提到的,Introspect 是一个很好的库,可以在 SwiftUI 中访问 UIKit 元素。但是请注意,用于访问 UIKit 元素的块会被调用多次。这可能会导致应用程序状态混乱。在我的情况下,CPU 使用率达到了 100%,应用程序无响应。我不得不使用一些预先条件来避免这种情况。
ScrollView() {
    ...
}.introspectScrollView { scrollView in
    if aPreCondition {
        //Your scrolling logic
    }
}

2

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)


1

在阅读了一些有关在层次结构之外使用按钮的评论后,我重新编写了一下代码,这一次使用了ScrollView,希望能够有所帮助:

import SwiftUI

结构体 ContentView: View { let colors: [Color] = [.red, .green, .blue]

@State private var idx : Int? = nil

var body: some View {
    VStack{
        
        Button("Jump to #12") {
            idx = 12
        }
        Button("Jump to #1") {
            idx = 1
        }
        
        ScrollViewReader { (proxy: ScrollViewProxy) in
            ScrollView(.horizontal) {
                HStack(spacing: 20) {
                    
                    ForEach(0..<100) { i in
                        Text("Example \(i)")
                            .font(.title)
                            .frame(width: 200, height: 200)
                            .background(colors[i % colors.count])
                            .id(i)
                    }
                } // HStack
                
            }// ScrollView
            .frame(height: 350)
            .onChange(of: idx) { newValue in
                withAnimation {
                    proxy.scrollTo(idx, anchor: .bottom)
                }
            }

        } // ScrollViewReader
    }
}

}


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