获取SwiftUI ScrollView的当前滚动位置

49

通过新的ScrollViewReader,似乎可以通过编程方式设置滚动偏移量。

但我想知道是否也可以 获取 当前滚动位置?

看起来ScrollViewProxy只有scrollTo 方法,允许我们设置偏移量。

谢谢!


可能是这个问题的重复:https://dev59.com/QVIH5IYBdhLWcg3wgu8U - SwiftySteve
6个回答

80
以前是可以读到的。这里有一个基于视图偏好的解决方案。
struct DemoScrollViewOffsetView: View {
    @State private var offset = CGFloat.zero
    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100) { i in
                    Text("Item \(i)").padding()
                }
            }.background(GeometryReader {
                Color.clear.preference(key: ViewOffsetKey.self,
                    value: -$0.frame(in: .named("scroll")).origin.y)
            })
            .onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
        }.coordinateSpace(name: "scroll")
    }
}

struct ViewOffsetKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat.zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}

备份


5
可以设定吗? - Ian Warburton
8
如果我将DemoScrollViewOffsetView放入NavigationView{}中,当在模拟器上运行时,在调试窗口中会收到以下警告信息:Bound preference ViewOffsetKey tried to update multiple times per frame.这个警告/错误是什么原因造成的,如何解决? - Vee
@Asperi,如果没有使用偏移变量,为什么要声明它? - Duck
1
@Asperi,如果我们想要识别滚动条的底部端,则该如何获取? - Sapana Ranipa
2
警告一句话。如果你在同事寻求帮助后发现这段代码,他们抱怨ScrollView非常卡顿:小心使用。看看Adrien的回答给出的想法:频繁改变状态会导致大量的重新评估。对于ScrollView中的复杂视图,Xcode的SwiftUI Profiler显示了数千次的body评估 - 对于一个在ScrollView中包含3次的视图。我花了两天时间进行调试。 - Frederik Winkelsdorf
显示剩余4条评论

21

我发现了一种不使用 PreferenceKey 的版本。这个想法很简单-通过从 GeometryReader 返回 Color ,我们可以直接在背景修饰符中设置 scrollOffset

struct DemoScrollViewOffsetView: View {
    @State private var offset = CGFloat.zero
    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100) { i in
                    Text("Item \(i)").padding()
                }
            }.background(GeometryReader { proxy -> Color in
                DispatchQueue.main.async {
                    offset = -proxy.frame(in: .named("scroll")).origin.y
                }
                return Color.clear
            })
        }.coordinateSpace(name: "scroll")
    }
}

6
最受欢迎的答案(@Asperi的)有一个限制: 滚动偏移量可以在函数中使用 .onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") } 这对于基于该偏移触发事件非常方便。 但是,如果ScrollView的内容取决于此偏移量(例如,如果它必须显示它),那么我们需要此函数来更新@State。 然后问题就是每次更改此偏移量时,都会更新@State并重新评估主体。这会导致显示缓慢。
相反,我们可以直接将ScrollView的内容包装在GeometryReader中,以使该内容可以直接依赖其位置(而不使用State甚至PreferenceKey)。
GeometryReader { geometry in
   content(geometry.frame(in: .named(spaceName)).origin)
}

这里的content是指(CGPoint) -> some View

我们可以利用这个特性来观察偏移量停止更新的时机,并且重新实现UIScrollView的didEndDragging行为。

GeometryReader { geometry in
   content(geometry.frame(in: .named(spaceName)).origin)
      .onChange(of: geometry.frame(in: .named(spaceName)).origin, 
                perform: offsetObserver.send)
      .onReceive(offsetObserver.debounce(for: 0.2, 
                 scheduler: DispatchQueue.main), 
                 perform: didEndScrolling)
}

其中 offsetObserver = PassthroughSubject<CGPoint, Never>()

最终,这将产生:

struct _ScrollViewWithOffset<Content: View>: View {
    
    private let axis: Axis.Set
    private let content: (CGPoint) -> Content
    private let didEndScrolling: (CGPoint) -> Void
    private let offsetObserver = PassthroughSubject<CGPoint, Never>()
    private let spaceName = "scrollView"
    
    init(axis: Axis.Set = .vertical,
         content: @escaping (CGPoint) -> Content,
         didEndScrolling: @escaping (CGPoint) -> Void = { _ in }) {
        self.axis = axis
        self.content = content
        self.didEndScrolling = didEndScrolling
    }
    
