SwiftUI - 检测 ScrollView 何时完成滚动?

SwiftUI - Detect when ScrollView has finished scrolling?

我需要找出 ScrollView 停止移动的确切时间。 使用 SwiftUI 可以吗?

Here 相当于 UIScrollView.

想了想还是没头绪...

用于测试的示例项目:

struct ContentView: View {
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                ForEach(0...100, id: \.self) { i in
                    Rectangle()
                        .frame(width: 200, height: 100)
                        .foregroundColor(.green)
                        .overlay(Text("\(i)"))
                }
            }
            .frame(maxWidth: .infinity)
        }
    }
}

谢谢!

这是一个可能方法的演示 - 使用带有去抖动的已更改滚动内容坐标的发布者,因此只有在坐标停止更改后才会报告事件。

测试 Xcode 12.1 / iOS 14.1

更新:经验证可用于 Xcode 13.3 / iOS 15.4

注意:您可以根据自己的需要调整去抖周期。

import Combine

struct ContentView: View {
    let detector: CurrentValueSubject<CGFloat, Never>
    let publisher: AnyPublisher<CGFloat, Never>

    init() {
        let detector = CurrentValueSubject<CGFloat, Never>(0)
        self.publisher = detector
            .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
            .dropFirst()
            .eraseToAnyPublisher()
        self.detector = detector
    }
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                ForEach(0...100, id: \.self) { i in
                    Rectangle()
                        .frame(width: 200, height: 100)
                        .foregroundColor(.green)
                        .overlay(Text("\(i)"))
                }
            }
            .frame(maxWidth: .infinity)
            .background(GeometryReader {
                Color.clear.preference(key: ViewOffsetKey.self,
                    value: -[=10=].frame(in: .named("scroll")).origin.y)
            })
            .onPreferenceChange(ViewOffsetKey.self) { detector.send([=10=]) }
        }.coordinateSpace(name: "scroll")
        .onReceive(publisher) {
            print("Stopped on: \([=10=])")
        }
    }
}

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

backup

我用下面的代码实现了一个scollview。 "Stopped on: \([=11=])" 永远不会被调用。我是不是做错了什么?

func scrollableView(with geometryProxy: GeometryProxy) -> some View {
        let middleScreenPosition = geometryProxy.size.height / 2

        return ScrollView(content: {
            ScrollViewReader(content: { scrollViewProxy in
                VStack(alignment: .leading, spacing: 20, content: {
                    Spacer()
                        .frame(height: geometryProxy.size.height * 0.4)
                    ForEach(viewModel.fragments, id: \.id) { fragment in
                        Text(fragment.content) // Outside of geometry ready to set the natural size
                            .opacity(0)
                            .overlay(
                                GeometryReader { textGeometryReader in
                                    let midY = textGeometryReader.frame(in: .global).midY

                                    Text(fragment.content) // Actual text
                                        .font(.headline)
                                        .foregroundColor( // Text color
                                            midY > (middleScreenPosition - textGeometryReader.size.height / 2) &&
                                                midY < (middleScreenPosition + textGeometryReader.size.height / 2) ? .white :
                                                midY < (middleScreenPosition - textGeometryReader.size.height / 2) ? .gray :
                                                .gray
                                        )
                                        .colorMultiply( // Animates better than .foregroundColor animation
                                            midY > (middleScreenPosition - textGeometryReader.size.height / 2) &&
                                                midY < (middleScreenPosition + textGeometryReader.size.height / 2) ? .white :
                                                midY < (middleScreenPosition - textGeometryReader.size.height / 2) ? .gray :
                                                .clear
                                        )
                                        .animation(.easeInOut)
                                }
                            )
                            .scrollId(fragment.id)
                    }
                    Spacer()
                        .frame(height: geometryProxy.size.height * 0.4)
                })
                .frame(maxWidth: .infinity)
                .background(GeometryReader {
                    Color.clear.preference(key: ViewOffsetKey.self,
                                           value: -[=10=].frame(in: .named("scroll")).origin.y)
                })
                .onPreferenceChange(ViewOffsetKey.self) { detector.send([=10=]) }
                .padding()
                .onReceive(self.fragment.$currentFragment, perform: { currentFragment in
                    guard let id = currentFragment?.id else {
                        return
                    }
                    scrollViewProxy.scrollTo(id, alignment: .center)
                })
            })
        })
        .simultaneousGesture(
            DragGesture().onChanged({ _ in
                print("Started Scrolling")
            }))
        .coordinateSpace(name: "scroll")
        .onReceive(publisher) {
            print("Stopped on: \([=10=])")
        }
    }

我不确定是否应该在这里做一个新的 Stack post,因为我正在努力使这里的代码有效。

编辑:实际上,如果我同时暂停播放的音频播放器,它会起作用。通过暂停它,它允许调用发布者。 尴尬。

编辑 2:删除 .dropFirst() 似乎修复了它,但过度调用了它。

对我来说,在将 Asperi 的答案实施到更复杂的 SwiftUI 视图中时,发布者也没有开火。为了解决这个问题,我创建了一个带有已发布变量集的 StateObject,该变量集具有一定的去抖动时间。

据我所知,情况是这样的:scrollView 的偏移量被写入发布者 (currentOffset),然后由发布者对其进行去抖动处理。当该值在去抖动(这意味着滚动已停止)后传递时,它被分配给视图(ScrollViewTest)接收的另一个发布者(offsetAtScrollEnd)。

import SwiftUI
import Combine

struct ScrollViewTest: View {
    
    @StateObject var scrollViewHelper = ScrollViewHelper()
    
    var body: some View {
        
        ScrollView {
            ZStack {
                
                VStack(spacing: 20) {
                    ForEach(0...100, id: \.self) { i in
                        Rectangle()
                            .frame(width: 200, height: 100)
                            .foregroundColor(.green)
                            .overlay(Text("\(i)"))
                    }
                }
                .frame(maxWidth: .infinity)
                
                GeometryReader {
                    let offset = -[=10=].frame(in: .named("scroll")).minY
                    Color.clear.preference(key: ViewOffsetKey.self, value: offset)
                }
                
            }
            
        }.coordinateSpace(name: "scroll")
        .onPreferenceChange(ViewOffsetKey.self) {
            scrollViewHelper.currentOffset = [=10=]
        }.onReceive(scrollViewHelper.$offsetAtScrollEnd) {
            print([=10=])
        }
        
    }
    
}

class ScrollViewHelper: ObservableObject {
    
    @Published var currentOffset: CGFloat = 0
    @Published var offsetAtScrollEnd: CGFloat = 0
    
    private var cancellable: AnyCancellable?
    
    init() {
        cancellable = AnyCancellable($currentOffset
                                        .debounce(for: 0.2, scheduler: DispatchQueue.main)
                                        .dropFirst()
                                        .assign(to: \.offsetAtScrollEnd, on: self))
    }
    
}

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