SwiftUI - 如何检测ScrollView何时停止滚动?

25

我需要找出我的ScrollView停止移动的确切时刻。 SwiftUI能做到吗?

这里是相当于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)
        }
    }
}
感谢!
6个回答

38

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

已在 Xcode 12.1 / iOS 14.1 上进行测试

更新:已验证可在 Xcode 13.3 / iOS 15.4 上使用

注意:您可以自行调整去抖动周期以适应您的需求。

demo

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: -$0.frame(in: .named("scroll")).origin.y)
            })
            .onPreferenceChange(ViewOffsetKey.self) { detector.send($0) }
        }.coordinateSpace(name: "scroll")
        .onReceive(publisher) {
            print("Stopped on: \($0)")
        }
    }
}

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


嘿,有点尴尬,这在我的情况下似乎不起作用。print从未被调用。此外,使用额外的手势来检测它何时开始,可以这样做:.simultaneousGesture( DragGesture().onChanged({ _ in print("Started Scrolling") })) - Oleg G.
ViewOffsetKey 的实现是什么? - Oleg G.
好的,基本上我实现了相同的代码。但是还是不起作用。Started scrolling 被调用了,但是 stop 没有被调用。 - Oleg G.
2
在Xcode 13和iOS 15中,我的打印语句从未触发。 - Subcreation
2
我在类似的代码中遇到了onReceive没有被调用的问题。(当然,移除debounce()调用并不是一个选项。)最终,我通过将“detector”和“publisher”变成@State变量而不是let属性来解决了这个问题。 - ecp
显示剩余5条评论

4

对我有效的另一种变体,基于@mdonati的答案

当我使用 LazyHStackLazyVStack 时,ZStack 解决了我的问题。

struct ScrollViewOffsetReader: View {
    private let onScrollingStarted: () -> Void
    private let onScrollingFinished: () -> Void
    
    private let detector: CurrentValueSubject<CGFloat, Never>
    private let publisher: AnyPublisher<CGFloat, Never>
    @State private var scrolling: Bool = false
    
    @State private var lastValue: CGFloat = 0
    
    init() {
        self.init(onScrollingStarted: {}, onScrollingFinished: {})
    }
    
    init(
        onScrollingStarted: @escaping () -> Void,
        onScrollingFinished: @escaping () -> Void
    ) {
        self.onScrollingStarted = onScrollingStarted
        self.onScrollingFinished = onScrollingFinished
        let detector = CurrentValueSubject<CGFloat, Never>(0)
        self.publisher = detector
            .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
            .eraseToAnyPublisher()
        self.detector = detector
    }
    
    var body: some View {
        GeometryReader { g in
            Rectangle()
                .frame(width: 0, height: 0)
                .onChange(of: g.frame(in: .global).origin.x) { offset in
                    if !scrolling {
                        scrolling = true
                        onScrollingStarted()
                    }
                    detector.send(offset)
                }
                .onReceive(publisher) {
                    scrolling = false
                    
                    guard lastValue != $0 else { return }
                    lastValue = $0
                    
                    onScrollingFinished()
                }
        }
    }
    
    func onScrollingStarted(_ closure: @escaping () -> Void) -> Self {
        .init(
            onScrollingStarted: closure,
            onScrollingFinished: onScrollingFinished
        )
    }
    
    func onScrollingFinished(_ closure: @escaping () -> Void) -> Self {
        .init(
            onScrollingStarted: onScrollingStarted,
            onScrollingFinished: closure
        )
    }
}

用法

ScrollView(.horizontal, showsIndicators: false) {
    ZStack {
        ScrollViewOffsetReader(onScrollingStarted: {
            isScrolling = true
        }, onScrollingFinished: {
            isScrolling = false
        })
        Text("More content...")
    }
}

谢谢你的回答,你能详细说明一下 lastValue 在这里的作用吗?它在哪里被设置了?为什么需要它?谢谢。 - TMin
1
它被定义为顶部的状态变量。它用于确保我们不会在每个事件中调用onScrollingFinished超过一次。 - Lachezar Todorov
这并不总是触发 onScrollingFinished,有时它会在新的 onScrollingStarted 之前出现。iOS 16.4 模拟器。 - StackUnderflow

3
对于我来说,当将Asperi的答案应用到一个更复杂的SwiftUI视图中时,发布者也没有触发。为了解决这个问题,我创建了一个带有一定防抖时间的已发布变量的StateObject。
据我所知,以下是发生的情况:scrollView的偏移量被写入一个发布者(currentOffset),然后使用防抖处理它。当值在防抖后传递(这意味着滚动已停止)时,它被分配给另一个发布者(offsetAtScrollEnd),该视图(ScrollViewTest)接收到该值。
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 = -$0.frame(in: .named("scroll")).minY
                    Color.clear.preference(key: ViewOffsetKey.self, value: offset)
                }
                
            }
            
        }.coordinateSpace(name: "scroll")
        .onPreferenceChange(ViewOffsetKey.self) {
            scrollViewHelper.currentOffset = $0
        }.onReceive(scrollViewHelper.$offsetAtScrollEnd) {
            print($0)
        }
        
    }
    
}

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

3

根据在这里发布的一些答案,我设计了这个组件,只读取x偏移量,因此无法用于垂直滚动,但可以轻松调整以适应您的需求。

import SwiftUI
import Combine

