Search code examples
cocoa-touchswiftuiios13

How do I restrict a dragging gesture to one direction only in SwiftUI?


I've embarrassingly spent the past 2 weeks trying to solve this.

What I'm trying to do is:

  1. Snap my Slide Over View to the bottom of the screen
  2. Disable dragging up and only allow the card to be dragged down to close

What I've tried:

I've tried messing with the size of the card by setting its height to the height of the screen. You can see this line commented out. After doing this, I've messed around with the offset of the card and set it so that it looks as if the card is actually less than half its size, around 300 in height. The problem with this is when I slide up slowly, I can see the empty space that is hidden out of the screen. This isn't the effect I want.

The next thing I have tried to do is change the height of the card to a desired height. Then adjust the offset so the card is where I want it to be. However, I feel manually adjusting it won't be reliable on different screens. So I'm trying to work out the right math needed to always have it be placed at the very bottom of the screen when it pops up.

Finally, I want to just make it so users can only drag down and not up.

I would really appreciate some help here. I've spent a lot of time message around and reading, learning new things, but I can't solve my specific problem.

Here is my Slide Over Card

    import SwiftUI

struct SigninView<Content: View> : View {
    @GestureState private var dragState = DragState.inactive
    @State var position = CardPosition.top
    
    var content: () -> Content
    var body: some View {
        let drag = DragGesture()
            .updating($dragState) { drag, state, transaction in
                state = .dragging(translation: drag.translation)
            }
            .onEnded(onDragEnded)
        
        return Group {
           // Handle()
            self.content()
        }
        .frame(height: 333) //UIScreen.main.bounds.height)
        .background(Color.purple)
        .cornerRadius(10.0)
        .shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
        .offset(y: self.position.rawValue + self.dragState.translation.height)
        .animation(self.dragState.isDragging ? nil : .interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
        .gesture(drag)
    }
    
    private func onDragEnded(drag: DragGesture.Value) {
        let verticalDirection = drag.predictedEndLocation.y - drag.location.y
        let cardTopEdgeLocation = self.position.rawValue + drag.translation.height
        let positionAbove: CardPosition
        let positionBelow: CardPosition
        let closestPosition: CardPosition
         
        if cardTopEdgeLocation <= CardPosition.middle.rawValue {
            positionAbove = .top
            positionBelow = .middle
        } else {
            positionAbove = .middle
            positionBelow = .bottom
        }
       
        if (cardTopEdgeLocation - positionAbove.rawValue) < (positionBelow.rawValue - cardTopEdgeLocation) {
            closestPosition = positionAbove
        } else {
            closestPosition = positionBelow
        }
       
        if verticalDirection > 0 {
            self.position = positionBelow
        } else if verticalDirection < 0 {
            self.position = positionAbove
        } else {
            self.position = closestPosition
        }
         
    }
}

enum CardPosition: CGFloat {
    case top = 100
    case middle = 790
    case bottom = 850
}

enum DragState {
    case inactive
    case dragging(translation: CGSize)
    
    var translation: CGSize {
        switch self {
        case .inactive:
            return .zero
        case .dragging(let translation):
            return translation
        }
    }
    
    var isDragging: Bool {
        switch self {
        case .inactive:
            return false
        case .dragging:
            return true
        }
    }
}

Here is my ContentView page, where I test it:

import SwiftUI

struct ContentView: View {
    @State var show:Bool = false

     var body: some View {
        SigninView {
            VStack {
                
                Text("TESTING")
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color.blue)
            }
        }
              
        
    }
  

}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Solution

  • First, you should probably not have your SigninView be your content view. Instead, consider presenting your sign in view as an overlay instead.

    var body: some View {
        ZStack {
            Text("Content here!")
        }
        .overlay(
            SigninView()
                .offset(...),
            alignment: .bottom
        )
    }
    

    This will automatically place your view at the bottom of the screen at the height of your SigninView, there should be little to no math involved here. The offset, you will define with your gesture and any space you want to exist between the bottom and your overlay.

    Next, to only allow down gestures, can't you just clamp your translation?

        var translation: CGSize {
            switch self {
            case .inactive:
                return .zero
            case .dragging(let translation):
                return max(0, translation) // clamp this to the actual translation or 0 so it can't go negative
            }
        }