kendall.tubbs
kendall.tubbs

Reputation: 41

SwiftUI animation issue

I'm creating a flashcard view for language education. The first iteration consisted of flipping cards to front and back. The second iteration consisted of an addition of a swipe-based interface. The third iteration was meant to combine the previous two; however I'm having animation visualization issues (i.e. the third flip animation doesn't look like the first iteration).

first and third iteration gif displaying issue

first iteration code:

struct CardBack : View {
    let width : CGFloat
    let height : CGFloat
    let firLanguage: String
    let wordClass: String
    let accuracy: String
    let definition: String
    @Binding var degree : Double
    
    var body: some View {
        ZStack {
            VStack {
                HStack {
                    Text(wordClass)
                        .padding(10)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(10)
                        .padding(.horizontal, 5)
                        .padding(.top, 5)
                        .font(.system(size: 15))
                    Text(firLanguage)
                        .padding(10)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(10)
                        .padding(.trailing, 5)
                        .padding(.top, 5)
                        .font(.system(size: 15))
                    Text(accuracy)
                        .padding(10)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(10)
                        .padding(.trailing, 5)
                        .padding(.top, 5)
                        .font(.system(size: 15))
                    Spacer()
                    Image(systemName: "command.circle.fill")
                        .font(.system(size: 29))
                        .padding(.horizontal, 5)
                        .padding(.top, 5)
                        .foregroundColor(Color.gray)
                }
                Spacer()
                HStack {
                    Text("word analysis subview")
                        .padding(10)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(10)
                        .padding(.horizontal, 5)
                        .padding(.bottom, 5)
                        .font(.system(size: 15))
                    Spacer()
                }
            }
            .frame(width: 350, height: 190)
            .background(Color.white)
            .cornerRadius(10)
            .shadow(radius: 5)
            VStack {
                Spacer()
                Text(definition)
                    .font(.title)
                Spacer()
            }
            .frame(width: 350, height: 90)
            .background(Color.white)
            .cornerRadius(0)
            .shadow(radius: 1)
        }
        .rotation3DEffect(Angle(degrees: degree), axis: (x: 1, y: 0, z: 0))
    }
}

struct CardFront : View {
    let width : CGFloat
    let height : CGFloat
    let secLanguage: String
    let wordClass: String
    let accuracy: String
    let term: String
    @Binding var degree : Double
    
    var body: some View {
        ZStack {
            VStack {
                HStack {
                    Text(wordClass)
                        .padding(10)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(10)
                        .padding(.horizontal, 5)
                        .padding(.top, 5)
                        .font(.system(size: 15))
                    Text(secLanguage)
                        .padding(10)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(10)
                        .padding(.trailing, 5)
                        .padding(.top, 5)
                        .font(.system(size: 15))
                    Text(accuracy)
                        .padding(10)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(10)
                        .padding(.trailing, 5)
                        .padding(.top, 5)
                        .font(.system(size: 15))
                    Spacer()
                    Image(systemName: "command.circle.fill")
                        .font(.system(size: 29))
                        .padding(.horizontal, 5)
                        .padding(.top, 5)
                        .foregroundColor(Color.gray)
                }
                Spacer()
                HStack {
                    Text("word analysis subview")
                        .padding(10)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(10)
                        .padding(.horizontal, 5)
                        .padding(.bottom, 5)
                        .font(.system(size: 15))
                    Spacer()
                }
            }
            .frame(width: 350, height: 190)
            .background(Color.white)
            .cornerRadius(10)
            .shadow(radius: 5)
            VStack {
                Spacer()
                Text(term)
                    .font(.title)
                Spacer()
            }
            .frame(width: 350, height: 90)
            .background(Color.white)
            .cornerRadius(0)
            .shadow(radius: 1)
        }
        .rotation3DEffect(Angle(degrees: degree), axis: (x: 1, y: 0, z: 0))
        
    }
}

struct CardFlipView: View {
    //MARK: Variables
    @State var backDegree = 0.0
    @State var frontDegree = -90.0
    @State var isFlipped = true
    
    let width : CGFloat = 200
    let height : CGFloat = 250
    let durationAndDelay : CGFloat = 0.3
    
    let firLanguage: String = "English"
    let secLanguage: String = "Spanish"
    let wordClass: String = "Verb"
    let accuracy: String = "63%"
    let term: String = "hablar"
    let definition: String = "to talk"
    
    //MARK: Flip Card Function
    func flipCard () {
        isFlipped = !isFlipped
        if isFlipped {
            withAnimation(.linear(duration: durationAndDelay)) {
                backDegree = 90
            }
            withAnimation(.linear(duration: durationAndDelay).delay(durationAndDelay)){
                frontDegree = 0
            }
        } else {
            withAnimation(.linear(duration: durationAndDelay)) {
                frontDegree = -90
            }
            withAnimation(.linear(duration: durationAndDelay).delay(durationAndDelay)){
                backDegree = 0
            }
        }
    }
    //MARK: View Body
    var body: some View {
        ZStack {
            CardBack(width: width, height: height, firLanguage: firLanguage, wordClass: wordClass, accuracy: accuracy, definition: definition, degree: $frontDegree)
            CardFront(width: width, height: height, secLanguage: secLanguage, wordClass: wordClass, accuracy: accuracy, term: term, degree: $backDegree)
        }.onTapGesture {
            flipCard ()
        }
    }
}

third iteration code:

struct Word: Hashable, CustomStringConvertible {
    var id: Int
    let front: String
    let back: String
    let term: String
    let definition: String
    let accuracy: Int
    let wordclass: String
    
    var description: String {
        return "\(term), id: \(id)"
    }
}

struct ContentView3: View {
    /// List of words
    @State var words: [Word] = [
        Word(id: 0, front: "Spanish", back: "English", term: "to speak", definition: "hablar", accuracy: 63, wordclass: "Verb"),
        Word(id: 1, front: "Spanish", back: "English", term: "to eat", definition: "comer", accuracy: 63, wordclass: "Verb"),
        //Word(id: 2, front: "Spanish", back: "English", term: "to think", definition: "pensar", accuracy: 63, wordclass: "Verb"),
        //Word(id: 3, front: "Spanish", back: "English", term: "to live", definition: "vivir", accuracy: 63, wordclass: "Verb"),
        //Word(id: 4, front: "Spanish", back: "English", term: "to write", definition: "escribir", accuracy: 63, wordclass: "Verb"),
    ]
    
    /// Return the CardViews width for the given offset in the array
    /// - Parameters:
    ///   - geometry: The geometry proxy of the parent
    ///   - id: The ID of the current user
    private func getCardWidth(_ geometry: GeometryProxy, id: Int) -> CGFloat {
        let offset: CGFloat = CGFloat(words.count - 1 - id) * 10
        return geometry.size.width - offset
    }
    
    /// Return the CardViews frame offset for the given offset in the array
    /// - Parameters:
    ///   - geometry: The geometry proxy of the parent
    ///   - id: The ID of the current user
    private func getCardOffset(_ geometry: GeometryProxy, id: Int) -> CGFloat {
        return  CGFloat(words.count - 1 - id) * 10
    }
    
    private var maxID: Int {
        return self.words.map { $0.id }.max() ?? 0
    }
    
    var body: some View {
        VStack {
            GeometryReader { geometry in
                LinearGradient(gradient: Gradient(colors: [Color.init(#colorLiteral(red: 0.9686274529, green: 0.78039217, blue: 0.3450980484, alpha: 1)), Color.init(#colorLiteral(red: 1, green: 0.9882352941, blue: 0.862745098, alpha: 1))]), startPoint: .bottom, endPoint: .top)
                    .frame(width: geometry.size.width * 1.5, height: geometry.size.height)
                    .background(Color.yellow)
                    .clipShape(Circle())
                    .offset(x: -geometry.size.width / 4, y: -geometry.size.height / 6)
                
                VStack(spacing: 24) {
                    Spacer()
                    ZStack {
                        ForEach(self.words, id: \.self) { word in
                            Group {
                                // Range Operator
                                if (self.maxID - 3)...self.maxID ~= word.id {
                                    CardView(word: word, onRemove: { removedWord in
                                        // Remove that user from our array
                                        self.words.removeAll { $0.id == removedWord.id }
                                    })
                                    .animation(.spring())
                                    .frame(width: self.getCardWidth(geometry, id: word.id), height: 400)
                                    .offset(x: 0, y: self.getCardOffset(geometry, id: word.id))
                                }
                            }
                        }
                    }
                    Spacer()
                }
            }
        }.padding()
    }
}

//MARK: CardView
struct CardView: View {
    @State private var translation: CGSize = .zero
    @State private var swipeStatus: LikeDislike = .none
    
    @State var backDegree = 0.0
    @State var frontDegree = -90.0
    @State var isFlipped = true
    
    let durationAndDelay : CGFloat = 0.3
    
    var word: Word
    private var onRemove: (_ word: Word) -> Void
    
    private var thresholdPercentage: CGFloat = 0.5 // when the user has draged 50% the width of the screen in either direction
    
    private enum LikeDislike: Int {
        case like, dislike, none
    }
    
    init(word: Word, onRemove: @escaping (_ word: Word) -> Void) {
        self.word = word
        self.onRemove = onRemove
    }
    
    private func getGesturePercentage(_ geometry: GeometryProxy, from gesture: DragGesture.Value) -> CGFloat {
        gesture.translation.width / geometry.size.width
    }
    
    func flipCard () {
        isFlipped = !isFlipped
        if isFlipped {
            withAnimation(.linear(duration: durationAndDelay)) {
                backDegree = 90
            }
            withAnimation(.linear(duration: durationAndDelay).delay(durationAndDelay)){
                frontDegree = 0
            }
        } else {
            withAnimation(.linear(duration: durationAndDelay)) {
                frontDegree = -90
            }
            withAnimation(.linear(duration: durationAndDelay).delay(durationAndDelay)){
                backDegree = 0
            }
        }
    }
    
    var body: some View {
        GeometryReader { geometry in
            VStack(alignment: .leading) {
                ZStack(alignment: self.swipeStatus == .like ? .topLeading : .topTrailing) {
                    if self.swipeStatus == .like {
                        Text("YES")
                            .font(.headline)
                            .padding()
                            .cornerRadius(10)
                            .foregroundColor(Color.green)
                            .overlay(
                                RoundedRectangle(cornerRadius: 10)
                                    .stroke(Color.green, lineWidth: 3.0)
                            ).padding(24)
                            .rotationEffect(Angle.degrees(-45))
                    } else if self.swipeStatus == .dislike {
                        Text("NO")
                            .font(.headline)
                            .padding()
                            .cornerRadius(10)
                            .foregroundColor(Color.red)
                            .overlay(
                                RoundedRectangle(cornerRadius: 10)
                                    .stroke(Color.red, lineWidth: 3.0)
                            ).padding(.top, 45)
                            .rotationEffect(Angle.degrees(45))
                    }
                    ZStack {
                        CardFront2(word: word, degree: $frontDegree)
                        CardBack2(word: word, degree: $backDegree)
                    }.onTapGesture {
                        flipCard ()
                    }
                }
            }
            .animation(.interactiveSpring())
            .offset(x: self.translation.width, y: 0)
            .rotationEffect(.degrees(Double(self.translation.width / geometry.size.width) * 1), anchor: .bottom)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        self.translation = value.translation
                        
                        if (self.getGesturePercentage(geometry, from: value)) >= self.thresholdPercentage {
                            self.swipeStatus = .like
                        } else if self.getGesturePercentage(geometry, from: value) <= -self.thresholdPercentage {
                            self.swipeStatus = .dislike
                        } else {
                            self.swipeStatus = .none
                        }
                        
                    }.onEnded { value in
                        // determine snap distance > 0.5 (half the width of the screen)
                        if abs(self.getGesturePercentage(geometry, from: value)) > self.thresholdPercentage {
                            self.onRemove(self.word)
                        } else {
                            self.translation = .zero
                        }
                    }
            )
        }
    }
}

struct CardFront2 : View {
    var word: Word
    @Binding var degree : Double
    
    var body: some View {
        ZStack {
            HStack {
             VStack(alignment: .leading, spacing: 6) {
             HStack {
             Text("\(self.word.front)") ///add toggleability
             .font(.subheadline)
             .foregroundColor(.gray)
             .padding(7)
             .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
             .cornerRadius(10)
             Text("\(self.word.wordclass)") ///add toggleability
             .font(.subheadline)
             .foregroundColor(.gray)
             .padding(7)
             .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
             .cornerRadius(10)
             Text("\(self.word.accuracy)%") ///add toggleability
             .font(.subheadline)
             .foregroundColor(.gray)
             .padding(7)
             .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
             .cornerRadius(10)
             Spacer()
             Image(systemName: "info.circle")
             .foregroundColor(.gray)
             }.padding(.top, 15)
             HStack {
             Spacer()
             Text("\(self.word.definition)")
             .font(.title)
             .bold()
             Spacer()
             }.padding(.vertical, 15)
             }
             Spacer()
             }
            .padding(.horizontal, 10)
            .background(Color.white)
            .cornerRadius(10)
            .shadow(radius: 5)
        }
        .rotation3DEffect(Angle(degrees: degree), axis: (x: 0, y: 1, z: 0))
    }
}

struct CardBack2 : View {
    var word: Word
    @Binding var degree : Double
    
    var body: some View {
        ZStack {
            HStack {
             VStack(alignment: .leading, spacing: 6) {
             HStack {
             Text("\(self.word.back)") ///add toggleability
             .font(.subheadline)
             .foregroundColor(.gray)
             .padding(7)
             .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
             .cornerRadius(10)
             Text("\(self.word.wordclass)") ///add toggleability
             .font(.subheadline)
             .foregroundColor(.gray)
             .padding(7)
             .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
             .cornerRadius(10)
             Text("\(self.word.accuracy)%") ///add toggleability
             .font(.subheadline)
             .foregroundColor(.gray)
             .padding(7)
             .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
             .cornerRadius(10)
             Spacer()
             Image(systemName: "info.circle")
             .foregroundColor(.gray)
             }.padding(.top, 15)
             HStack {
             Spacer()
             Text("\(self.word.term)")
             .font(.title)
             .bold()
             Spacer()
             }.padding(.vertical, 15)
             }
             Spacer()
             }
            .padding(.horizontal, 10)
            .background(Color.white)
            .cornerRadius(10)
            .shadow(radius: 5)
        }
        .rotation3DEffect(Angle(degrees: degree), axis: (x: 0, y: 1, z: 0))
    }
}

I tried moving the .onTapGesture onto different parent views. I have really tried playing with all things animation for a few hours now, and I haven't been able to crack it. I want the card flip on iteration 3 to mirror that of iteration 1.

Upvotes: 3

Views: 181

Answers (1)

kendall.tubbs
kendall.tubbs

Reputation: 41

To update you guys on my complete fix.

At first I was clueless, then I identified the GeometryReader{} (GR) under CardView() as potentially the issue; however, it was actually the unnecessary VStack{} directly under the GR that it was reading from, instead of the ZStack{}. Outlined below.

GeometryReader { geometry in
  VStack { // <-REMOVED
        ZStack(alignment: self.swipeStatus == .yes ? .topLeading : .topTrailing) {
            if self.swipeStatus == .yes {...} else if self.swipeStatus == .no {...}
            ZStack {
                CardFront2(word: word, degree: $frontDegree)
                CardBack2(word: word, degree: $backDegree)
            }.onTapGesture {
                flipCard ()
            }
        }

After removing the above outlined code I was able to see more of the animation take place; however it still wasn't complete. I identified an animation in the ContentView() causing it. Outlined below.

                 ZStack {
                    ForEach(self.words, id: \.self) { word in
                        Group {
                            if (self.maxID - 3)...self.maxID ~= word.id {
                                CardView2(word: word, onRemove: { removedWord in
                                    self.words.removeAll { $0.id == removedWord.id }
                                })
                                .animation(.spring()) // <-REMOVED
                                .frame(width: self.getCardWidth(geometry, id: word.id), height: 400)
                                .offset(x: 0, y: self.getCardOffset(geometry, id: word.id))
                            }
                        }
                    }
                }

I'm quite self-taught, so I consistently need shoving in the right direction. Thank you both for your pointers!

Upvotes: 1

Related Questions