SwiftUI: How to animate a child of a parent which is currently animated

Viewed 554

I'm trying to create an app in which there are multiple views that can be transitioned between with animations within them. So far this is done by animating the offset of a VStack inside a scrollView where the offset is calculated by multiplying the screen height by the index of the current screen. I decided to go this route instead of using scrollTo as I wanted to customise the transition animation to ease in and out.

Here is the ContentView code:

import SwiftUI

struct ContentView: View {
    
    @Namespace var carousel
    @Namespace var splash
    @Namespace var home

    @State private var activeIndex = CGFloat(0)

    var body: some View {
        GeometryReader { geo in
            ScrollViewReader { proxy in
                ScrollView([]) {
                    VStack (spacing: 0) {
                        ZStack {
                            Color.red
                            
                            Splash(home: home, geo: geo, proxy: proxy)
                            .frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
                            .id(splash)
                        }
                        
                        ZStack {
                            Color.blue
                            
                            VStack {
                                Home(activeIndex: self.$activeIndex, splash: splash, home: home, geo: geo, proxy: proxy)
                                .frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
                                .offset(y: -200)
                                .id(home)
                            }
                        }
                    }
                    .offset(y: activeIndex > 0 ? -geo.size.height * activeIndex + 200 : -geo.size.height * activeIndex)
                    .animation(.easeInOut(duration: 0.8))
                    .id(carousel)
                }
                .frame(width: geo.size.width, height: geo.size.height)
            }
        }.ignoresSafeArea()
    }
}

And here is the code in which the button exists:

import SwiftUI

struct Home: View {
    
    @Binding var activeIndex: CGFloat
    var splash: Namespace.ID
    var home: Namespace.ID
    var geo: GeometryProxy
    var proxy: ScrollViewProxy

    var body: some View {
        VStack (alignment: .leading, spacing: 0) {
            HStack {
                Spacer()
                VStack {
                    Button(activeIndex == 0 ? "Let's go" : "Go Back") {
                        if activeIndex == 0 {
                            self.activeIndex = 1;
                        }
                        else {
                            self.activeIndex = 0;
                        }
                    }
                    .frame(width: 200, height: 50)
                    .font(.custom("Montserrat-SemiBold", size: 18))
                    .background(Color.green)
                    .cornerRadius(25)
                    .foregroundColor(.white)
                    .offset(y: 25)
                }
                Spacer()
            }
            .zIndex(1)
            VStack {
            }
            .frame(width: geo.size.width, height: geo.size.height * 0.8, alignment: .leading)
            .background(Color.white)
            .cornerRadius(40)
        }
        .frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
        .cornerRadius(40)
    }
}

Here's a gif of the functionality so far

I want to animate the 'Let's Go' button on the home page to wiggle slightly, then when it's pressed, the animation stops as the screen transition begins. However, this is not as easy as I thought, as this animation seems to be affected by the animation on the VStack in ContentView.

Here is my attempt at making this animation work:

import SwiftUI

struct Home: View {
    
    @State private var isAnimating = false
    @State private var stopAnimating = false
    
    @Binding var activeIndex: CGFloat
    var splash: Namespace.ID
    var home: Namespace.ID
    var geo: GeometryProxy
    var proxy: ScrollViewProxy

    var body: some View {
        VStack (alignment: .leading, spacing: 0) {
            HStack {
                Spacer()
                VStack {
                    Button(activeIndex == 0 ? "Let's go" : "Go Back") {
                        if activeIndex == 0 {
                            self.activeIndex = 1;
                        }
                        else {
                            self.activeIndex = 0;
                        }
                        isAnimating.toggle()
                        stopAnimating.toggle()
                    }
                    .frame(width: 200, height: 50)
                    .font(.custom("Montserrat-SemiBold", size: 18))
                    .background(Color.green)
                    .cornerRadius(25)
                    .foregroundColor(.white)
                    .offset(y: 25)
                    .shadow(color: Color.black.opacity(0.35), radius: 10)
                    .rotationEffect(Angle(degrees: self.isAnimating ? 5 : self.stopAnimating ? 0 : -5))
                    .animation(self.isAnimating ? Animation // Animation added
                            .easeInOut(duration: 0.2)
                                .repeatForever() : .default, value: self.isAnimating)
                    .onAppear { self.isAnimating = true }
                }
                Spacer()
            }
            .zIndex(1)
            VStack {
            }
            .frame(width: geo.size.width, height: geo.size.height * 0.8, alignment: .leading)
            .background(Color.white)
            .cornerRadius(40)
        }
        .frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
        .cornerRadius(40)
    }
}

And here is the (hilarious) output

Any help would be much appreciated!

1 Answers

I ended up using DispatchQueue.main.asyncAfter to set a timeout to stop and start the wiggling animation after the screen transitions had finished. Seems to work fine for now.

Bonus GIF

import SwiftUI

struct Home: View {
    
    @State private var isAnimating = false
    @State private var stopAnimating = false
    
    @Binding var activeIndex: CGFloat
    var splash: Namespace.ID
    var home: Namespace.ID
    var geo: GeometryProxy
    var proxy: ScrollViewProxy

    var body: some View {
        VStack (alignment: .leading, spacing: 0) {
            HStack {
                Spacer()
                VStack {
                    Button(activeIndex == 0 ? "Let's go" : "Go Back") {
                        if activeIndex == 0 {
                            self.activeIndex = 1;
                        }
                        else {
                            self.activeIndex = 0;
                        }
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { // THIS
                            isAnimating.toggle()
                            stopAnimating.toggle()
                        }
                    }
                    .frame(width: 200, height: 50)
                    .font(.custom("Montserrat-SemiBold", size: 18))
                    .background(Color.green)
                    .cornerRadius(25)
                    .foregroundColor(.white)
                    .offset(y: 25)
                    .shadow(color: Color.black.opacity(0.35), radius: 10)
                    .rotationEffect(Angle(degrees: self.isAnimating ? 5 : self.stopAnimating ? 0 : -5))
                    .animation(self.isAnimating ? Animation // Animation added
                            .easeInOut(duration: 0.2)
                                .repeatForever() : .default, value: self.isAnimating)
                    .onAppear { self.isAnimating = true }
                }
                Spacer()
            }
            .zIndex(1)
            VStack {
            }
            .frame(width: geo.size.width, height: geo.size.height * 0.8, alignment: .leading)
            .background(Color.white)
            .cornerRadius(40)
        }
        .frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
        .cornerRadius(40)
    }
}
Related