in SwiftUI, in a ForEach(0 ..< 3), animate the tapped button only (not all 3), animate the other 2 differently

After a full day trying to animate these buttons I give up and ask for help. I would like to animate the correct button only like this: .rotation3DEffect(.degrees(self.animationAmount), axis: (x: 0, y: 1, z: 0)) and at the same time make the other two buttons fade out to 25% opacity.

When the player clicks the wrong button I would like to animate the wrong button like this: .rotation3DEffect(.degrees(self.animationAmount), axis: (x: 1, y: 1, z: 1)) (or anyway else you can think to indicate disaster) and leave the other two alone.

After that happened I would like the alert to show.

Below is my code. I commented what I would like to do and where if at all possible. It all works like I want but cannot get the animation going.

Thanks for your help in advance

    import SwiftUI

struct ContentView: View {
   @State private var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Russia", "Spain", "UK", "US"]
        @State private var correctAnswer = Int.random(in: 0...2)
        @State private var showingScore = false
        @State private var scoreTitle = ""
        @State private var userScore = 0
        @State private var userTapped = ""
         @State private var animationAmount =  0.0
    
        var body: some View {
            ZStack {
                LinearGradient(gradient: Gradient(colors: [.blue, .black]), startPoint: .top, endPoint: .bottom)
                    .edgesIgnoringSafeArea(.all)
                
                VStack(spacing: 20) {
                    VStack {
                        Text("Tap the flag of...")
                            .foregroundColor(.white).font(.title)
                        Text(countries[correctAnswer])
                            .foregroundColor(.white)
                            .font(.largeTitle).fontWeight(.black)
                    }
                    
                    ForEach(0 ..< 3) { number in
                        Button(action: {
                                self.flagTapped(number)
                            if self.correctAnswer == number {
                                //animate the correct button only like this:
                                //.rotation3DEffect(.degrees(self.animationAmount), axis: (x: 0, y: 1, z: 0))
                                // and
                                // make the other two buttons fade out to 25% opacity
                            } else {
                                // animate the wrong button like this:
                                //.rotation3DEffect(.degrees(self.animationAmount), axis: (x: 1, y: 1, z: 1))
                            }
                        }) {
                            Image(self.countries[number])
                                .renderingMode(.original)
                                .clipShape(Capsule())
                                .overlay(Capsule().stroke(Color .black, lineWidth: 1))
                                .shadow(color: .black, radius: 2)
                        }
                    }
                     
                    Text ("your score is:\n \(userScore)").foregroundColor(.white).font(.title).multilineTextAlignment(.center)
                }
                
            }
            .alert(isPresented: $showingScore) {
                Alert(title: Text(scoreTitle), message: Text("You chose the flag of \(userTapped)\nYour score is now: \(userScore)"), dismissButton: .default(Text("Continue")) {
                    self.askQuestion()
                    })
            }
        }
        func flagTapped(_ number: Int) {
            userTapped = countries[number]
            if number == correctAnswer {
                scoreTitle = "Correct"
                userScore += 1
            } else {
                scoreTitle = "Wrong"
                userScore -= 1
            }
            showingScore = true
        }
        
        func askQuestion() {
            countries.shuffle()
            correctAnswer = Int.random(in: 0...2)
        }
    
    }

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

Upvotes: 3

Views: 1328

Answers (3)

Yarik
Yarik

Reputation: 1

this is an old question, but still I guess I found partial solution for this and it might help future students too. My solution is not a perfect one and it is a bit clumsy, but maybe it may help others to come up with a better one. What I saw from the task itself you have to animate each button and fadeout those, which were not tapped. However if you apply the animation to all of the views made with forEach and they all are attached to the same variable, the animation will be triggered for all of them: My solution was to split this animation variables into an array with indexes of the flags and change only variables attached to the flag tapped or not tapped.

This is my complete code:

