我需要找出我的
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)
}
}
}
谢谢!
这里是一个可能方法的演示 - 使用带有去抖动功能的已更改滚动内容坐标的发布者,因此仅在坐标停止更改后才报告事件。
使用 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()
}
}
ZStack解决了我使用LazyHStack或LazyVStack
时的问题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...")
}
}
对我来说,在将 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()
}
}
根据此处发布的一些答案,我想出了这个仅读取 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") }
}
我用下面的代码实现了一个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()
似乎可以修复它,但过度调用它。
上面的答案都不适合我。因此,在这篇中等文章的帮助下我更新了我的代码,我需要对我的消息进行分页(我在这里分享我在应用程序中使用的实际代码)。 为每一行添加注释以了解代码在做什么。
@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) {
}
}
我希望这会对您有所帮助,或者您可以在我提供的文章中获得更多详细信息。
添加此
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)")
}
}