    var body: some View {
        ScrollView(axis) {
            GeometryReader { geometry in
                content(geometry.frame(in: .named(spaceName)).origin)
                    .onChange(of: geometry.frame(in: .named(spaceName)).origin, perform: offsetObserver.send)
                    .onReceive(offsetObserver.debounce(for: 0.2, scheduler: DispatchQueue.main), perform: didEndScrolling)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
        }
        .coordinateSpace(name: spaceName)
    }
}

注意:我看到的唯一问题是GeometryReader占用了所有可用的宽度和高度。这并不总是理想的(特别是对于水平的ScrollView)。因此,必须确定内容的大小以在ScrollView上反映出来。

struct ScrollViewWithOffset<Content: View>: View {
    @State private var height: CGFloat?
    @State private var width: CGFloat?
    let axis: Axis.Set
    let content: (CGPoint) -> Content
    let didEndScrolling: (CGPoint) -> Void
    
    var body: some View {
        _ScrollViewWithOffset(axis: axis) { offset in
            content(offset)
                .fixedSize()
                .overlay(GeometryReader { geo in
                    Color.clear
                        .onAppear {
                            height = geo.size.height
                            width = geo.size.width
                        }
                })
        } didEndScrolling: {
            didEndScrolling($0)
        }
        .frame(width: axis == .vertical ? width : nil,
               height: axis == .horizontal ? height : nil)
    }
}

这在大多数情况下都可以使用(除非内容大小发生变化,这不是理想的情况)。最后,您可以像这样使用它:

struct ScrollViewWithOffsetForPreviews: View {
    @State private var cpt = 0
    let axis: Axis.Set
    var body: some View {
        NavigationView {
            ScrollViewWithOffset(axis: axis) { offset in
                VStack {
                    Color.pink
                        .frame(width: 100, height: 100)
                    Text(offset.x.description)
                    Text(offset.y.description)
                    Text(cpt.description)
                }
            } didEndScrolling: { _ in
                cpt += 1
            }
            .background(Color.mint)
            .navigationTitle(axis == .vertical ? "Vertical" : "Horizontal")
        }
    }
}

it doesn't scroll - undefined
我刚刚用Xcode(15.0.1)测试了这段代码,因为它是用早期版本开发的,结果是可以运行的。@malsag,你能提供更多的背景信息吗? - undefined
我不知道,我真的用Xcode 15.0运行了你的代码,但当我尝试滚动时它并没有滚动,我甚至在VStack中添加了内容。 - undefined

4

我有类似的需求,但是需要使用 List 而不是 ScrollView,并且想知道列表中的项目是否可见(List 预加载尚未可见的视图,因此无法使用 onAppear()/onDisappear())。

经过一番“美化”后,我最终采用了以下用法:

struct ContentView: View {
    var body: some View {
        GeometryReader { geometry in
            List(0..<100) { i in
                Text("Item \(i)")
                    .onItemFrameChanged(listGeometry: geometry) { (frame: CGRect?) in
                        print("rect of item \(i): \(String(describing: frame)))")
                    }
            }
            .trackListFrame()
        }
    }
}

这个技术是由这个 Swift 包支持的:https://github.com/Ceylo/ListItemTracking


有没有办法以某种方式读取列表的几何形状? - undefined

2

我有点晚来参加派对,但今天我遇到了这个问题,所以我想回答一下。这个要点包含了能够满足你需求的代码。

https://gist.github.com/rsalesas/313e6aefc098f2b3357ae485da507fc4

        ScrollView {
            ScrollViewReader { proxy in
                content()
            }
            .onScrolled { point in
                print("Point: \(point)")
            }
        }
        .trackScrolling()

它提供了扩展功能,以在滚动ScrollView时调用。首先在ScrollView上使用.trackScrolling,然后在其中放置一个ScrollViewReader。在ScrollViewReader上使用.onScrolled扩展来接收事件(一个参数,一个UnitPoint)。

您确实需要打开滚动跟踪,我找不到其他方法来做到这一点。为什么不支持这个...


1

通过查看以前的示例,您可以在不使用PreferenceKeys的情况下达到相同的结果。

import SwiftUI

struct LazyVScrollView<Content: View>: View {
    @State private var rect: CGRect = .zero

    private let id = UUID()
    private let content: (CGRect) -> Content

    init(content: @escaping (CGRect) -> Content) {
        self.content = content
    }

    var body: some View {
        ScrollView {
            content(rect)
                .background {
                    GeometryReader {
                        Color.clear
                            .onChange(of: $0.frame(in: .named(id))) { newValue in
                                rect = newValue
                            }
                    }
                }
        }
        .coordinateSpace(name: id)
    }
}

在2023年的IOS 17中使用.scrollPosition(id: $mainID) - undefined

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