'Dealing cards from a deck - MatchedGeometryEffect - Multiple Inserted Views

I'm trying to deal groups of cards from a deck, and have the cards fly from the deck to their respective spots on screen (Stanford 2021 IOS class SET game). I'm tracking the status of each card, including whether it has been dealt or not. I thought I had all the logic correct, but the animation using a matchedGeometryEffect is not working. Both in the canvas view and when running the application in the simulator, the cards just go to their final spots in the overall View, and do not animate individually.

In an earlier version, I was seeing multiple debugger messages about "Multiple inserted views in matched geometry group Pair<Int, ID> have 'isSource: true'", but those messages no longer appear. With this slimmed-down version, the cards simply fail to animate between their original positions (in the card deck) and their final positions in the VGrid.

This codes uses a modified LazyVGrid to display the cards, with a control called AspectVGrid. The primary difference between the original LazyVGrid and the AspectVGrid is that the AspectVGrid creates a LazyVGrid with a fixed aspect ratio. The AspectVGrid only controls the grid layout itself. The code for that struct is included below.

When the application starts, all the cards are available in the deck, and each card's view is assigned its source matchedGeometryEffect. The cards are all marked as undealt (in a @State Set of card IDs). When the deck is tapped (onTapGesture), the model updates either twelve or three additional cards as wasDealt, and those newly dealt cards are supposed to be animated via a "withAnimation" code block, with individual Views in the AspectVGrid as their destinations.

Any suggestions on how to resolve this would be welcome. This seems like a pretty straightforward process, but I am clearly missing something in my implementation.

Thanks in advance for any ideas on this. I've included the model, View Model, and Views below.

Model

import Foundation
import SwiftUI
struct dataModel {
    struct Card: Identifiable {
        var wasDealt: Bool
        var wasDiscarded: Bool
        var dealDelay: Double
        let id: Int
    }
    
    private(set) var firstCardWasDealt: Bool = false
    private(set) var cards: Array<Card>
    
    var cardsDealt: Array<Card> {
        get {cards.filter({ card in card.wasDealt})}
    }
    
    private var numberOfCardsInPlay: Int {
        get { cardsDealt.count }
    }
    
    var cardsDisplayed: Array<Card> {
        get {cardsDealt.filter({ card in !card.wasDiscarded })}
    }
    
    var cardsInDeck: Array<Card> {
        get {cards.filter({ card in !card.wasDealt })}
    }
    
    init() {
        cards = []
        newGame()
    }
    
    // Divides the total time to deal the cards by the number of cards to deal,
    // and provides the applicable delay to the nth (index) card in the group of
    // cards to be dealt.
    private func calcDelay(numCards: Int, index: Int, totalDelay: Double) -> Double {
        return Double(index) * (totalDelay / Double(numCards))
    }
    
    // If no cards have been dealt, deal twelve cards.  Otherwise, deal three cards.
    // When dealing the cards, apply the appropriate delay to each card being dealt.
    // If all cards are already in play, don't deal any more cards.
    mutating func dealMoreCards() {
        if !firstCardWasDealt {
            for index in (0...11) {
                cards[index].dealDelay = calcDelay(numCards: 12, index: index, totalDelay: CardConstants.total12CardDealDuration)
                cards[index].wasDealt = true
            }
            firstCardWasDealt = true
            } else {
                if numberOfCardsInPlay < cards.count {
                let startIndex = numberOfCardsInPlay
                    for index in (startIndex...(startIndex + 2)) {
                        cards[index].dealDelay = calcDelay(numCards: 3, index: index, totalDelay: CardConstants.total3CardDealDuration)
                        cards[index].wasDealt = true
                    }
            }
        }
    }

    mutating func newGame() {
        firstCardWasDealt = false
        cards = []
        for index in (0...80) {
            cards.append(Card(wasDealt: false, wasDiscarded: false, dealDelay: 0, id: index))
        }
    }
}

