UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Day 34: Animation Wrap-up Challenge One

Forums > 100 Days of SwiftUI

Hi, I am looking for a pointer but not the solution.

The challenge is "When you tap a flag, make it spin around 360 degrees on the Y axis."

I can make them all spin, I can make any one of them spin but I cannot make the one that was tapped spin.

The answer must be in the learning from day 32 & 33 so I'd be grateful if someone could tell me which of the learning topics I need to revisit to solve this challenge.

Thank you for your help

Regards

Ian

3      

This challenge was a tough one for me. But I think the thing that helped me the most was realizing that I needed to create a variable to store which button was tapped to accomplish this.

We aren't storing that information anywhere yet when the project is finished. But, we have no way of making sure that the animation is only applied to that button if we don't store that information at the time when it is tapped.

3      

@Fly0strich

Thank you, I will see if i can build on your clue.

Regards

3      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Hi,

I am slipping behind because I am refusing to move on until I understand how and why something works. The code below spins the pressed button and dims the other two, but I am not really sure why. I am not really sure why the pressed one spins but I'm doubly unsure why the other two always dim.

Also, how do a return the opacity of the two that dimmed ready for the next press?

Any simple explanation appreciated. Thank you for your help.

Regards, Ian

import SwiftUI
struct DiscImage: View {
    var content: String
    var rotationAmount: Double
    var opacityAmount: Double

    var body: some View {
        Image(content)
            .renderingMode(.original)
            .shadow(radius: 3)
            .imageScale(.small)
            .rotation3DEffect(.degrees(rotationAmount), axis: (x: 1, y: 0, z: 0))
            .opacity(opacityAmount)
    }
}

struct ContentView: View {
    @State private var rotationAmount:Double = 0.0
    @State private var opacityAmount:Double = 1.0
    @State private var chosenFlag: Int = -1

    var body: some View {        
        VStack {
            ForEach(0..<3) {number in
                Button {
                    chosenFlag = number
                    rotationAmount = 0.0
                    opacityAmount = 1.0
                    withAnimation(.easeInOut(duration: 4)) {
                        rotationAmount = 360
                        opacityAmount = 0.05                          
                    }
                } label: {
                    DiscImage(content: "disc"+String(number),
                              rotationAmount: (chosenFlag == number ? rotationAmount : 0),
                              opacityAmount: (chosenFlag == number ? 1.0 : opacityAmount))
                }
            }
        }
    }
    func resetDiscs() {
        //
    }
}

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

3      

Ian wants animations to come into focus!

I am not really sure why the pressed one spins but I'm doubly unsure why the other two always dim. Also, how do I reset the opacity of the two untapped icons?

Kodachrome

You take great photos!

Think about taking a photo of a distant bridge. Set your telephoto lens such that you have the most blurry image possible. Call that photo #1. Take another photo that is at the other end of the focus spectrum--every detail is sharp and crisp! This is photo #10,001.

Now, think how many gentle nudges you'd have to give your telephoto lens to take 10,000 photos while ever-so-gradually bringing that bridge into sharp focus.

THIS. This is how SwiftUI creates animations. First it looks at your Button view before you tap. In this state, the image is at full opacity, and is NOT rotating.

Next you tap ONE image. The rotationAmount for that image changes from 0.0 to 360. The opacity for the other two images change from 1.0 to 0.05.

SwiftUI takes a photo of the BEFORE state. Then it takes a photo of the AFTER state. THEN, SwiftUI takes 10,000 photos whilst ever so gently changing the opacity of two untapped icons to 0.05. At the same time, it rotates the tapped button 360 degrees around the X-Axis. It makes these in between photos stretch over a four second period and streams them to your device so quickly you don't see the images change. This is how SwiftUI processes view animations.

struct GameView: View {
    @State private var rotationAmount:      Double = 0.0
    @State private var untappedIconOpacity: Double = 1.0
    @State private var chosenFlag:          Int?   = nil

