1

I'm trying to add a delete animation to my ForEach, so that each Card inside it scales out when removed. This is what I have so far:

The problem is that no matter which Card is pressed, it's always the last one that animates. And sometimes, the text inside each card has a weird sliding/morphing animation. Here's my code:

/// Ran into this problem: "SwiftUI ForEach index out of range error when removing row"
/// `ObservableObject` solution from https://stackoverflow.com/a/62796050/14351818
class Card: ObservableObject, Identifiable {
    let id = UUID()
    @Published var name: String

    init(name: String) {
        self.name = name
    }
}

struct ContentView: View {
    @State var cards = [
        Card(name: "Apple"),
        Card(name: "Banana "),
        Card(name: "Coupon"),
        Card(name: "Dog"),
        Card(name: "Eat")
    ]
    
    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                
                ForEach(cards.indices, id: \.self) { index in
                    CardView(card: cards[index], removePressed: {
                        
                        withAnimation(.easeOut) {
                            _ = cards.remove(at: index) /// remove the card
                        }
                        
                    })
                    .transition(.scale)
                }
                
            }
        }
    }
}

struct CardView: View {
    
    @ObservedObject var card: Card
    var removePressed: (() -> Void)?
    
    var body: some View {
        Button(action: {
            removePressed?() /// call the remove closure
        }) {
            VStack {
                Text("Remove")
                Text(card.name)
            }
        }
        .foregroundColor(Color.white)
        .font(.system(size: 24, weight: .medium))
        .padding(40)
        .background(Color.red)
    }
}

How can I scale out the Card that is clicked, and not the last one?

aheze
  • 24,434
  • 8
  • 68
  • 125
  • 1
    You need to make sure that your `id`s actually identify the object being deleted. Right now your `id`s that `ForEach` is using are just indices - an `Int`, in this case. So when you delete an item and the view re-renders, the only difference in the array is that the last id is gone. – New Dev Apr 04 '21 at 17:43
  • @NewDev thanks, makes sense! How would you suggest I id them? Would something like `cards.map { $0.id }` be good? – aheze Apr 04 '21 at 17:59
  • Nvm, that results in "Cannot convert value of type '[UUID]' to expected argument type 'KeyPath.Index>.Element, ID>'" – aheze Apr 04 '21 at 18:05
  • Why are you doing `ForEach` over indices here? I think you can do `ForEach(cards) { card in ...}` since each `Card` is `Identifiable` – New Dev Apr 04 '21 at 18:07
  • @NewDev the indices were so that I could do `cards.remove(at: index)` – aheze Apr 04 '21 at 18:08

3 Answers3

6

The reason you're seeing this behavior is because you use an index as an id for ForEach. So, when an element is removed from the cards array, the only difference that ForEach sees is that the last index is gone.

You need to make sure that the id uniquely identifies each element of ForEach.

If you must use indices and have each element identified, you can either use the enumerated method or zip the array and its indices together. I like the latter:

ForEach(Array(zip(cards.indices, cards)), id: \.1) { (index, card) in 
   //...
}

The above uses the object itself as the ID, which requires conformance to Hashable. If you don't want that, you can use the id property directly:

ForEach(Array(zip(cards.indices, cards)), id: \.1.id) { (index, card) in
  //...
}

For completeness, here's the enumerated version (technically, it's not an index, but rather an offset, but for 0-based arrays it's the same):

ForEach(Array(cards.enumerated()), id: \.1) { (index, card) in 
   //...
}
New Dev
  • 48,427
  • 12
  • 87
  • 129
  • Thanks! However I also get `Generic struct 'ForEach' requires that 'Card' conform to 'Hashable'` with both of the above examples. Why would this happen? – aheze Apr 04 '21 at 18:20
  • Btw once I conform to `Hashable`, it works perfectly. – aheze Apr 04 '21 at 18:36
  • However this breaks `proxy.scrollTo(cards.count - 1, anchor: .center)` when inside a `ScrollViewReader { proxy in`. I think it might be because of the `id: \.1` - could you explain what that is for? – aheze Apr 04 '21 at 19:03
  • Your first senetance is false. Aheze does not use index as id. – mahan Apr 04 '21 at 19:31
  • @aheze, `id: \.1` uses the second element from a `(index, card)` tuple - which is a `Card` - as an `id`, but you're right - it needs to conform to `Hashable`. If you don't want that, you can use the `id` property directly with `id: \.1.id` (I updated the answer) – New Dev Apr 04 '21 at 22:51
  • @mahan, why do you think it's wrong? In the original question, he's using `ForEach(cards.indices, id: \.self) { }`, i.e. iterating over an array of indices, and `id: \.self` referring to each element - an index - as the id. – New Dev Apr 04 '21 at 22:56
