How to stop SwiftUI DragGesture from animating subviews

3.6k views Asked by At

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?

3

There are 3 answers

0
Asperi On BEST ANSWER

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) {
    }
}
2
i4guar On

So this is my updated answer. I don't think there is a pretty way to do it so now I am doing it with a custom Button.

import SwiftUI

struct ContentView: View {
    @State var isShowing = false
    var body: some View {
        Text("Hello, world!")
            .padding()
            .onTapGesture(count: 1, perform: {
                withAnimation(.spring()) {
                    self.isShowing.toggle()
                }
            })
            .showModal(isShowing: self.$isShowing)
    }
}

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
    @State var isDragging = false
    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
                                    isDragging = true
                                    bottomState = value.translation.height
                                    
                                }
                                .onEnded { _ in
                                    isDragging = false
                                    if bottomState > 50 {
                                        withAnimation(.spring()) {
                                            isShowing = false
                                        }
                                    }
                                    bottomState = 0
                                })
                        .transition(.move(edge: .bottom))
                }
            }
        }
    }
}

struct Container: View {
    var body: some View {
        CustomButton(action: {}, label: {
            Text("Pressme")
        })
        .frame(maxWidth: .infinity, maxHeight: 200)
    }
}

struct CustomButton<Label >: View where Label: View {
    @State var isPressed = false
    var action: () -> ()
    var label: () -> Label
    var body: some View {
        label()
            .scaleEffect(self.isPressed ? 0.9 : 1.0)
        .gesture(DragGesture(minimumDistance: 0).onChanged({_ in
            withAnimation(.spring()) {
                self.isPressed = true
            }
        }).onEnded({_ in
            withAnimation(.spring()) {
                self.isPressed = false
                action()
            }
        }))
    }
}

The problem is that you can't use implicit animations inside the container as they will be animated when it moves. So you need to explicitly set an animation using withAnimation also for the button pressed, which I now did with a custom Button and a DragGesture.

It is the difference between explicit and implicit animation.

Take a look at this video where this topic is explored in detail:

https://www.youtube.com/watch?v=3krC2c56ceQ&list=PLpGHT1n4-mAtTj9oywMWoBx0dCGd51_yG&index=11

0
Mahdi BM On

In Container, declare a binding var so you can pass the bottomState to the Container View:

struct Container: View {
    
    @Binding var bottomState: CGFloat

              .
              .
              .
              .
}

Dont forget to pass bottomState to your Container View wherever you use it:

Container(bottomState: $bottomState)

Now in your Container View, you just need to declare that you don't want an animation while bottomState is being changed:

Text("CONTAINER")
            .frame(maxWidth: .infinity, maxHeight: 200)
            .animation(nil, value: bottomState) // You Need To Add This
            .animation(.spring())

In .animation(nil, value: bottomState), by nil you are asking SwiftUI for no animations, while value of bottomState is being changed.

This approach is tested using Xcode 12 GM, iOS 14.0.1. You must use the modifiers of the Text in the order i put them. that means that this will work:

.animation(nil, value: bottomState)
.animation(.spring())

but this won't work:

.animation(.spring())
.animation(nil, value: bottomState)

I also made sure that adding .animation(nil, value: bottomState) will only disable animations when bottomState is being changed, and the animation .animation(.spring()) should always work if bottomState is not being changed.