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

SwiftUI记忆游戏开发:如何实现卡牌交换的位移动画效果

SwiftUI记忆卡牌游戏卡牌交换动画实现问题

问题描述

我正在开发一款记忆类卡牌游戏,游戏流程如下:首个环节中所有卡牌会翻面展示,两张匹配的卡牌会高亮显示,随后所有卡牌翻回背面,随机选中的卡牌会交换位置。目前我可以通过array.swap、array.shuffle函数实现卡牌数值的交换,也可以正常实现卡牌翻转动画,但无法实现卡牌本身交换位置的动画效果。我还有多个配套类代码,无法全部上传,若需要更多细节可告知。

演示图

现有代码

struct findTheCardsFourCards: View {
    @Namespace var cardAnimation: Namespace.ID
    @State var flipped = false
    @State var animate3d = false
    @State var rememberTheCard = false
    @State var timeRemaining = 15
    @State var selectYourCardsText = false
    @State var shuffleCards = false
    let showCardsFirst = 15
    let startGame = 11
    let selectYourCards = 5
    
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    @EnvironmentObject var game: CardModel
    var rowCount = 2
    var colCount = 2
    
    var body: some View {
        ZStack{
            VStack{
                ForEach(0 ..< game.cardInGame.count/rowCount){row in
                    HStack{
                        ForEach(0 ..< colCount){col in
                            CardView(card: game.cardInGame[row * rowCount + col], cardAnimation: cardAnimation, flipped: flipped, animate3d: animate3d, action: {game.cardInGame.swapAt(2, 3)})
                                .shadow(color: Color.purple, radius: highlightCards(card: game.cardInGame[row*rowCount+col]))
                                .animation(.linear)
                                .onReceive(timer, perform: { _ in
                                    switch timeRemaining{
                                    case 14:
                                        flipped.toggle()
                                    case 8:
                                        game.cardInGame.swapAt(1,2)
                                        
                                    default: break
                                           
                                    }
                                        
                                    /*
                                    if timeRemaining > startGame {
                                        game.cardInGame[row * rowCount + col].cardSelected = true
                                        
                                    }
                                    else if timeRemaining == startGame - 1{
                                        game.cardInGame[row * rowCount + col].cardSelected = false
                                    }
                                    if timeRemaining == startGame {
                                        shuffleCards = true
                                        game.cardInGame.shuffle()
                                            
                                    }*/
                                })
                            
                        }
                    }
                }
            }
            HStack{
                Text("REMEMBER THE CARDS")
                   
            }
            .opacity(rememberTheCard ? 1.0:0.0)
            HStack{
                Text("SELECT YOUR CARDS")
            }
            .opacity(selectYourCardsText ? 1.0 : 0.0)
            //.layoutPriority(rememberTheCard ? 1.0:0.0)
            
        }
        .onReceive(timer, perform: { _ in
            switch timeRemaining{
            case 13:
                rememberTheCard = true
                game.flipGame.toggle()
            case 10:
                rememberTheCard = false
                game.flipGame.toggle()
            case 8:
                    game.cardInGame.swapAt(1, 3)
            case 6:
                selectYourCardsText = true
            case 3:
                selectYourCardsText = false
            
            default:
                break
            }
            /*if timeRemaining == showCardsFirst {
                rememberTheCard = true
                game.flipGame.toggle()
            }
            if timeRemaining == startGame {
                rememberTheCard = false
                game.flipGame.toggle()
            }
            if timeRemaining == startGame - 2 {
                selectYourCardsText = true
            }
            if timeRemaining == selectYourCards{
                selectYourCardsText = false
            }*/
            timeRemaining -= 1
        })
}

  func highlightCards(card: CardItem) -> CGFloat {
        //if game.cardInGame[row*rowCount+col].winningCard == true{
        if card.winningCard == true {
            if game.flipGame == true{
                return 10}}
        if card.cardSelected == true{
            return 10}
        return 0

    }
}

struct findTheCardsFourCards_Previews: PreviewProvider {
    static var previews: some View {
        findTheCardsFourCards()
            .environmentObject(testCardModel)
            .previewDevice("iPad Pro (9.7-inch)")
    }
}

    struct ContentView: View {
    @EnvironmentObject var game: CardModel
    @State private var timeRemaining = 5
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    var body: some View {
        
        VStack{
            findTheCardsFourCards()
            HStack{
                    
                Button(action: {game.flipGame.toggle()

                } ) {
                    
                    Text("Start Game")
                    
                }
            }
            .padding()
            .padding()
            .background(Color.green)
            .cornerRadius(15)
            .padding()
            //.onReceive(timer){ time in
                //if self.timeRemaining == 0 {
                  //      self.timeRemaining -= 1
                    //}
                //else{
                  //  game.flipGame.toggle()
               // }
                //}
        
        }
    }
}

解决方案

问题核心是当前ForEach遍历的是索引而非可识别的卡牌元素,同时没有正确触发matchedGeometryEffect的位置过渡动画,可按以下步骤修改:

  1. 首先确保你的CardItem结构体实现Identifiable协议,每个卡牌拥有唯一的id属性
  2. 将原来按索引遍历的ForEach替换为按卡牌id遍历,让SwiftUI可以识别单个卡牌的位置变化
  3. 给每个CardView添加.matchedGeometryEffect(id: card.id, in: cardAnimation)修饰符,绑定你已经定义的命名空间
  4. 所有数组交换、打乱操作都要包裹在withAnimation闭包中,触发系统动画

修改后核心代码段

// 遍历部分替换为
ForEach(game.cardInGame) { card in
    CardView(card: card, cardAnimation: cardAnimation, flipped: flipped, animate3d: animate3d, action: {
        withAnimation(.linear(duration: 0.5)) {
            game.cardInGame.swapAt(2, 3)
        }
    })
    .matchedGeometryEffect(id: card.id, in: cardAnimation)
    .shadow(color: Color.purple, radius: highlightCards(card: card))
    .onReceive(timer, perform: { _ in
        switch timeRemaining{
        case 14:
            withAnimation {
                flipped.toggle()
            }
        case 8:
            withAnimation(.linear(duration: 0.8)) {
                game.cardInGame.swapAt(1,2)
            }
        default: break
        }
    })
}
// 外部的swap操作也需要包裹withAnimation
case 8:
    withAnimation(.linear(duration: 0.8)) {
        game.cardInGame.swapAt(1, 3)
    }

如果需要保留网格布局,遍历之后再按行列分组即可,核心是保持每个卡牌的唯一id和matchedGeometryEffect绑定,所有会改变卡牌顺序的操作都放在withAnimation中触发动画。

内容的提问来源于stack exchange,提问作者Alemastr

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.29 00:24:04