    var body: some View {
        VStack {
            ForEach(0..<3) { number in
                // Executes ONLY when tapped.
                Button {
                    withAnimation(.easeInOut(duration: 4)) {
                        // When chosenFlag gets a new value,
                        // ALL views associated with chosenFlag
                        // will be redrawn. The views will be animated.
                        // SwiftUI looks at the BEFORE state, then
                        // looks at the AFTER state. Then it draws
                        // thousands of inbetween states that form
                        // the animation.
                        chosenFlag         = number
                        // Same with untappedIconOpacity.
                        // Swift looks at ALL views associated with
                        // untappedIconOpacity. It takes a snapshot of the
                        // before state (untappedIconOpacity = 1.0) then it
                        // takes a snapshot of the after state (untappedIconOpacity = 0.05)
                        // Then it draws thousands of inbetween states that form
                        // the animation of the untapped buttons fading from
                        // an opacity of 1.0 down to 0.05.
                        untappedIconOpacity = 0.05
                    }
                } label: {
                // When @State variables change, SwiftUI is obligated to redraw the images.
                    DiscImage(content: Image(systemName: "opticaldisc"),
                              rotationAmount: (chosenFlag == number ? 360 : 0),
                              opacityAmount:  (chosenFlag == number ? 1.0 : untappedIconOpacity))
                }
            }.padding(.bottom)
            // Note! This changes the untappedIconOpacity WITHOUT animation
            Button { chosenFlag = nil; untappedIconOpacity = 1.0 } label: { Text("Reset") }
        }
    }
}

struct GameView_Previews: PreviewProvider {
    static var previews: some View {
        GameView()
    }
}

ForEach = a View Factory

See -> It's a view factory!

Another helpful article for you. When you're building a view and you use a ForEach, think of the ForEach as a view factory.

In your solution, the ForEach view factory builds what? It builds THREE Buttons. Remember one of @twoStraw's lessons about view structs...they are very easy to destroy and rebuild. SwiftUI is doing this hundreds of times everytime you tap, drag, change orientations, etc.

Each time you interact with the app, you can bet that some part of your view is being redrawn.

When you redraw your three Buttons, you can specify EACH TIME, what the Button's opacity and rotation amounts should be.

It's convenient to CHANGE the Button's opacity, only when it WASN'T tapped.

It's convenient to CHANGE the Button's rotation, only IF it was the one tapped.

4      

It's a GameView

Also, I changed your example from ContentView to GameView.

Why?

I think it more accurately describes what the view wants to display.

See -> Describe your View

Please add your comments to that thread!

4      

Test your Knowledge

Why is this code from your answer redundant?

// Some redundant code from your solution.
// What's redundant, and why?

