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()
}
}
我用下面的代码实现了一个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()
}
}
我需要找出 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()
}
}
我用下面的代码实现了一个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()
}
}