How can I detect when a ScrollView is being dragged?
Within my ScrollView I have an @Binding scrollPositionOffset
variable that I watch with .onChange(of:)
and then programmatically scroll to that position using ScrollViewReader.scrollTo()
. This works great, but I need to also update scrollPositionOffset
when I scroll the ScrollView directly. I'm struggling to do that as this would trigger the .onChange(of:)
closure and get into a loop.
My solution is to conditionally call ScrollViewReader.scrollTo()
only when I have a localScrolling
variable set to false. I've tried to set this using DragGesture.onChanged
and .onEnded
, but this isn't the same as the drag gesture that causes the scroll, so .onEnded
never fires.
What I think I need is a @GestureRecognizer for ScrollView similar to UIScrollView's isDragging
or isTracking
(I'm aware I could use UIScrollView, but I don't know how, and that seems like it might be more work!! I'd accept an answer that shows me how to drop that into a SwiftUIView too)
Context (in case anyone has a cleaner solution to my actual scenario):
I have a ScrollView that I'm programmatically scrolling to create an effect like the Minimap view within Xcode (i.e. I have a zoomed-out view adjacent to the ScrollView, and dragging the minimap causes the ScrollView to scroll).
This works great when I use the minimap, but I'm struggling to get the reverse to happen: moving the position of the ScrollView to update the minimap view.
Code
@Binding var scrollPositionOffset: CGFloat
let zoomMultiplier:CGFloat = 1.5
var body: some View{
ScrollViewReader { scrollViewProxy in
GeometryReader{ geometry in
ScrollView {
ZStack(alignment:.top){
//The content of my ScrollView
MagnifierView()
.frame(height: geometry.size.height * zoomMultiplier)
//I'm using this as my offset reference
Rectangle()
.frame(height:10)
.alignmentGuide(.top) { _ in
geometry.size.height * zoomMultiplier * -scrollPositionOffset
}
.id("scrollOffset")
}
}
.onAppear(){
scrollViewProxy.scrollTo("scrollOffset", anchor: .top)
}
.onChange(of: scrollPositionOffset, perform: { _ in
//Only call .scrollTo() if the view isn't already being scrolled by the user
if !localScrolling {
scrollViewProxy.scrollTo("scrollOffset", anchor: .top)
}
})
.gesture(
DragGesture()
.onChanged{gesture in
localScrolling = true
let offset = gesture.location.y/(zoomMultiplier * geometry.size.height)
scrollPositionOffset = offset
}
.onEnded({gesture in
//Doesn't ever fire when scrolling
localScrolling = false
})
)
}
}
}
Using ScrollViewStyle
:
struct CustomScrollView: ScrollViewStyle {
@Binding var isDragging: Bool
func make(body: AnyView, context: Context) -> some View {
body
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
class Coordinator: ScrollViewCoordinator {
var parent: CustomScrollView
init(parent: CustomScrollView) {
self.parent = parent
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
parent.isDragging = false
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
parent.isDragging = true
}
}
}
struct TestView: View {
@State var isDragging = false
var body: some View {
ScrollView {
}.scrollViewStyle(CustomScrollView(isDragging: $isDragging))
}
}
Version 2.0.0 Update:
All you have to do now is:
ScrollView
@ScrollState var state
scrollViewStyle
modifier
.scrollViewStyle(.defaultStyle($state))
TestView:
struct TestView: View {
@ScrollState var state
var body: some View {
ScrollView {
...
}.scrollViewStyle(.defaultStyle($state))
.onChange(of: state.isDragging) { newValue in
print(newValue)
}
}
}