You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI卡片堆叠转网格动画异常:点击后堆叠消失,卡片从屏外飞入

问题解决:卡片堆叠到网格的动画异常修复

问题根源

  1. 卡片实例不唯一:你的testDeck是计算属性,每次访问都会生成全新的TestCard对象。matchedGeometryEffect依赖视图实例的一致性来追踪位置,不同实例会被当成完全不同的视图,导致动画变成“删除旧堆叠+插入新网格卡片”,而非平滑移动。
  2. 堆叠无固定尺寸:卡片堆叠的ZStack没有固定宽高,当卡片被移走时,堆叠会收缩,破坏了动画的起点位置,剩下的卡片会直接消失。
  3. 视图标识冲突: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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 01:40:03