SwiftUI卡片从堆叠中心飞至目标位置的轨迹过渡动画问题
SwiftUI实现卡片从堆叠中心飞至目标位置的过渡动画
要实现卡片从堆叠中心出发、平滑飞至最终位置的动画,内置的transition(.move(edge: .bottom))无法满足需求——它是从边缘而非指定中心位置开始过渡,导致动画生硬。下面是具体的实现方案:
核心思路
- 捕获堆叠中心坐标:用
GeometryReader获取卡片堆叠视图(CardsDeckView)的全局位置,计算出中心坐标作为动画起点。 - 自定义过渡动画:通过
AnyTransition.asymmetric定义插入动画,让卡片从堆叠中心的偏移位置移动到目标位置,搭配缩放动画增强自然感。 - 优化状态管理:把分散的
firstCardShown等布尔值改成数组管理卡片状态,简化逻辑同时支持后续扩展。
完整代码示例
struct CardFlyAnimationView: View { @StateObject private var viewModel = CardViewModel() @State private var deckCenter: CGPoint = .zero private let cardWidth: CGFloat = 120 // 根据实际需求调整 var body: some View { VStack { HStack(spacing: 20) { ForEach(viewModel.shownCards) { card in RotatableCardView( cardLayout: card.layout, width: cardWidth - 20, isFlipped: binding(for: card.id) ) // 应用自定义飞行动画过渡 .transition(cardFlyTransition(from: deckCenter)) } } .padding(.top, 40) .containerRelativeFrame(.vertical) { height, _ in height / 2 } // 捕获堆叠视图的中心位置 GeometryReader { proxy in CardsDeckView() .onTapGesture { withAnimation(.easeInOut(duration: 0.6)) { viewModel.showNextCard() } } .onAppear { // 计算堆叠视图的全局中心坐标 let frame = proxy.frame(in: .global) deckCenter = CGPoint(x: frame.midX, y: frame.midY) } } .containerRelativeFrame(.vertical) { height, _ in height / 2 } } } // 自定义飞行动画过渡 private func cardFlyTransition(from startPoint: CGPoint) -> AnyTransition { // 计算当前卡片到堆叠中心的偏移量(反向计算,实现从中心飞至目标位置) let cardSize = CGSize(width: cardWidth - 20, height: (cardWidth - 20) * 1.4) // 按实际卡片宽高比调整 let targetMidX = UIScreen.main.bounds.width / 2 - (cardSize.width + 20) * CGFloat(viewModel.shownCards.count - 1) / 2 let offsetX = startPoint.x - (targetMidX + cardSize.width / 2) let offsetY = startPoint.y - cardSize.height / 2 return .asymmetric( insertion: .offset(x: offsetX, y: offsetY) .combined(with: .scale(scale: 0.8)) .animation(.easeInOut(duration: 0.6)), removal: .identity // 移除动画可按需自定义 ) } // 为每个卡片创建翻转状态绑定 private func binding(for cardId: UUID) -> Binding<Bool> { .init( get: { viewModel.shownCards.first(where: { $0.id == cardId })?.isFlipped ?? false }, set: { newValue in if let index = viewModel.shownCards.firstIndex(where: { $0.id == cardId }) { viewModel.shownCards[index].isFlipped = newValue } } ) } } // 优化后的ViewModel,用数组管理卡片状态 class CardViewModel: ObservableObject { struct CardItem: Identifiable { let id = UUID() let layout: CardLayout // 替换为你的实际CardLayout类型 var isFlipped = false } @Published var shownCards: [CardItem] = [] private let allCards: [CardItem] = [ CardItem(layout: CardLayout()), // 替换为你的实际布局数据 CardItem(layout: CardLayout()), CardItem(layout: CardLayout()) ] func showNextCard() { guard shownCards.count < allCards.count else { return } shownCards.append(allCards[shownCards.count]) } } // 以下为占位视图,替换为你的实际实现 struct RotatableCardView: View { let cardLayout: CardLayout let width: CGFloat @Binding var isFlipped: Bool var body: some View { RoundedRectangle(cornerRadius: 10) .fill(Color.white) .frame(width: width, height: width * 1.4) .shadow(radius: 5) } } struct CardsDeckView: View { var body: some View { RoundedRectangle(cornerRadius: 10) .fill(Color.gray.opacity(0.3)) .frame(width: 100, height: 140) } } struct CardLayout {} // 占位类型,替换为你的实际布局模型
关键细节说明
- 堆叠位置捕获:在
CardsDeckView出现时,通过GeometryReader计算其全局中心坐标,确保动画起点准确。 - 自定义过渡:
asymmetric过渡仅定义插入动画,让卡片从堆叠中心偏移位置移动到目标位置,配合scale(0.8)模拟卡片“从堆叠中飞出、逐渐放大”的自然效果。 - 状态管理优化:用数组替代单独的布尔值,简化视图代码的同时,后续新增卡片无需修改视图结构。
内容的提问来源于stack exchange,提问作者Igor
相关产品推荐
相关产品推荐