struct ContentView: View {
@State private var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Monaco", "Nigeria", "Poland", "Spain", "UK", "US"].shuffled()
@State private var title = ""
@State private var isDisplayed = false
@State private var rightAnswer = Int.random(in: 0...2)
@State private var messageText = ""
@State private var score = 0
@State private var answersCount = 0
@State private var modalButtonText = ""
@State private var animationRotation = [0.0, 0.0, 0.0]
@State private var viewAnimation = 0.0
@State private var opacity = [1.0, 1.0, 1.0]


var body: some View {
    ZStack {
        Color(red: 239, green: 239, blue: 239)
            .ignoresSafeArea()
        VStack (spacing: 50){
            Spacer()
            VStack {
                Text("Guess the Flag")
                    .font(.subheadline.weight(.semibold))
                Text(countries[rightAnswer])
                    .font(.largeTitle.bold())
            }
            VStack (spacing: 30) {
        
                ForEach(0...2, id: \.self) {country in
                    Button {
                        checkCard(country)
                    } label: {
                        FlagView(index: country, arrayOfCountries: countries)
                    }.rotation3DEffect(Angle(degrees: animationRotation[country]), axis: (x: 0, y: 1, z: 0)).opacity(opacity[country])
                }.rotation3DEffect(Angle(degrees: viewAnimation), axis: (x: 0, y: 1, z: 0))
                Spacer()
                Text("Score: \(score)").TitleStyle()
                Spacer()
            }
        }
    }
    .alert(title, isPresented: $isDisplayed) {
        Button(modalButtonText) {
            newQuestion()
        }
    } message: {
        Text(messageText)
    }
}

func checkCard(_ number: Int) {
    
    if number == rightAnswer {
        modalButtonText = "Continue"
        title = "Correct"
        messageText = "You scored 1 point"
        score += 1
    } else {
        modalButtonText = "Continue"
        title = "Incorrect"
        messageText = "You lost 1 point"
        if score > 0 {
            score -= 1
        }
    }
    if answersCount > 8 {
        modalButtonText = "Restart"
        messageText = "This was the last one"
    }
    
    withAnimation(.easeIn(duration: 1.0)) {
        animationRotation[number] -= 360
        for i in 0...2 {
            if i != number {
                opacity[i] = 0.25
            }
        }
    }
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { timer in
        isDisplayed = true
    }

}

func newQuestion(){

    countries.shuffle()
    rightAnswer = Int.random(in: 0...2)
    for i in 0...2 {
        opacity[i] = 1.0
    }
    
    withAnimation(.easeIn(duration: 0.5)) {
        viewAnimation += 180
    }
    if answersCount > 8 {
        score = 0
        answersCount = 0
    } else {
        answersCount += 1
    }

}

} These are the solution parts:

@State private var animationRotation = [0.0, 0.0, 0.0]
@State private var opacity = [1.0, 1.0, 1.0]

Here we create two variables of arrays with relevant values

ForEach(0...2, id: \.self) {country in
                    Button {
                        checkCard(country)
                    } label: {
                        FlagView(index: country, arrayOfCountries: countries)
                    }.rotation3DEffect(Angle(degrees: animationRotation[country]), axis: (x: 0, y: 1, z: 0)).opacity(opacity[country])
                }.rotation3DEffect(Angle(degrees: viewAnimation), axis: (x: 0, y: 1, z: 0))

in ForEach View we call assign those variables to each of the animation modifiers properties

        withAnimation(.easeIn(duration: 1.0)) {
        animationRotation[number] -= 360
        for i in 0...2 {
            if i != number {
                opacity[i] = 0.25
            }
        }
    }
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { timer in
        isDisplayed = true
    }

Setting delay so I can show animation before the alert pops up and changing the opacity of other flags that were not selected with loop

Upvotes: 0

Carlos
Carlos

Reputation: 31

I had a problem similar to yours when solving this challenge. I figured out a solution without using DispatchQueue.main.asyncAfter. I made the following as the final solution:

  1. Spin around 360 degrees the correct chosen flag and fade out the other flags to 25 % of opacity.
  2. Blur the background of the wrong flag chosen with red and fade out the other flags to 25 % of opacity.

Here is the full solution (I comment on the parts that were important to achieve the above solution):

import SwiftUI

// Create a custom view
struct FlagImage: View {
    var countryFlags: String
    
    var body: some View {
        Image(countryFlags)
            .renderingMode(.original)
            .clipShape(Capsule())
            .overlay(Capsule().stroke(Color.black, lineWidth: 1))
            .shadow(color: .black, radius: 2)
    }
}

struct ContentView: View {
    
    @State private var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Russia", "Spain", "UK", "US"].shuffled()
    
    @State private var correctAnswer = Int.random(in: 0...3)
    @State private var showingScore = false
    @State private var scoreTitle = ""
    
    @State private var userScore = 0
    
    // Properties for animating the chosen flag
    @State private var animateCorrect = 0.0
    @State private var animateOpacity = 1.0
    @State private var besidesTheCorrect = false
    @State private var besidesTheWrong = false
    @State private var selectedFlag = 0
    