1

New Dev's answer was great, but I had something else that I needed. In my full code, I had a button inside each Card that scrolled the ScrollView to the end.

/// the ForEach
ForEach(Array(cards.enumerated()), id: \.1) { (index, card) in
    CardView(
        card: cards[index],
        scrollToEndPressed: {
            proxy.scrollTo(cards.count - 1, anchor: .center) /// trying to scroll to end... not working though.
        },
        removePressed: {
            
            withAnimation(.easeOut) {
                _ = cards.remove(at: index) /// remove the card
            }
            
        }
    )
    .transition(.scale)
}

/// CardView
struct CardView: View {
    
    @ObservedObject var card: Card
    var scrollToEndPressed: (() -> Void)?
    var removePressed: (() -> Void)?
    
    var body: some View {
        VStack {
            Button(action: {
                scrollToEndPressed?() /// scroll to the end
            }) {
                VStack {
                    Text("Scroll to end")
                }
            }
            
            Button(action: {
                removePressed?() /// call the remove closure
            }) {
                VStack {
                    Text("Remove")
                    Text(card.name)
                }
            }
            .foregroundColor(Color.white)
            .font(.system(size: 24, weight: .medium))
            .padding(40)
            .background(Color.red)
        }
    }
}

With the above code, the "Scroll to end" button didn't work.

"Scroll to end" above each Card, but clicking it doesn't work

I fixed this by assigning an explicit ID to each CardView.

ForEach(Array(cards.enumerated()), id: \.1) { (index, card) in
    CardView(card: cards[index], scrollToEndPressed: {
        
        withAnimation(.easeOut) { /// also animate it
            proxy.scrollTo(cards.last?.id ?? card.id, anchor: .center) /// scroll to the last card's ID
        }
        
    }, removePressed: {
        
        withAnimation(.easeOut) {
            _ = cards.remove(at: index) /// remove the card
        }

    })
    .id(card.id) /// add ID
    .transition(.scale)
}

Result:

"Scroll to end" above each Card works!

aheze
  • 24,434
  • 8
  • 68
  • 125
1

I recommend you to rethink and use Card as struct rather than a class and confirm to Identifiable and Equatable.

struct Card: Hashable, Identifiable {
    let id = UUID()
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

And then create a View Model that holds your cards.

class CardViewModel: ObservableObject {
    @Published
    var cards: [Card] = [
        Card(name: "Apple"),
        Card(name: "Banana "),
        Card(name: "Coupon"),
        Card(name: "Dog"),
        Card(name: "Eat")
    ]
}

Iterate over cardViewModel.cards and pass card to the CardView. Use removeAll method of Array instead of remove. It is safe since Cards are unique.

ForEach(viewModel.cards) { card in
   CardView(card: card) {
        withAnimation(.easeOut) {
          cardViewModel.cards.removeAll { $0 == card}
       }
   }       
}

A complete working exammple.

struct ContentView: View {
    @ObservedObject var cardViewModel = CardViewModel()
    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                
                ForEach(cardViewModel.cards) { card in
                    CardView(card: card) {
                        withAnimation(.easeOut) {
                            cardViewModel.cards.removeAll { $0 == card}
                        }
                    }
                    .transition(.scale)
                }
                
            }
        }
    }
}

struct CardView: View {
        
    var card: Card
    
    var removePressed: (() -> Void)?

    
    var body: some View {
        Button(action: {
            removePressed?()
        }) {
            VStack {
                Text("Remove")
                Text(card.name)
            }
        }
        .foregroundColor(Color.white)
        .font(.system(size: 24, weight: .medium))
        .padding(40)
        .background(Color.red)
    }
}





If for some reason you need index of card in ContentView, do this.

  1. Accessing and manipulating array item in an EnvironmentObject

  2. Removing items from a child view generated by For Each loop causes Fatal Error

Both of them are similar to this tutorial of Apple.

https://developer.apple.com/tutorials/swiftui/handling-user-input

mahan
  • 12,366
  • 5
  • 48
  • 83
  • Thanks for the answer! The current answer is already working for me, but if something comes up I'll try out yours. – aheze Apr 04 '21 at 20:10