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

问题描述 投票:0回答:7

我需要找出我的

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

谢谢!

ios swift swiftui scrollview
7个回答
38
投票

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

使用 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: -$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()
    }
}


4
投票

基于@mdonati 的回答,还有一个对我有用的变体

ZStack解决了我使用LazyHStackLazyVStack

时的问题
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...")
    }
}

3
投票

对我来说,在将 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 = -$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") }

}

0
投票

我用下面的代码实现了一个scollview。 并且

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

我不确定我是否应该在此处发布新的堆栈帖子,因为我正在尝试使此处的代码起作用。

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

编辑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")
    }
}

ScrollOfSerPrefrenceKey 的定义

struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero

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

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


0
投票

添加此

PreferenceKey
来跟踪
View
的垂直滚动偏移:

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

添加此

ViewModifier
以允许跟踪视图的垂直偏移并在滚动停止时调用
scrollPostionUpdate
闭包:

extension View {
    
    func onScrollEnded(in coordinateSpace: CoordinateSpace, onScrollEnded: @escaping (CGFloat) -> Void) -> some View {
        modifier(OnVerticalScrollEnded(coordinateSpace: coordinateSpace, scrollPostionUpdate: onScrollEnded))
    }
}

final class OnVerticalScrollEndedOffsetTracker: ObservableObject {
    let scrollViewVerticalOffset = CurrentValueSubject<CGFloat, Never>(0)
    
    func updateOffset(_ offset: CGFloat) {
        scrollViewVerticalOffset.send(offset)
    }
}

struct OnVerticalScrollEnded: ViewModifier {
    let coordinateSpace: CoordinateSpace
    let scrollPostionUpdate: (CGFloat) -> Void
    @StateObject private var offsetTracker = OnVerticalScrollEndedOffsetTracker()
    
    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader(content: { geometry in
                    Color.clear.preference(key: VerticalScrollOffsetKey.self, value: abs(geometry.frame(in: coordinateSpace).origin.y))
                })
            )
            .onPreferenceChange(VerticalScrollOffsetKey.self, perform: offsetTracker.updateOffset(_:))
            .onReceive(offsetTracker.scrollViewVerticalOffset.debounce(for: 0.1, scheduler: DispatchQueue.main).dropFirst(), perform: scrollPostionUpdate)
    }
}

用法:将

.onScrollEnded
修饰符添加到ScrollView
content
,并给
ScrollView
一个坐标空间名称:

struct ScrollingEndedView: View {
    private let coordinateSpaceName = "scrollingEndedView_coordinateSpace"
    
    var body: some View {
        ScrollView {
            VStack {
                ForEach(0...100, id: \.self) { rowNum in
                    Text("Row \(rowNum)")
                        .frame(maxWidth: .infinity)
                        .padding(.vertical)
                        .background(Color.orange)
                }
            }
            .onScrollEnded(in: .named(coordinateSpaceName), onScrollEnded: updateScrollPosition(_:))
        }
        .coordinateSpace(name: coordinateSpaceName) // add the coordinateSpaceName to the ScrollView itself
    }
    
    private func updateScrollPosition(_ position: CGFloat) {
        print("scrolling ended @: \(position)")
    }
}
© www.soinside.com 2019 - 2024. All rights reserved.