Search code examples
iosswiftswiftuigesture-recognitionswiftui-scrollview

SwiftUI ScrollView gesture recogniser


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


Solution

  • 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:

    1. Add a property to track the state of the ScrollView
      @ScrollState var state
      
    2. Use the 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)
            }
        }
    }