    var body: some View {
        
        ZStack {
            LinearGradient(gradient: Gradient(colors: [.blue, .black]), startPoint: .top, endPoint: .bottom).edgesIgnoringSafeArea(.all)
            
            VStack(spacing: 30) {
                
                VStack {
                    Text("Tap on the flag!")
                        .foregroundColor(.white)
                        .font(.title)
                    
                    Text(countries[correctAnswer])
                        .foregroundColor(.white)
                        .font(.largeTitle)
                        .fontWeight(.black)
                }
                
                
                ForEach(0 ..< 4) { number in
                    Button(action: {
                        
                        self.selectedFlag = number
                        
                        self.flagTapped(number)
                        
                    }) {
                        
                        FlagImage(countryFlags: self.countries[number])
                    }
                    // Animate the flag when the user tap the correct one:
                    // Rotate the correct flag
                    .rotation3DEffect(.degrees(number == self.correctAnswer ? self.animateCorrect : 0), axis: (x: 0, y: 1, z: 0))
                    // Reduce opacity of the other flags to 25%
                    .opacity(number != self.correctAnswer && self.besidesTheCorrect ? self.animateOpacity : 1)
                    
                    // Animate the flag when the user tap the wrong one:
                    // Create a red background to the wrong flag
                    .background(self.besidesTheWrong && self.selectedFlag == number ? Capsule(style: .circular).fill(Color.red).blur(radius: 30) : Capsule(style: .circular).fill(Color.clear).blur(radius: 0))
                    // Reduce opacity of the other flags to 25% (including the correct one)
                    .opacity(self.besidesTheWrong && self.selectedFlag != number ? self.animateOpacity : 1)
                    
                }
                Spacer()
                
                Text("Your total score is: \(userScore)")
                    .foregroundColor(Color.white)
                    .font(.title)
                    .fontWeight(.black)
                
                Spacer()
                
            }
        }
        .alert(isPresented: $showingScore) {
            Alert(title: Text(scoreTitle), dismissButton: .default(Text("Continue")) {
                self.askQuestion()
                })
        }
        
    }
    
    func flagTapped(_ number: Int) {
        
        if number == correctAnswer {
            scoreTitle = "Correct!"
            
            userScore += 1
            
            // Create animation for the correct answer
            withAnimation {
                self.animateCorrect += 360
                self.animateOpacity = 0.25
                self.besidesTheCorrect = true
            }
        } else {
            scoreTitle = "Wrong!"
            
            // Create animation for the wrong answer
            withAnimation {
                self.animateOpacity = 0.25
                self.besidesTheWrong = true
            }
        }
        showingScore = true
    }

    func askQuestion() {
        // Return the booleans to false
        besidesTheCorrect = false
        besidesTheWrong = false
        countries = countries.shuffled()
        correctAnswer = Int.random(in: 0...3)
    }
    
}

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

Basically, my solution is to add ternary operators inside the view modifiers (e.g., .rotation3DEffect(...), .opacity(...) and .background(...)) after the Button view. The tricky part is to correctly combine the checking condition.

I prefer to add the withAnimation modifier to my flagTapped function. In this place I have more control of the animations if the user select a correct or a wrong flag.

I made a small change to the original challenge: just add one more flag to the view.

The final result when a user presses the correct and wrong flag is here:

Screenshot.

Upvotes: 1

Lukas
Lukas

Reputation: 281

I had the same problem. Here is my solution (only the code that's different from yours):

The ForEach:

ForEach(0 ..< 3, id: \.self){ number in
                Button(action: {
                    withAnimation{
                        self.tappedFlag = number
                        self.flagTapped(number)
                    }
                }){
                    FlagImage(imageName: self.countries[number])
                }
                .rotation3DEffect(.degrees(self.isCorrect && self.selcectedNumber == number ? 360 : 0), axis: (x: 0, y: 1, z: 0))
                .opacity(self.isFadeOutOpacity && self.selcectedNumber != number ? 0.25 : 1)
                    
                .rotation3DEffect(.degrees(self.wrongAnswer && number != self.correctAnswer ? 180 : 0), axis: (x: 1, y: 0, z: 0))
                .opacity(self.wrongAnswer && number != self.correctAnswer ? 0.25 : 1)
                
            }

The alert:

.alert(isPresented: $showingScore){
        if scoreTitle == "Correct"{
            return Alert(title: Text(scoreTitle), message: Text("Your score is \(userScore)"), dismissButton: .default(Text("Continue")){
                    self.askQuestion()
                })
        }else{
            return Alert(title: Text(scoreTitle), message: Text("That is the flag of \(countries[tappedFlag]), you lost one point!"), dismissButton: .default(Text("Continue")){
                self.askQuestion()
            })
        }
    }

The two functions:

func flagTapped(_ number: Int){
    self.selcectedNumber = number
    self.alreadyTapped = true
    if number == correctAnswer{
        scoreTitle = "Correct"
        userScore += 1
        self.isCorrect = true
        self.isFadeOutOpacity = true
    }else{
        self.wrongAnswer = true
        scoreTitle = "Wrong"
        if userScore != 0{
            userScore -= 1
        }
    }
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        self.showingScore = true
    }
}

func askQuestion(){
    countries = countries.shuffled()
    correctAnswer = Int.random(in: 0...2)
    self.isCorrect = false
    self.isFadeOutOpacity = false
    self.wrongAnswer = false
}

You have to declare some new variables :)

I hope I could help.

PS: There is a playlist on Youtube for 100DaysOfSwiftUI with the solutions to almost every own task.

https://www.youtube.com/watch?v=9AUGceRIUSA&list=PL3pUvT0fmHNhb3qcpvuym6KeM12eQK3T1

Upvotes: 1

Related Questions