SwiftUI - ForEach 删除转换始终仅应用于最后一项

SwiftUI - ForEach deletion transition always applied to last item only

我正在尝试向我的 ForEach 添加一个删除动画,以便其中的每个 Card 在删除时都向外扩展。这是我目前所拥有的:

问题是无论按下哪个Card总是最后一个激活。有时,每张卡片中的文字都有一个奇怪的 sliding/morphing 动画。这是我的代码:

/// Ran into this problem: "SwiftUI ForEach index out of range error when removing row"
/// `ObservableObject` solution from 
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)
    }
}

如何横向扩展单击的 Card 而不是最后一个?

您看到此行为的原因是因为您将索引用作 ForEachid。因此,当从 cards 数组中删除一个元素时,ForEach 看到的唯一区别是最后一个索引消失了。

您需要确保 id 唯一标识 ForEach 的每个元素。

如果您必须使用索引并标识每个元素,您可以使用 enumerated 方法或 zip 数组及其索引一起使用。我喜欢后者:

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

上面使用对象本身作为ID,需要符合Hashable。如果你不想这样,你可以直接使用id 属性:

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

为完整起见,这里是 enumerated 版本(从技术上讲,它不是索引,而是偏移量,但对于基于 0 的数组,它是相同的):

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

很棒,但我还有其他需要的东西。在我的完整代码中,每个 Card 内都有一个按钮,可将 ScrollView 滚动到末尾。

/// 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)
        }
    }
}

使用上面的代码,“滚动到结束”按钮不起作用。

我通过为每个 CardView 分配一个明确的 ID 来解决这个问题。

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)
}

结果:

我建议您重新考虑并使用 Card 作为 struct 而不是 class 并确认 Identifiable and Equatable.

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

然后创建一个包含卡片的视图模型。

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

迭代 cardViewModel.cards 并将 card 传递给 CardView。使用 Array 的 removeAll 方法代替 remove。这是安全的,因为 Card 是唯一的。

ForEach(viewModel.cards) { card in
   CardView(card: card) {
        withAnimation(.easeOut) {
          cardViewModel.cards.removeAll { [=12=] == card}
       }
   }       
}

一个完整的工作示例。

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 { [=13=] == 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)
    }
}





如果出于某种原因您需要 ContentView 中的卡片索引,请执行此操作。

都和苹果的这个教程差不多

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