Search code examples
swiftswiftuigesture

ScrollView inside custom gesture-based slide-up card causing issues in SwiftUI


I've made a minimum reproducible example that I'll paste below. Just plug it into Xcode and you'll see what the deal is. Essentially, I have a custom Slide-Up card. When it's enum position is .top, and I swipe the ScrollView in Xcode simulator, it causes the position of the card to slightly shift. Is there any way to lock the position of the card? Or at the very least make it so having the ScrollView inside of the slide-up card less problematic with swiping gestures?

Content View:

struct ContentView : View {
    var body: some View {
        ZStack(alignment: Alignment.top) {
            Text("test")
                .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
                .background(Color.red)
            SlideOverCard {
                VStack {
                    ScrollView {
                    VStack {
                        ForEach(1..<100) { _ in
                            Text("test")
                        }
                    }.frame(width: UIScreen.main.bounds.width)
                    }
                    Text("TESTER LINE OF TEXT")
                    Spacer()
                    
                    
                }.frame(width: UIScreen.main.bounds.width)
            }
        }
        .edgesIgnoringSafeArea(.vertical)
    }
}

Slide-Up Card:

struct SlideOverCard<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(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
        .background(Color.white)
        .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 = 500
    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
        }
    }
}

Handle:

struct Handle : View {
    private let handleThickness = CGFloat(5.0)
    var body: some View {
        RoundedRectangle(cornerRadius: handleThickness / 2.0)
            .frame(width: 40, height: handleThickness)
            .foregroundColor(Color.secondary)
            .padding(5)
    }
}

Solution

  • Ok, I managed to reproduce what you meant...

    Tested with Xcode 12 / iOS 14

    Update: - here is found solution

    ScrollView {
        VStack {
            ForEach(1..<100) { _ in
                Text("test")
            }
        }.frame(width: UIScreen.main.bounds.width)
    }
    .background(Color.white)             // << make opaque background
    .highPriorityGesture(DragGesture())  // << block below DragGesture
    

    Also I would consider variant to move drag gesture on "handle" (as you already have it)

        return VStack {  // << make it instead of Group
            Handle()
                .gesture(drag)   // << here !!
            self.content()
        }
        .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
        .background(Color.white)
        .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))