struct CardConstants {
    static let color = Color.red
    static let aspectRatio: CGFloat = 2/3
    static let dealAnimationDuration: Double = 0.2  // 0.5  - This value controls how long it takes to animate each card
    static let total12CardDealDuration: Double = 3.0 // this controls how long it takes to deal twelve cards (in seconds)
    static let total3CardDealDuration: Double = 0.75 // this controls how long it takes to deal three cards (in seconds)
}

View Model

import SwiftUI
class ViewModel: ObservableObject {
    @Published private var model: dataModel
    
    init() {
        model = dataModel()
    }
    
    var cardsDealt: Array<dataModel.Card> {
        model.cardsDealt
    }
    
    var cardsDisplayed: Array<dataModel.Card> {
        model.cardsDisplayed
    }
    
    var cardsInDeck: Array<dataModel.Card> {
        model.cardsInDeck
    }
    
    func choose(_ card: dataModel.Card) {
        // do something with the chosen card
    }
    
    func dealMoreCards() {
        model.dealMoreCards()
    }
}

View

import SwiftUI
struct exampleView: View {
    @ObservedObject var example:  ViewModel
    @Namespace private var dealingNamespace
    @State private var dealt = Set<Int>()
    
    private func deal(_ card: dataModel.Card) {
        dealt.insert(card.id)
    }
    
    private func isUnDealt(_ card: dataModel.Card) -> Bool {
        !dealt.contains(card.id)
    }
    
    var body: some View {
        VStack {
            Text("Example - \(example.cardsDisplayed.count) cards Displayed")
            AspectVGrid(items: example.cardsDisplayed, aspectRatio: 2/3, content: { card in CardView(card: card)
                    .padding(4)
                    .matchedGeometryEffect(id: card.id, in: dealingNamespace)
                    .transition(AnyTransition.asymmetric(insertion: .opacity, removal: .opacity))
                    .onTapGesture {
                        example.choose(card)
                    }
            })
            HStack {
                deckBody
                Spacer()
            }
        }
    }
    
    var deckBody: some View {
        ZStack {
            ForEach(example.cardsInDeck) {
                card in CardView(card: card)
                    .matchedGeometryEffect(id: card.id, in: dealingNamespace)
                    .transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity))
            }
        }
        .frame(width: 60, height: 90)
        .foregroundColor(.cyan)
        .onTapGesture {
            example.dealMoreCards()  // Make the next group of cards available (12 or 3)
            // deal cards
            for card in example.cardsDealt {
                if isUnDealt(card) {
                    withAnimation(Animation.easeInOut(duration: CardConstants.dealAnimationDuration).delay(card.dealDelay)) {
                        deal(card)
                    }
                }
            }
        }
    }
}

struct CardView: View {
    let card: dataModel.Card
    
    var body: some View {
        VStack {
            ZStack {
                let shape = RoundedRectangle(cornerRadius: 5)
                if card.wasDealt {
                    shape.fill().foregroundColor(.white)
                } else {
                    shape.fill().foregroundColor(.cyan)
                }
                shape.strokeBorder(lineWidth: 3)
                Text("Card: \(card.id)")
                    .padding(4)
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let example = ViewModel()
        exampleView(example: example)
    }
}

AspectVGrid

//
//  AspectVGrid.swift
//
//  Created by CS193p Instructor on 4/14/21.
//  Copyright Stanford University 2021
//

import SwiftUI

struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
    var items: [Item]
    var aspectRatio: CGFloat
    var 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
            VStack {
                let width: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
                LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
                    ForEach(items) { item in
                        content(item).aspectRatio(aspectRatio, contentMode: .fit)
                    }
                }
                Spacer(minLength: 0)
            }
        }
    }
    
    private func adaptiveGridItem(width: CGFloat) -> GridItem {
        var gridItem = GridItem(.adaptive(minimum: width))
        gridItem.spacing = 0
        return gridItem
    }
    
    private func widthThatFits(itemCount: Int, in size: CGSize, itemAspectRatio: CGFloat) -> CGFloat {
        var columnCount = 1
        var rowCount = itemCount
        repeat {
            let itemWidth = size.width / CGFloat(columnCount)
            let itemHeight = itemWidth / itemAspectRatio
            if  CGFloat(rowCount) * itemHeight < size.height {
                break
            }
            columnCount += 1
            rowCount = (itemCount + (columnCount - 1)) / columnCount
        } while columnCount < itemCount
        if columnCount > itemCount {
            columnCount = itemCount
        }
        return floor(size.width / CGFloat(columnCount))
    }
}

