swiftswiftuiswiftui-foreach

Updating array used in foreach does not update view swift


This is a prompted learning app that shows you a question and its answer on a card. There are a stack of cards with each card representing an item in an array.

If you get the answer correct you swipe right and the card is deleted. If you get it wrong, you swipe left and the card is supposed to be moved to the back of the stack for you to try again. The problem is the ForEach is not updating the view even though the move has been successful.

I have tried creating the array directly in ContentView bypassing the class but this doesn't work. I also tried to move the work from CardView to ContentView but no shot. What is going wrong?

I have stripped down the code to the barest minimum to reproduce the problem here:

struct Card: Identifiable, Equatable {
    let id = UUID()
    var question: String
    var answer: String
    
    static func ==(lhs: Card, rhs: Card) -> Bool {
        return lhs.id == rhs.id
    }
    
    #if DEBUG
    static var example = Card(question: "How many stars are in the milky way galaxy?", answer: "Just one you")
    static var example2 = Card(question: "What is your name?", answer: "Bugs Bunny")
    static var example3 = Card(question: "What is your favorite color?", answer: "Red")
    static var example4 = Card(question: "What is the largest building in the world?", answer: "The Burj Khalifa")
    static var example5 = Card(question: "Average daily human calory intake", answer: "2000kcal")
    #endif
}

@Observable
class CardC {
    var items: [Card] = [.example, .example2, .example3, .example4, .example5]
}



struct ContentView: View {
    @State private var cards = CardC()
    
    func resetCards() {
        cards.items = [.example, .example2, .example3, .example4, .example5]
    }
    
    var body: some View {
            VStack {
                if cards.items.isEmpty {
                    withAnimation {
                        Button("Restart game", role: .cancel, action: resetCards)
                            .buttonStyle(.borderedProminent)
                    }
                } else {
                    ZStack {
                        ForEach(Array(cards.items.enumerated()), id: \.element.id) { index, card in
                            CardView(cards: cards, card: card)
                                .allowsHitTesting(index == cards.items.count - 1)
                        }
                    }
                }
            }
            .onAppear(perform: resetCards)
    }
}

#Preview {
    ContentView()
}



struct CardView: View {
    @Bindable var cards: CardC
    let card: Card
    
    @State private var offset = CGSize.zero
    
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 25)
                .fill(.white)
            
            VStack(alignment: .center) {
                Text(card.question)
                
                Text(card.answer)
            }
        }
        .shadow(radius: 10)
        .frame(width: 450, height: 250)
        .offset(x: offset.width * 5)
        .gesture(
            DragGesture()
                .onChanged { drag in
                    offset = drag.translation
                }
                .onEnded { _ in
                    let cardCopy = card
                    
                    if abs(offset.width) > 100 {
                        let index = cards.items.firstIndex(of: card)!
                        cards.items.remove(at: index)
                        
                        if offset.width < -100 {
                            cards.items.insert(cardCopy, at: 0)
                        }
                    } else {
                        withAnimation {
                            offset = .zero
                        }
                    }
                }
        )
    }
}

#Preview {
    CardView(cards: CardC(), card: .example)
}

