swiftuidraggesture

How to stop SwiftUI DragGesture from animating subviews


I'm building a custom modal and when I drag the modal, any subviews that have animation's attached, they animate while I'm dragging. How do I stop this from happening?

I thought about passing down an @EnvironmentObject with a isDragging flag, but it's not very scalable (and doesn't work well with custom ButtonStyles)

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
            .showModal(isShowing: .constant(true))
    }
}

extension View {
    func showModal(isShowing: Binding<Bool>) -> some View {
        ViewOverlay(isShowing: isShowing, presenting: { self })
    }
}

struct ViewOverlay<Presenting>: View where Presenting: View {
    @Binding var isShowing: Bool
    
    let presenting: () -> Presenting
    
    @State var bottomState: CGFloat = 0
    
    var body: some View {
        ZStack(alignment: .center) {
            presenting().blur(radius: isShowing ? 1 : 0)
            VStack {
                if isShowing {
                    Container()
                        .background(Color.red)
                        .offset(y: bottomState)
                        .gesture(
                            DragGesture()
                                .onChanged { value in
                                    bottomState = value.translation.height
                                }
                                .onEnded { _ in
                                    if bottomState > 50 {
                                        withAnimation {
                                            isShowing = false
                                        }
                                    }
                                    bottomState = 0
                                })
                        .transition(.move(edge: .bottom))
                }
            }
        }
    }
}

struct Container: View {
    var body: some View {
// I want this to not animate when dragging the modal
        Text("CONTAINER")
            .frame(maxWidth: .infinity, maxHeight: 200)
            .animation(.spring())
    }
}


ui

UPDATE:

extension View {
    func animationsDisabled(_ disabled: Bool) -> some View {
        transaction { (tx: inout Transaction) in
            tx.animation = tx.animation
            tx.disablesAnimations = disabled
        }
    }
}


Container()
   .animationsDisabled(isDragging || bottomState > 0)

In real life the Container contains a button with an animation on its pressed state

struct MyButtonStyle: ButtonStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.9 : 1)
            .animation(.spring())
    }
}

Added the animationsDisabled function to the child view which does in fact stop the children moving during the drag.

What it doesn't do is stop the animation when the being initially slide in or dismissed.

Is there a way to know when a view is essentially not moving / transitioning?


Solution

  • Theoretically SwiftUI should not translate animation in this case, however I'm not sure if this is a bug - I would not use animation in Container in that generic way. The more I use animations the more tend to join them directly to specific values.

    Anyway... here is possible workaround - break animation visibility by injecting different hosting controller in a middle.

    Tested with Xcode 12 / iOS 14

    demo

    struct ViewOverlay<Presenting>: View where Presenting: View {
        @Binding var isShowing: Bool
        
        let presenting: () -> Presenting
        
        @State var bottomState: CGFloat = 0
        
        var body: some View {
            ZStack(alignment: .center) {
                presenting().blur(radius: isShowing ? 1 : 0)
                VStack {
                        Color.clear
                    if isShowing {
                            HelperView {
                        Container()
                            .background(Color.red)
                            }
                            .offset(y: bottomState)
                            .gesture(
                                 DragGesture()
                                      .onChanged { value in
                                            bottomState = value.translation.height
                                      }
                                      .onEnded { _ in
                                            if bottomState > 50 {
                                                 withAnimation {
                                                      isShowing = false
                                                 }
                                            }
                                            bottomState = 0
                                      })
                            .transition(.move(edge: .bottom))
                    }
                        Color.clear
                }
            }
        }
    }
    
    struct HelperView<Content: View>: UIViewRepresentable {
        let content: () -> Content
        func makeUIView(context: Context) -> UIView {
            let controller = UIHostingController(rootView: content())
            return controller.view
        }
        
        func updateUIView(_ uiView: UIView, context: Context) {
        }
    }