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

时间:2020-03-25 19:14:34

标签: ios swift swiftui

在当前发布的Xcode 11.4 / iOS 13.4的当前工具/系统中,List中不支持SwiftUI本地支持“ scroll-to”功能。因此,即使他们(苹果公司)将在下一个 major 版本中提供它,我也需要向后支持iOS 13.x。

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

  • 滚动列表以结束
  • 将列表滚动到顶部
  • 和其他人

(我不喜欢将SO UITableView的完整基础结构包装到UIViewRepresentable/UIViewControllerRepresentable中)。

8 个答案:

答案 0 :(得分:31)

SWIFTUI 2.0

这里是Xcode 12 / iOS 14(SwiftUI 2.0)中的替代解决方案,当滚动控件位于滚动区域之外时,可以在相同情况下使用该解决方案(因为SwiftUI2 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()
            
            ScrollView {
                ScrollViewReader { sp in
                    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的简单代理(需要执行一次),然后将所需的“ scroll-to”操作重定向到该存储的scrollview

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 :(得分:5)

非常感谢Asperi。当在视图外部添加新条目时,我需要向上滚动列表。重新设计以适合macOS。

我将state / proxy变量带到一个环境对象中,并在视图外部使用它来强制滚动。我发现我不得不更新两次,第二次以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
    }
}

答案 2 :(得分:5)

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

滚动到任何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


预览

preview

答案 3 :(得分:2)

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

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

答案 4 :(得分:1)

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

答案 5 :(得分:0)

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)

答案 6 :(得分:0)

如@ lachezar-todorov的回答Introspect中所述,它是一个不错的库,可以访问UIKit中的SwiftUI元素。但是请注意,您多次访问用于访问UIKit元素的块。这确实会弄乱您的应用程序状态。在我的cas中,CPU使用率达到100%,应用程序变得无响应。我必须使用一些先决条件来避免它。

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

答案 7 :(得分:0)

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

    }
    
}

}