3

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.

2
  • 2
    Thanks for providing a working example, but could you try and remove any code that isn't related to the problem? 300+ lines of code is a lot to look through to try and see where the issue lies. e.g the problem still occurs if all the dragGesture code is removed, so we probably can eliminate that as the cause. Commented Jan 22, 2023 at 11:57
  • 1
    Looks to me like the animation is broken because the delay in flipCard() isn't happening. As mentioned, continuing to simplify the code seems like a good approach. Commented Jan 22, 2023 at 16:49

1 Answer 1

1

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!

Sign up to request clarification or add additional context in comments.

1 Comment

Glad you got it sorted out. I think most of us are still trying to understand how SwiftUI interacts with SwiftUI. :)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.