Solution

  • The issue is that when you swipe left, you're changing the offset of the card that's supposed to go to the bottom of the stack, but you're not resetting the offset after.

    So in the .onEnded of your DragGesture, if swiping left, insert the card back and reset its offset:

    if offset.width < -100 {
        cards.items.insert(cardCopy, at: 0)
        offset = .zero // <- here, reset the offset after inserting the card back in
    }
    

    Additional suggestions

    Since you're using a model, your insertion/removal functions should be part of the model, not defined in the gesture. The gesture should simply call the respective functions as needed.

    So your @Observable could look something like this:

    @Observable
    class CardStack {
        var cards: [ItemCard] = [.example, .example2, .example3, .example4, .example5]
        
        func moveToBottom(_ card: ItemCard) {
            guard let index = cards.firstIndex(of: card) else { return }
            let removed = cards.remove(at: index)
            cards.insert(removed, at: 0)
        }
        
        func popLast() {
            _ = cards.popLast()
        }
        
        func resetCards() {
            cards = [.example, .example2, .example3, .example4, .example5]
        }
    }
    

    Then in your gesture's .onEnded:

    .onEnded { _ in
        if offset.width > 100 {
            //remove from stack
            stack.popLast()
        }
        else if offset.width < -100 {
            //move to back of stack
            stack.moveToBottom(card)
            offset = .zero // <- reset offset after moving card to bottom of stack
        }
        else {
            withAnimation {
                offset = .zero
            }
        }
    }
    

    Your .tinderSwipe function forces the background to be defined three times. If ever you want to change any card background properties, you will have to update it accordingly. Instead, I suggest a simpler function that only returns a color based on swipe distance:

    func cardColor(_ translation: Double) -> Color {
        if translation < 0 {
            return .red
        } else if translation == 0 {
            return .white
        } else {
            return .green
        }
    }
    

    That you call in a single .background modifier:

    .background(cardColor(offset.width), in: .rect(cornerRadius: 25))
    

    Here's the complete working code with the suggested changes:

    import SwiftUI
    
    struct ItemCard: Identifiable, Equatable {
        let id = UUID()
        var question: String
        var answer: String
        
        static func ==(lhs: ItemCard, rhs: ItemCard) -> Bool {
            return lhs.id == rhs.id
        }
        
        #if DEBUG
        static var example = ItemCard(question: "How many stars are in the milky way galaxy?", answer: "Just one you")
        static var example2 = ItemCard(question: "What is your name?", answer: "Bugs Bunny")
        static var example3 = ItemCard(question: "What is your favorite color?", answer: "Red")
        static var example4 = ItemCard(question: "What is the largest building in the world?", answer: "The Burj Khalifa")
        static var example5 = ItemCard(question: "Average daily human calory intake", answer: "2000kcal")
        #endif
    }
    
    @Observable
    class CardStack {
        var cards: [ItemCard] = [.example, .example2, .example3, .example4, .example5]
        
        func moveToBottom(_ card: ItemCard) {
            guard let index = cards.firstIndex(of: card) else { return }
            let removed = cards.remove(at: index)
            cards.insert(removed, at: 0)
        }
        
        func popLast() {
            _ = cards.popLast()
        }
        
        func resetCards() {
            cards = [.example, .example2, .example3, .example4, .example5]
        }
    }
    
    
    
    struct CardsContentView: View {
        
        //Observables
        @State private var stack = CardStack()
        
        //Body
        var body: some View {
                VStack {
                    if stack.cards.isEmpty {
                        withAnimation {
                            Button("Restart game", role: .cancel, action: stack.resetCards)
                                .buttonStyle(.borderedProminent)
                        }
                    } else {
                        ZStack {
                            ForEach(Array(stack.cards.enumerated()), id: \.element.id) { index, card in
                                CardView(stack: stack, card: card)
                                    .allowsHitTesting(index == stack.cards.count - 1)
                            }
                        }
                        .padding()
                    }
                    HStack {
                        Text("Cards in stack: \(stack.cards.count)")
                    }
                }
                .padding()
                .frame(maxWidth: .infinity, alignment: .center)
                .onAppear(perform: stack.resetCards)
        }
    }
    
    #Preview {
        CardsContentView()
    }
    
    struct CardView: View {
        
        //Parameters
        @Bindable var stack: CardStack
        let card: ItemCard
        var removal: (() -> Void)? = nil
        
        //State values
        @State private var isShowingAnswer = false
        @State private var offset = CGSize.zero
        
        func cardColor(_ translation: Double) -> Color {
            if translation < 0 {
                return .red
            } else if translation == 0 {
                return .white
            } else {
                return .green
            }
        }
        
        //Body
        var body: some View {
            ZStack {            
                RoundedRectangle(cornerRadius: 25)
                    .fill(Color.gray.opacity(0.3))
                    .opacity(1.0 - abs(offset.width/50.0))
                    .background(cardColor(offset.width), in: .rect(cornerRadius: 25))
                
                VStack(alignment: .center) {
                    Text(card.question)
                        .font(.largeTitle)
                        .foregroundStyle(.black)
                    
                    if isShowingAnswer {
                        Text(card.answer)
                            .font(.title)
                            .foregroundStyle(.secondary)
                    }
                }
                .padding()
                .multilineTextAlignment(.center)
            }
            .padding()
            // .frame(width: 450, height: 250)
            .opacity(2 - abs(offset.width/50.0))
            .animation(.bouncy, value: offset)
            .rotationEffect(.degrees(offset.width/5.0))
            .offset(x: offset.width * 5)
            .onTapGesture {
                withAnimation {
                    isShowingAnswer.toggle()
                }
            }
            .highPriorityGesture(
                DragGesture()
                    .onChanged { drag in
                        offset = drag.translation
                    }
                    .onEnded { _ in
                        if offset.width > 100 {
                            //remove from stack
                            stack.popLast()
                        }
                        else if offset.width < -100 {
                            //move to back of stack
                            stack.moveToBottom(card)
                            offset = .zero // <- reset offset after moving card to bottom of stack
                        }
                        else {
                            withAnimation {
                                offset = .zero
                            }
                        }
                    }
            )
        }
    }
    
    #Preview {
        CardView(stack: CardStack(), card: .example)
    }