Button {
    chosenFlag = number
    rotationAmount = 0.0
    opacityAmount = 1.0
    withAnimation(.easeInOut(duration: 4)) {
        rotationAmount = 360
        opacityAmount = 0.05                          
    }

4      

@Naaz8  

I had a hard time completing this challenge. I think the biggest help I received was realizing I had to create a variable to store which button had been tapped.

In the event that the project is completed, we have not yet stored that information anywhere. However, if we do not store that information when the button is tapped, we cannot guarantee that the animation will only be applied to that button. https://essentialsclothing.co/

3      

I'm going to try to show the relevant parts of my code for this project, or at least, the way that I ended up modifying it. I don't remember how closely it matches what Paul asked us to do in the challenges for this project, so I'll give you an explanation of what it's doing with the animations first.

  1. All flags start the round showing at 100% opacity and with no rotation.

  2. When any flag is tapped, it rotates the tapped flag 360 degrees. Untapped flags don't rotate at all.

  3. When any flag is tapped, the flag that was the correct answer remains showing at 100% opacity.

  4. The tapped flag will of course stay showing at 100% opacity if it was also the correct answer, but it will change to only 33% opacity if it was not the correct answer.

  5. Any untapped flag that was not the correct answer will disappear from the screen. (0% opacity)

So basically, after any flag is tapped, you will end up with either 1 or 2 flags still showing. There will only be 1 if the tapped flag was the correct answer. But there will be 2 showing with the tapped flag being kind of faded if the tapped flag was not the correct answer. Hopefully that makes sense.

I am going to use my code here rather than trying to explain yours because I think the structure of it is a little more clear, and the variable names might make some things a little more clear to you as well. But mostly because I think it will be easier for me to explain. Hopefully it doesn't just make you more confused in the end.

Now, for the code... (I am of course excluding some functions and View body elements that aren't really relevant to the animation part)

struct ContentView: View {
    @State private var tappedFlag: Int? = nil
    @State private var tappedFlagRotationAmount = 0.0
    @State private var tappedFlagOpacity = 1.0

    @State private var currentQuestion = 1
    @State private var score = 0
    @State private var gameOver = false

    @State private var correctAnswer = Int.random(in: 0...2)
    @State private var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Russia", "Spain", "UK", "US"].shuffled()

    var body: some View {
        ForEach(0..<3) { flagNumber in
            Button {
                withAnimation {
                    flagTapped(flagNumber)
                }
            } label: {
                FlagImage(country: countries[flagNumber])
            }
            .rotation3DEffect(.degrees(flagNumber == tappedFlag ? tappedFlagRotationAmount : 0.0), axis: (x: 0, y: 1, z: 0))
            .opacity(flagNumber == tappedFlag ? tappedFlagOpacity : untappedFlagOpacity(flagNumber))
        }
    }

    func FlagImage(country: String) -> some View {
        Image(country)
            .renderingMode(.original)
            .clipShape(Capsule())
            .shadow(radius: 5)
    }

    func flagTapped(_ flagNumber: Int) {
        tappedFlag = flagNumber
        tappedFlagRotationAmount = 360

        if flagNumber == correctAnswer {
            score += 1
            tappedFlagOpacity = 1.0
        } else {
            tappedFlagOpacity = 0.33
        }

        if currentQuestion != 8 {
            currentQuestion += 1
        } else {
            gameOver = true
        }
    }

    func untappedFlagOpacity(_ flagNumber: Int) -> Double {
        if tappedFlag == nil || flagNumber == correctAnswer {
            return 1.0
        } else {
            return 0.0
        }
    }

    func askQuestion() {
        countries.shuffle()
        tappedFlag = nil
        correctAnswer = Int.random(in: 0...2)
        tappedFlagRotationAmount = 0.0
        tappedFlagOpacity = 1.0
    }
}

First, I just want to point out this bit...

func FlagImage(country: String) -> some View {
        Image(country)
            .renderingMode(.original)
            .clipShape(Capsule())
            .shadow(radius: 5)
}

This is somewhat comparable to your DiscImage struct. However, notice that it is basically just an Image, as the FlagImage name suggests. The image doesn't need to worry about how the user interface might rotate it or anything like that. It just needs to simply be an image. We can let the View that is displaying the image worry about storing the animation amount variables, since they are more related to how we want the image to be displayed in the user interface than to the image itself.

Which, brings me to these...

@State private var tappedFlag: Int? = nil
@State private var tappedFlagRotationAmount = 0.0
@State private var tappedFlagOpacity = 1.0

Mainly, I just want you to notice how I've named them. These are the animation amounts that are going to be used for the tapped flag only. So, try not to get confused in thinking that these are the animation amounts for all of our buttons. It's easy to do that using a name like rotationAmount or opacity.

Also notice, I didn't actually make variables for the animation amounts of the untapped buttons. One reason is that I am never actually rotating the untapped buttons at all, so their rotation amount will always be 0.0. The opacity of an untapped button can change, however, and that's why I've created this function...

func untappedFlagOpacity(_ flagNumber: Int) -> Double {
        if tappedFlag == nil || flagNumber == correctAnswer {
            return 1.0
        } else {
            return 0.0
        }
}

The reason I made it a function rather than a property is because I needed it to be able to take a parameter flagNumber that only exists within the scope of our ForEach loop where this function is called from. That way it can check if the current flag button being made by our ForEach loop is the correct answer, and give it a special rule to follow for determining its opacity. That wouldn't have been possible with a simple property.

You'll see why I did it this way more clearly when you look at our ForEach loop that creates our buttons...

ForEach(0..<3) { flagNumber in
            Button {
                withAnimation {
                    flagTapped(flagNumber)
                }
            } label: {
                FlagImage(country: countries[flagNumber])
            }
            .rotation3DEffect(.degrees(flagNumber == tappedFlag ? tappedFlagRotationAmount : 0.0), axis: (x: 0, y: 1, z: 0))
            .opacity(flagNumber == tappedFlag ? tappedFlagOpacity : untappedFlagOpacity(flagNumber))
}

The interesting parts of this snippet are these lines...

.rotation3DEffect(.degrees(flagNumber == tappedFlag ? tappedFlagRotationAmount : 0.0), axis: (x: 0, y: 1, z: 0))
.opacity(flagNumber == tappedFlag ? tappedFlagOpacity : untappedFlagOpacity(flagNumber))

Remember that these lines are inside of our ForEach loop. So, these lines aren't actually changing the values as buttons are being tapped. They are just laying out the rules that this button should follow for determining what its rotation amount and opacity should be at any given time. And, we are giving these rules at the time when each button is created. So, the button has no way of knowing if it will ever be the tapped button yet. It just knows what to do to determine what those values should be in any case at any given time.

The first line basically says "If this flag button that we are creating right now ends up being the tappedFlag button at some point, use tappedFlagRotationAmount on it at those times, otherwise use 0.0."

The scond line basically says "If this flag button that we are creating right now ends up being the tappedFlag at some point, use tappedFlagOpacity on it, otherwise, get the opacity from the untappedFlagOpacity() function.

Our untappedFlagOpacity() fuction (as shown above) basically just makes sure that when no button is tapped yet, all of the flags are at 100% opacity, but after a button is tapped, the untapped buttons will be at either 0% or 100% opacity depending on whether or not they were the correct answer.

Side note: I'll tell you, it isn't actually 100% necessary that we used a function here, but the alternative was using very ugly and hard to read line of code like this...

.opacity(tappedFlag == nil ? 1.0 : flagNumber == tappedFlag ? tappedFlagOpacity : flagNumber == correctAnswer ? 1.0 : 0.0)

So handling all that logic in a function makes things a lot cleaner and easier to read.

.opacity(flagNumber == tappedFlag ? tappedFlagOpacity : untappedFlagOpacity(flagNumber))

Now, lets not forget about the other interesting part of our ForEach code snippet. We used withAnimation. As was described in a previous comment on this thread, withAnimation basically just says, "Take a snapshot of what all of my variables are set to right now, before running this block of code, and compare them to what they are set to after this block of code is finished running. Then, instead of changing those values instantly, show it in a slowly incremented, frame by frame sort of way."

So, what is the block of code that we used withAnimation on actually doing that might cause changes to our variables? It's calling this function...

func flagTapped(_ flagNumber: Int) {
        tappedFlag = flagNumber
        tappedFlagRotationAmount = 360

        if flagNumber == correctAnswer {
            score += 1
            tappedFlagOpacity = 1.0
        } else {
            tappedFlagOpacity = 0.33
        }

        if currentQuestion != 8 {
            currentQuestion += 1
        } else {
            gameOver = true
        }
    }

As you can see, that will change our tappedFlag, tappedFlagRotationAmount, and tappedFlagOpacity variables. That means that it will also trigger those rules that we put in place for our buttons to follow, to determine their rotation amounts and opacities differently as well.

.rotation3DEffect(.degrees(flagNumber == tappedFlag ? tappedFlagRotationAmount : 0.0), axis: (x: 0, y: 1, z: 0))
.opacity(flagNumber == tappedFlag ? tappedFlagOpacity : untappedFlagOpacity(flagNumber))

But instead of making the changes instantaneously, Swift will make sure that they change incrementally, and that the changes are displayed in a frame by frame sort of way to the best of its ability.

That just leave us with one other question that you asked. How do we make sure that these changes get reset when a new question is asked? Well, we have a function that takes care of asking the next question. So, that seems like a good place to handle resetting these values when the next question is asked...

    func askQuestion() {
        countries.shuffle()
        tappedFlag = nil
        correctAnswer = Int.random(in: 0...2)
        tappedFlagRotationAmount = 0.0
        tappedFlagOpacity = 1.0
    }

(This function is called with the press of another button that I didn't show in the sample code here)

Hopefully, all that helps to clear things up a little bit, rather than just confusing you further. But I tried my best!

5      

Thank you for all the help, especially to @Obelix for taking the time to comment on my code.

I am still working through the replies but I think my main issue stems from my previous expereince of writing quite linear code, I was missing the fact that the the view will get redrawn with every change of state as they change and not just once at that point in the code.

Thank you

3      

@Fly0strich, I've just been going through all your code and explanations, really helpful, thank you.

3      

Hi ! Im realy confused with this task. I only figured out how to spinn corrrect chosen flag but i cant make other flags dimm after button is pressed. In my code, wrong flags are dimmed from the begining :) Can you give me some hint here?

//
//  ContentView.swift
//  GuessTheFlag 2.0
//
//  Created by Filip Pokłosiewicz on 19/09/2023.
//

import SwiftUI

struct Title: ViewModifier {
    func body(content: Content) -> some View {
        content
            .font(.largeTitle).bold()
            .foregroundColor(.blue)
    }
}

struct FlagImage: View {

    var flagImage: Image
    var body: some View {
        flagImage
            .renderingMode(.original)
            .clipShape(Capsule())
            .shadow(radius: 10)

    }
}

struct ContentView: View {

    @State private var questionLimit = 0
    @State private var showingScore = false
    @State private var scoreTitle = ""
    @State private var userScore = 0
    @State private var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Russia", "Spain", "UK", "US"].shuffled()
    @State private var correctAnswer = Int.random(in: 0...2)
    @State private var animationAmount = 0.0
    @State private var spinThatFlag = false
    @State private var flagOpacity = 1.0
   // @State private var correctFlagChosen = false

    var body: some View {
        ZStack{
            RadialGradient(stops: [
                .init(color: Color(red: 0.1, green: 0.2, blue: 0.45), location: 0.3),
                .init(color: Color(red: 0.76, green: 0.15, blue: 0.26), location: 0.3),
            ], center: .top, startRadius: 200, endRadius: 400)
            .ignoresSafeArea()

            VStack {
                Spacer()
                Text("Guess the flag")
                    .font(.largeTitle).bold()
                    .foregroundColor(.white)

                VStack(spacing:15) {
                    VStack {
                        Text("Tap the flag of")
                            .modifier(Title())
                        Text(countries[correctAnswer])
                            .modifier(Title())
                    }
                    ForEach(0..<3) { number in
                        Button  {
                            flagTapped(number)
                            withAnimation {
                                spinThatFlag.toggle()

                            }

                        } label: {
                            FlagImage(flagImage: Image(countries[number]))

                        }
                        .rotation3DEffect(number == correctAnswer && spinThatFlag ? .degrees(animationAmount) : .degrees(0), axis: (x: 0, y: 1, z: 0))
                        .opacity((number != correctAnswer) ? 0.25 : 1)

                    }
                }

                .frame(maxWidth: .infinity)
                .padding(.vertical, 20)
                .background(.thinMaterial)
                .clipShape(RoundedRectangle(cornerRadius: 20))
                Spacer()
                Spacer()

                Text("Your score is \(userScore)")
                    .foregroundColor(.white)
                    .font(.title.bold())
                Spacer()
            }
            .padding()
        }

        .alert(scoreTitle, isPresented: $showingScore) {
            Button("Continue", action: askQuestion)
        } message: {
            Text("Your score is \(userScore)")
        }

        .alert("Game over", isPresented: .constant(questionLimit >= 8)) {
            Button("Play again", action: reset)
        } message: {
            Text("Your final score is \(userScore)")
        }
    }

    func flagTapped(_ number: Int) {
        if number == correctAnswer {
            scoreTitle = "Correct"
            userScore += 1
            animationAmount += 360

        } else {
            scoreTitle = "Wrong, that's the flag of \(countries[number])"
            userScore -= 1
            animationAmount = 0

        }
        showingScore = true
        questionLimit += 1

    }

    func askQuestion() {
        countries.shuffle()
        correctAnswer = Int.random(in: 0...2)

    }

    func reset() {
        userScore = 0
        questionLimit = 0
        countries.shuffle()

    }

}

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

3      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.