[英]SwiftUI - Detect when ScrollView has finished scrolling?
I need to find out the exact moment when my ScrollView
stops moving.我需要找出我的ScrollView
停止移动的确切时刻。 Is that possible with SwiftUI? SwiftUI 有可能吗?
Here would be an equivalent for UIScrollView
. 这是UIScrollView
的等价物。
I have no idea after thinking a lot about it...想了很多之后还是不知道...
A sample project to test things out:一个用于测试的示例项目:
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)
}
}
}
Thanks!谢谢!
Here is a demo of possible approach - use publisher with changed scrolled content coordinates with debounce, so event reported only after coordinates stopped changing.这是一个可能方法的演示 - 使用更改滚动内容坐标和去抖动的发布者,因此仅在坐标停止更改后才报告事件。
Tested with Xcode 12.1 / iOS 14.1使用 Xcode 12.1 / iOS 14.1 测试
Note: you can play with debounce period to tune it for your needs.注意:您可以使用 debounce period 来调整它以满足您的需要。
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)")
}
}
}
For me the publisher also didn't fire when implementing Asperi's answer into a more complicated SwiftUI view.对我来说,发布者在将 Asperi 的答案实施到更复杂的 SwiftUI 视图时也没有开火。 To fix it I created a StateObject with a published variable set with a certain debounce time.为了解决这个问题,我创建了一个带有已发布变量的 StateObject,并设置了一定的去抖时间。
To my best of knowledge, this is what happens: the offset of the scrollView is written to a publisher (currentOffset) which then handles it with a debounce.据我所知,这就是发生的情况:scrollView 的偏移量被写入发布者(currentOffset),然后由去抖动处理它。 When the value gets passed along after the debounce (which means scrolling has stopped) it's assigned to another publisher (offsetAtScrollEnd), which the view (ScrollViewTest) receives.当值在去抖(这意味着滚动已停止)之后被传递时,它被分配给另一个发布者(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()
}
}
I implemented a scollview with the following code.我用下面的代码实现了一个 scollview。 And the "Stopped on: \($0)"
is never called.并且永远不会调用"Stopped on: \($0)"
。 Did i do something wrong?我做错什么了吗?
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)")
}
}
I am not sure if I should do a new Stack post or not here, since I am trying to make the code here works.我不确定是否应该在此处发布新的 Stack 帖子,因为我正在尝试使此处的代码正常工作。
Edit: Actually it works if I paused the audio player playing at the same time.编辑:实际上,如果我同时暂停播放音频播放器,它会起作用。 By pausing it, it allows the publisher to be called.通过暂停它,它允许调用发布者。 Awkward.尴尬的。
Edit 2: removing .dropFirst()
seems to fix it but over calling it.编辑 2:删除.dropFirst()
似乎可以修复它,但会调用它。
Based on some of the answers posted here, I came up with this component that only reads x-offset so it won't work for vertical scroll, but can easily be tweaked to adjust to your needs.根据此处发布的一些答案,我想出了这个仅读取 x-offset 的组件,因此它不适用于垂直滚动,但可以轻松调整以适应您的需求。
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
)
}
}
Usage用法
ScrollView {
ScrollViewOffsetReader()
.onScrollingStarted { print("Scrolling started") }
.onScrollingFinished { print("Scrolling finished") }
}
The ZStack solved my problem when I was using LazyHStack or LazyVStack当我使用LazyHStack或LazyVStack时, 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...")
}
}
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.