SwiftUI卡片堆叠转网格动画异常:点击后堆叠消失,卡片从屏外飞入
问题解决:卡片堆叠到网格的动画异常修复
问题根源
- 卡片实例不唯一:你的
testDeck是计算属性,每次访问都会生成全新的TestCard对象。matchedGeometryEffect依赖视图实例的一致性来追踪位置,不同实例会被当成完全不同的视图,导致动画变成“删除旧堆叠+插入新网格卡片”,而非平滑移动。 - 堆叠无固定尺寸:卡片堆叠的
ZStack没有固定宽高,当卡片被移走时,堆叠会收缩,破坏了动画的起点位置,剩下的卡片会直接消失。 - 视图标识冲突:
TestCard中把shape作为存储属性,可能导致SwiftUI无法正确识别视图身份,干扰动画逻辑。
修改步骤 & 完整代码
import SwiftUI struct ContentView: View { var body: some View { VStack { cardStack testGrid } .padding() } //MARK: - TESTING @Namespace var testSpace struct TestCard: Identifiable, View { let id: String var body: some View { RoundedRectangle(cornerRadius: 10) .fill(Color.blue.opacity(0.8)) // 加颜色方便观察动画 .padding() } } // 改成静态常量,确保卡片实例全局唯一 static let testDeck: [TestCard] = { (0..<10).map { TestCard(id: "\($0)") } }() var testGrid: some View { AspectVGrid(Self.testDeck, aspectRatio: 5/3) { card in if isDealtTest(card) { TestCard(id: card.id) .matchedGeometryEffect(id: card.id, in: testSpace) // 移除多余transition,避免干扰matchedGeometryEffect的原生动画 } } } @State private var dealtTest = Set<TestCard.ID>() private func isDealtTest(_ card: TestCard) -> Bool { dealtTest.contains(card.id) } private var undealtCardsTest: [TestCard] { Self.testDeck.filter { !isDealtTest($0) } } private var cardStack: some View { ZStack { ForEach(undealtCardsTest) { card in TestCard(id: card.id) .matchedGeometryEffect(id: card.id, in: testSpace) // 移除多余transition } } // 固定堆叠尺寸,匹配单个卡片的宽高比(5/3) .frame(width: 150, height: 90) .onTapGesture { dealTest() } } private func dealTest() { var delay: TimeInterval = 0 for card in Self.testDeck { withAnimation(.easeInOut(duration: 1).delay(delay)) { _ = dealtTest.insert(card.id) } delay += 0.15 } } } // 补充AspectVGrid的实现(如果项目中已有可忽略) struct AspectVGrid<Item, ItemView>: View where Item: Identifiable, ItemView: View { let items: [Item] let aspectRatio: CGFloat let content: (Item) -> ItemView init(_ items: [Item], aspectRatio: CGFloat, @ViewBuilder content: @escaping (Item) -> ItemView) { self.items = items self.aspectRatio = aspectRatio self.content = content } var body: some View { GeometryReader { geometry in let width = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio) LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 10) { ForEach(items) { item in content(item) .aspectRatio(aspectRatio, contentMode: .fit) } } } } private func adaptiveGridItem(width: CGFloat) -> GridItem { var gridItem = GridItem(.adaptive(minimum: width)) gridItem.spacing = 10 return gridItem } private func widthThatFits(itemCount: Int, in size: CGSize, itemAspectRatio: CGFloat) -> CGFloat { let columnCount = Int((size.width / size.height) * itemAspectRatio).clamped(to: 1...itemCount) let availableWidth = size.width - CGFloat(columnCount - 1) * 10 // 减去间距 return availableWidth / CGFloat(columnCount) } } extension Comparable { func clamped(to limits: ClosedRange<Self>) -> Self { return min(max(self, limits.lowerBound), limits.upperBound) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
关键修改说明
- 将
testDeck改为静态常量,确保所有场景下使用的TestCard都是同一个实例,让matchedGeometryEffect能正确关联堆叠和网格中的同一张卡片。 - 给卡片堆叠的
ZStack设置固定宽高,匹配单个卡片的5/3比例,避免堆叠在卡片移除时收缩变形。 - 移除多余的
transition配置,matchedGeometryEffect本身会自动处理视图的平滑移动过渡,额外的transition会干扰动画逻辑。 - 调整
TestCard的实现,将shape移至body内,避免存储属性引发的视图标识冲突。
内容的提问来源于stack exchange,提问作者gnadel
相关产品推荐
相关产品推荐