MatchedGeometryEffectApp

import SwiftUI

@main
struct MatchedGeometryEffectApp: App {
    private let example = ViewModel()
    
    var body: some Scene {
        WindowGroup {
            exampleView(example: example)
        }
    }
}


Solution 1:[1]

So, the problem you have been having with the matched geometry seems to be that AspectVGrid, which you were given as part of the assignment, interferes with the matched geometry animation. I suspect it has to do with the resizing mechanism. Removing that gives you a matched geometry that does what one would expect.

Here is a working example of the matched geometry in your ExampleView. I also rewrote CardView to make it simpler.

struct ExampleView: View {
    @ObservedObject var example:  ViewModel
    @Namespace private var dealingNamespace
    @State private var dealt = Set<Int>()
    
    private func deal(_ card: DataModel.Card) {
        dealt.insert(card.id)
    }
    
    private func isUnDealt(_ card: DataModel.Card) -> Bool {
        !dealt.contains(card.id)
    }
    
    let columns = [
        GridItem(.adaptive(minimum: 80))
    ]
    
    var body: some View {
        VStack {
            VStack {
                Text("Example - \(example.cardsDisplayed.count) cards Displayed")
                LazyVGrid(columns: columns, spacing: 10) {
                    ForEach(example.cardsDisplayed) { card in
                        CardView(card: card)
                            .aspectRatio(2/3, contentMode: .fit)
                            .matchedGeometryEffect(id: card.id, in: dealingNamespace)
                            .onTapGesture {
                                example.choose(card)
                            }
                            .padding(4)
                    }
                }
                Spacer()
                HStack {
                    deckBody
                    Spacer()
                }
            }
        }
    }
    
    var deckBody: some View {
        ZStack {
            ForEach(example.cardsInDeck) { card in
                CardView(card: card)
                    .matchedGeometryEffect(id: card.id, in: dealingNamespace)
            }
        }
        .frame(width: 60, height: 90)
        .foregroundColor(.cyan)
        .onTapGesture {
            withAnimation {
                example.dealMoreCards()  // Make the next group of cards available (12 or 3)
                // deal cards
                for card in example.cardsDealt {
                    if isUnDealt(card) {
                        deal(card)
                    }
                }
            }
        }
    }
}

struct CardView: View {
    let card: DataModel.Card
    
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 5)
                .fill()
                .foregroundColor(card.wasDealt ? .white : .cyan)
            RoundedRectangle(cornerRadius: 5)
                .strokeBorder(lineWidth: 3)
            Text("Card: \(card.id)")
                .padding(4)
        }
    }
}

A quick note about naming conventions. Types and structs should all be capitalized, so ExampleView and DataModel would be convention, and I changed the code to reflect that. You will need to fix yours when you incorporate this code.

Also note that this view isn't perfect. Eventually, it will push the deck of cards off the bottom of the screen, but that can be handled.

Your attempt to make the cards fly one at a time doesn't work. First, the withAnimation has to capture example.dealMoreCards() and trying to implement a .delay in that situation doesn't work. It can be implemented with a DispatchQueue.main.asyncAfter(deadline:), but you would have to rework how you pass the timing as the individual cards won't be available yet.

Lastly, the transitions do nothing in this situation.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Yrb