Hunter
Hunter

Reputation: 1

Parent view not animating child view's frame changes in SwiftUI

So I'm finishing up work on a SwiftUI based iOS App. I'm in the process of refining animations and visual layout stuff. My issue is as follows:

I have a parent view (A) which contains a child view (B) in its body, both views use an @ObservedObject view model for their data. I also have an overlay view (C) applied within A's body onto B. When B changes its frame size due to reloading on a data change from its view model, the overlay does not animate through the frame size change and just jumps to the new position.

    struct A: View {
        @ObservedObject var viewModel: ViewModelForA
        
        var body: some View {
            VStack {
                Spacer()
                B().overlay{
                    VStack{
                        HStack{
                            Spacer()
                            C()
                        }
                        Spacer()
                    }
                }
            }
        }
    }

How can I get C to animate correctly according to B's changing frame size?

Upvotes: 0

Views: 1039

Answers (1)

Helam
Helam

Reputation: 1505

I just had this same issue and can't find anything online for how to do this so I came up with a solution of my own that uses an environment action (similar to how the OpenURLAction is structured).

Usage of the solution (implementation shown after)

To use it, you use this custom .childTriggeredAnimation(animation: .default) modifier on the parent view. This adds an animateParent action to the environment which can be used by child views to trigger an animation in the parent.

struct ParentView: View {
    // without the solution, the text above
    // and below the child view jump instantly
    // even though the child view size is animating
    // with the solution, they animate along with the child
    var body: some View {
        VStack {
            Text("Above")
            ChildView() // grows and shrinks in an animated way
            Text("Below")
        }
        .childTriggeredAnimation(animation: .default) // part 1
    }
}

And then in the child view you pull the animateParent action out of the environment with @Environment(\.animateParent) and then call that action when you want the animation to take place in the parent.

struct ChildView: View {
    @State private var isBig = false
    @Environment(\.animateParent) 
    private var animateParent // part 2
    var body: some View {
        Button { 
            // causes animation in child view
            isBig.toggle() 
            // triggers animation in parent
            animateParent() // part 3
            // OR animateParent(overrideAnimation: whatever)
        } label: {
            Rectangle()
                .frame(height: isBig ? 400 : 200)
        }
        .animation(.default, value: isBig)
    }
}

Implementation

For the implementation we define a custom EnvironmentKey (the docs demonstrate this very well) to use to store the action to run that will trigger the animation in the parent.

We also make a custom ViewModifier to place on the parent view to setup the animation in the parent and to add the custom action to the environment so that the child views can access it when they need to.

import SwiftUI
struct AnimateParentAction {
    private let action: (Animation?) -> Void
    init(action: @escaping (Animation?) -> Void) {
        self.action = action
    }
    func callAsFunction() {
        action(nil)
    }
    func callAsFunction(overrideAnimation: Animation) {
        action(overrideAnimation)
    }
}
private struct AnimateParentActionKey: EnvironmentKey {
    static var defaultValue: AnimateParentAction = .init(action: { _ in })
}
extension EnvironmentValues {
    var animateParent: AnimateParentAction {
        get { self[AnimateParentActionKey.self] }
        set { self[AnimateParentActionKey.self] = newValue }
    }
}
private struct OnAnimateParentCalledModifier: ViewModifier {
    let animation: Animation
    @State private var animationToggle = false
    @State private var overrideAnimation: Animation?
    func body(content: Content) -> some View {
        content
            .environment(\.animateParent, AnimateParentAction(action: { overrideAnimation in
                self.overrideAnimation = overrideAnimation
                animationToggle.toggle()
            }))
            .animation(overrideAnimation ?? animation, value: animationToggle)
    }
}
extension View {
    func childTriggeredAnimation(animation: Animation) -> some View {
        modifier(OnAnimateParentCalledModifier(animation: animation))
    }
}

This solution worked for me, I am curious to see if anyone has come up with other better solutions but I feel like this is decently clean and reusable despite the boilerplate needed to set it up initially.

Upvotes: 1

Related Questions