struct ScrollViewOffsetReader: View {
    private let onScrollingStarted: () -> Void
    private let onScrollingFinished: () -> Void
    
    private let detector: CurrentValueSubject<CGFloat, Never>
    private let publisher: AnyPublisher<CGFloat, Never>
    @State private var scrolling: Bool = false
    
    init() {
        self.init(onScrollingStarted: {}, onScrollingFinished: {})
    }
    
    private init(
        onScrollingStarted: @escaping () -> Void,
        onScrollingFinished: @escaping () -> Void
    ) {
        self.onScrollingStarted = onScrollingStarted
        self.onScrollingFinished = onScrollingFinished
        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 {
        GeometryReader { g in
            Rectangle()
                .frame(width: 0, height: 0)
                .onChange(of: g.frame(in: .global).origin.x) { offset in
                    if !scrolling {
                        scrolling = true
                        onScrollingStarted()
                    }
                    detector.send(offset)
                }
                .onReceive(publisher) { _ in
                    scrolling = false
                    onScrollingFinished()
                }
        }
    }
    
    func onScrollingStarted(_ closure: @escaping () -> Void) -> Self {
        .init(
            onScrollingStarted: closure,
            onScrollingFinished: onScrollingFinished
        )
    }
    
    func onScrollingFinished(_ closure: @escaping () -> Void) -> Self {
        .init(
            onScrollingStarted: onScrollingStarted,
            onScrollingFinished: closure
        )
    }
}

使用说明

ScrollView {
    ScrollViewOffsetReader()
        .onScrollingStarted { print("Scrolling started") }
        .onScrollingFinished { print("Scrolling finished") }

}

您的解决方案对我来说完美无缺。然而,它似乎无法维护其子元素的状态。我已经将Lottie动画添加到ScrollView中,但当我向上和向下滚动时,动画总是重新启动。关于我的问题的详细信息在这里:https://stackoverflow.com/questions/75338453/swiftui-scrollview-with-listeners-not-maintaining-content-state。 - Paras Gorasiya
这比@lachezar-todorov的解决方案更好。 - StackUnderflow

0
我使用以下代码实现了一个滚动视图。 但是"Stopped on: \($0)"从未被调用。我做错了什么吗?
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: -$0.frame(in: .named("scroll")).origin.y)
                })
                .onPreferenceChange(ViewOffsetKey.self) { detector.send($0) }
                .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: \($0)")
        }
    }

我不确定是否应该在这里发布一个新的Stack帖子,因为我正在尝试使这里的代码工作。

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

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


0
没有一个上面的答案对我有用。所以通过这篇中文博客文章的帮助,我更新了我的代码,我需要为我的消息添加分页(我在这里分享了我在应用中使用的实际代码)。 每一行都添加了注释,以便理解代码的作用。
@SwiftUI.State private var scrollPosition: CGPoint = .zero

    var chatView: some View { // Made the chat/messages view separate I am adding this chat view in the body view directly, it was not compiling so I break all views in separate variables.
    VStack {
        if viewModel.isFetchingData {
            LoadingView().frame(maxWidth: .infinity, maxHeight: 50)
        } // With the help of this condition I am showing a loader at the top of scroll view
        ScrollViewReader { value in
            ScrollView(showsIndicators: false) {
                VStack {
                    ForEach(0..<(viewModel.messageModelWithSection.count), id: \.self) { index in // Messages has section so used two loops
                        Section(header: headerFor(group: viewModel.messageModelWithSection[index])) {  // This will add the date at the top of section
                            ForEach(viewModel.messageModelWithSection[index].messages) { message in  // These are the messages that contains in the decided date
                                if "\(message.user_id ?? 0)" == "\(viewModel.currentUserId)" { // Sender Id is mine than show messages at my side
                                    MyMessageView(message: Binding.constant(message)) // This structure is define in my app
                                        .onTapGesture { // When user tap on message open the image/pdf in detail
                                            messageHasbeenClicked(message: message) // This method is defined in my app
                                        }
                                } else { // sender id does not match with me, than show the message on the other side
                                    OtherMessageView(message: Binding.constant(message)) // This structure is define in my app
                                        .onTapGesture {// When user tap on message open the image/pdf in detail
                                            messageHasbeenClicked(message: message) // This method is defined in my app
                                        }
                                }
                            }
                        }
                    }
                }
                .rotationEffect(.degrees(180))  // This is done to show the messages from the bottom
                // Detect that is scroll change by user
                .background(GeometryReader { geometry in
                    Color.clear
                        .preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin) // ScrollOffset Preference Key definition is available below
                })
                .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                    self.scrollPosition = value
                    if value.y < 10, value.y > 0, viewModel.isFetchingData == false { // Checking that its at top and not other pagination call is send to the server
                        print("Scroll is at top position")
                        viewModel.fetchPreviousMessage(forPagination: true) // It will fetch the pagination result and update the Published Object of messageModelWithSection.
                    }
                }
                .onAppear {
                    value.scrollTo(viewModel.messageModelWithSection.last?.messages.last)
                }
            }
            
        }
        .rotationEffect(.degrees(180)) // This is done to show the messages from the bottom
        .coordinateSpace(name: "scroll")
    }
}

滚动偏好键的定义
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero

static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
}
}

希望这能有所帮助,或者你可以在我提供的文章中获取更多详细信息。

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