Search code examples
iosswiftuiswiftui-animation

Prevent SwiftUI animation from overriding nested withAnimation block


I have a screen with a draggable component (the "Springy" text) that springs back when released with a very prominent spring animation.

There is some text that comes in asynchronously (in this example, using a Timer), and I want that to fade in nicely while the Springy label moves up smoothly to make room for it.

Screenshot of example code

I added an animation to the VStack, and it transitions as I want it to when the asynchronously-loaded text fades in. However, that breaks the spring animation on the "Springy" text.

There is an animation that's commented out here, and if switch the 1 second animation to that spot the asynchronously-loaded text fades in, but right at the beginning of the animation the "Springy" text jumps up instead of sliding up.

How can I get both animations to work as I want them to?

import SwiftUI
import Combine

class Store: ObservableObject {
    @Published var showSection: Bool = false
    
    private var cancellables: [AnyCancellable] = []
    
    init() {
        
        Timer
            .publish(every: 2, on: RunLoop.main, in: .common)
            .autoconnect()
            .map { _ in true}
            .assign(to: \.showSection,
                    on: self)
            .store(in: &cancellables)
    }
}

struct ContentView: View {
    
    @ObservedObject var store = Store()
    
    @State var draggedState = CGSize.zero
    
    var body: some View {
        VStack {
            Spacer()
            VStack(alignment: .leading) {
                VStack(spacing: 4) {
                    HStack {
                        Text("Springy")
                            .font(.title)
                        Image(systemName: "hand.point.up.left.fill")
                            .imageScale(.large)
                            
                    }
                    .offset(x: draggedState.width, y: draggedState.height)
                    .foregroundColor(.secondary)
                    .gesture(
                        DragGesture().onChanged { value in
                            draggedState = value.translation
                        }
                        .onEnded { value in
                            // How can this animation be fast without the other animation slowing it down?
                            withAnimation(Animation
                                            .interactiveSpring(response: 0.3,
                                                               dampingFraction: 0.1,
                                                               blendDuration: 0)) {
                                draggedState = .zero
                            }
                        }
                    )
                    
                    VStack {
                        if store.showSection {
                            Text("Something that animates in after loading asynchrously.")
                                .font(.body)
                                .padding()
                                .transition(.opacity)
                        }
                    }
                    // When the animation is here, it doesn't cancel out the spring animation, but the Springy text jumps up at the beginning of the animation instead of animating.
//                    .animation(.easeInOut(duration: 1))
                }
                // Animation here has desired effect for loading, but overrides the Springy release animation.
                .animation(.easeInOut(duration: 1))
            }
            Spacer()
        }
    }
}

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

Expanded question, based on answer from Asperi.

Now, supposing I have multiple elements in this main VStack that I want to animate when they come in.

  1. Is it a good solution to add a separate .animation modifier with a value for each section I want to animate?

In the expanded example below, that's the part with:

.animation(.easeInOut(duration: 1),
                       value: store.showSection)
.animation(.easeInOut(duration: 1),
                      value: store.somePossibleText)
.animation(.easeInOut(duration: 1),
                      value: store.moreInformation)
  1. I am using 3 approaches here to determine whether to show a section or not. I used Bool in the initial example for simplicity, but in my real scenario I think it will make the most sense to use the String? approach instead of setting a separate Bool or checking for an empty string. Are there drawbacks to using that when it comes to animations, or is that fine? It seems to work well in this example given Asperi's solution.

Here's the full example again with the new modifications:

import SwiftUI
import Combine

class Store: ObservableObject {
    
    @Published var showSection: Bool = false
    @Published var somePossibleText: String = ""
    @Published var moreInformation: String?
    
    private var cancellables: [AnyCancellable] = []
    
    init() {
        
        Timer
            .publish(every: 2, on: RunLoop.main, in: .common)
            .autoconnect()
            .map { _ in true }
            .assign(to: \.showSection,
                    on: self)
            .store(in: &cancellables)
        
        Timer
            .publish(every: 3, on: RunLoop.main, in: .common)
            .autoconnect()
            .map { _ in "Something else loaded later" }
            .assign(to: \.somePossibleText,
                    on: self)
            .store(in: &cancellables)
        
        Timer
            .publish(every: 4, on: RunLoop.main, in: .common)
            .autoconnect()
            .map { _ in "More stuff loaded later" }
            .assign(to: \.moreInformation,
                    on: self)
            .store(in: &cancellables)
    }
}

struct ContentView: View {
    
    @ObservedObject var store = Store()
    
    @State var draggedState = CGSize.zero
    
    var body: some View {
        VStack {
            Spacer()
            VStack(alignment: .leading) {
                VStack(spacing: 4) {
                    HStack {
                        Text("Springy")
                            .font(.title)
                        Image(systemName: "hand.point.up.left.fill")
                            .imageScale(.large)
                    }
                    .offset(x: draggedState.width,
                            y: draggedState.height)
                    .foregroundColor(.secondary)
                    .gesture(
                        DragGesture().onChanged { value in
                            draggedState = value.translation
                        }
                        .onEnded { value in
                            // TODO: how can this animation be fast without the other one slowing it down?
                            withAnimation(Animation
                                            .interactiveSpring(response: 0.3,
                                                               dampingFraction: 0.1,
                                                               blendDuration: 0)) {
                                draggedState = .zero
                            }
                        }
                    )
                    
                    if store.showSection {
                        Text("Something that animates in after loading asynchrously.")
                            .font(.body)
                            .padding()
                            .transition(.opacity)
                    }
                    
                    if store.somePossibleText != "" {
                        Text(store.somePossibleText)
                            .font(.footnote)
                            .padding()
                            .transition(.opacity)
                    }
                    
                    if let moreInformation = store.moreInformation {
                        Text(moreInformation)
                            .font(.footnote)
                            .padding()
                            .transition(.opacity)
                    }
                }
                .animation(.easeInOut(duration: 1),
                           value: store.showSection)
                .animation(.easeInOut(duration: 1),
                           value: store.somePossibleText)
                .animation(.easeInOut(duration: 1),
                           value: store.moreInformation)
            }
            Spacer()
        }
    }
}

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

Solution

  • If I correctly understood your expectation, it is enough to bind last animation to showSection value, like below

    }
    // Animation here has desired effect for loading, but overrides the Springy release animation.
    .animation(.easeInOut(duration: 1), value: store.showSection)
    

    Tested with Xcode 12 / iOS 14.