Swift @State property changes itself at some point?

Viewed 83

I'm currently going through the Standford iOS development lectures (linked roughly where I'm at, with mostly relevant code), while slightly straying slightly from what he's doing to learn a bit independently. Still most of the code there is relevant to my question.

Right now I'm trying to get the cards to animate in by showing/hiding them depending on if their ID is in a Set, dealt. In the .onAppear for the content View, I iterate over the cards, and add each of them to the dealt Set. Then, I only display them if isUndealt(card)

import SwiftUI

struct EmojiMemoryGameView: View {
    
    @ObservedObject var game: EmojiMemoryGame
  
    var body: some View {
        NavigationView {
            content
                .navigationTitle(game.themeName)
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItemGroup(placement: .bottomBar) {
                        Text("Score: \(game.score)")
                            .font(.title3)
                            .fontWeight(.semibold)
                        Spacer()
                        shuffleButton
                    }
                }
        }
        .navigationViewStyle(.stack)
    }
    
    @State private var dealt = Set<String>()
  
    private func deal(_ card: EmojiMemoryGame.Card) {
        print("Before insert: \(dealt)")
        print("Inserting \(card.id)")
        dealt.insert(card.id)
        let bah = dealt
        print("After insert: \(dealt)")
    }
    
    private func isUndealt(_ card: EmojiMemoryGame.Card) -> Bool {
        print("dealt: \(dealt)")
        let bah = dealt
        return !dealt.contains(card.id)
    }
    
    var content: some View {
        VStack {
            themeSelector.frame(maxWidth: .infinity)
            AspectVGrid(items: game.cards, aspectRatio: 2/3) { card in
                if isUndealt(card) || (card.isMatched && !card.isFaceUp) {
                    Color.clear
                } else {
                    CardView(card: card)
                        .padding(4)
                        .transition(AnyTransition.asymmetric(insertion: .scale, removal: .opacity))
                        .onTapGesture {
                            withAnimation {
                                game.choose(card)
                            }
                        }
                }
            }
            .onAppear {
                withAnimation {
                    for card in game.cards {
                        deal(card)
                    }
                }
            }
            .foregroundColor(game.themeColor)
            .padding(.horizontal)
        }
    }

...

You'll notice I added logging to deal() and isUndealt() to show when dealt is modified, and what its current contents are. Here's what the output looks like on a sample run (shortened all the inserts to save space):

Before insert: []
Inserting 4AE58783-5F32-4359-8E88-300D73F1AA89
After insert: ["4AE58783-5F32-4359-8E88-300D73F1AA89"]
Before insert: ["4AE58783-5F32-4359-8E88-300D73F1AA89"]
Inserting B82B98B1-47AC-4836-BE01-05FEFAC083E5
After insert: ["4AE58783-5F32-4359-8E88-300D73F1AA89", "B82B98B1-47AC-4836-BE01-05FEFAC083E5"]
Before insert: ["4AE58783-5F32-4359-8E88-300D73F1AA89", "B82B98B1-47AC-4836-BE01-05FEFAC083E5"]
Inserting B7A90B6E-C3BC-4F06-92D8-98B7DCAEFA6A
After insert: ["B7A90B6E-C3BC-4F06-92D8-98B7DCAEFA6A", "4AE58783-5F32-4359-8E88-300D73F1AA89", "B82B98B1-47AC-4836-BE01-05FEFAC083E5"]
Before insert: ["B7A90B6E-C3BC-4F06-92D8-98B7DCAEFA6A", "4AE58783-5F32-4359-8E88-300D73F1AA89", "B82B98B1-47AC-4836-BE01-05FEFAC083E5"]
Inserting 3A921774-3CDD-4A0F-85FB-FC1B4089CA8E
After insert: ["4AE58783-5F32-4359-8E88-300D73F1AA89", "B82B98B1-47AC-4836-BE01-05FEFAC083E5", "B7A90B6E-C3BC-4F06-92D8-98B7DCAEFA6A", "3A921774-3CDD-4A0F-85FB-FC1B4089CA8E"]
Before insert: ["4AE58783-5F32-4359-8E88-300D73F1AA89", "B82B98B1-47AC-4836-BE01-05FEFAC083E5", "B7A90B6E-C3BC-4F06-92D8-98B7DCAEFA6A", "3A921774-3CDD-4A0F-85FB-FC1B4089CA8E"]
Inserting 8A9FA6C2-AC77-4C46-9A8D-C21992FD4D75
After insert: ["4AE58783-5F32-4359-8E88-300D73F1AA89", "B82B98B1-47AC-4836-BE01-05FEFAC083E5", "B7A90B6E-C3BC-4F06-92D8-98B7DCAEFA6A", "8A9FA6C2-AC77-4C46-9A8D-C21992FD4D75", "3A921774-3CDD-4A0F-85FB-FC1B4089CA8E"]

...

dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []

As you can see, as I'm adding to the Set from deal(), it's properly adding the IDs as I would expect. However, later when I check dealt from isDealt(), it shows that it's empty every time.

The odd thing is, if I replace the body with what the instructor uses in the tutorial (getting rid of the NavigationView)

    var body: some View {
        VStack {
            content
            shuffleButton
        }
        .padding()
    }

Suddenly the animation works, with the output as such:

dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
dealt: []
Before insert: []
Inserting 5012E432-9972-4932-8444-1408FEA3188B
After insert: ["5012E432-9972-4932-8444-1408FEA3188B"]
Before insert: ["5012E432-9972-4932-8444-1408FEA3188B"]
Inserting 29CD9E52-83A1-49CF-A280-7CEE9FDB13D3
After insert: ["5012E432-9972-4932-8444-1408FEA3188B", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
Before insert: ["5012E432-9972-4932-8444-1408FEA3188B", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
Inserting 48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E
After insert: ["48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "5012E432-9972-4932-8444-1408FEA3188B", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]

...

dealt: ["6D182BF3-DDAF-4487-A83E-4939A7AC452B", "5012E432-9972-4932-8444-1408FEA3188B", "B16870E9-71A8-46B0-AE73-D9E292EEFCB0", "96CD9D45-5EE6-4AD3-948D-0176A85BA11F", "48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "B998EB7A-9B09-44B8-A64F-D6DE62B7DCBE", "59637C10-E9F1-4A61-8CA2-A1796CC48523", "E6F26A49-8DC8-4926-95EE-DF9ACAB739FE", "B2BD590E-FB65-4101-A07A-616465A7AAAF", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
dealt: ["6D182BF3-DDAF-4487-A83E-4939A7AC452B", "5012E432-9972-4932-8444-1408FEA3188B", "B16870E9-71A8-46B0-AE73-D9E292EEFCB0", "96CD9D45-5EE6-4AD3-948D-0176A85BA11F", "48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "B998EB7A-9B09-44B8-A64F-D6DE62B7DCBE", "59637C10-E9F1-4A61-8CA2-A1796CC48523", "E6F26A49-8DC8-4926-95EE-DF9ACAB739FE", "B2BD590E-FB65-4101-A07A-616465A7AAAF", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
dealt: ["6D182BF3-DDAF-4487-A83E-4939A7AC452B", "5012E432-9972-4932-8444-1408FEA3188B", "B16870E9-71A8-46B0-AE73-D9E292EEFCB0", "96CD9D45-5EE6-4AD3-948D-0176A85BA11F", "48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "B998EB7A-9B09-44B8-A64F-D6DE62B7DCBE", "59637C10-E9F1-4A61-8CA2-A1796CC48523", "E6F26A49-8DC8-4926-95EE-DF9ACAB739FE", "B2BD590E-FB65-4101-A07A-616465A7AAAF", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
dealt: ["6D182BF3-DDAF-4487-A83E-4939A7AC452B", "5012E432-9972-4932-8444-1408FEA3188B", "B16870E9-71A8-46B0-AE73-D9E292EEFCB0", "96CD9D45-5EE6-4AD3-948D-0176A85BA11F", "48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "B998EB7A-9B09-44B8-A64F-D6DE62B7DCBE", "59637C10-E9F1-4A61-8CA2-A1796CC48523", "E6F26A49-8DC8-4926-95EE-DF9ACAB739FE", "B2BD590E-FB65-4101-A07A-616465A7AAAF", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
dealt: ["6D182BF3-DDAF-4487-A83E-4939A7AC452B", "5012E432-9972-4932-8444-1408FEA3188B", "B16870E9-71A8-46B0-AE73-D9E292EEFCB0", "96CD9D45-5EE6-4AD3-948D-0176A85BA11F", "48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "B998EB7A-9B09-44B8-A64F-D6DE62B7DCBE", "59637C10-E9F1-4A61-8CA2-A1796CC48523", "E6F26A49-8DC8-4926-95EE-DF9ACAB739FE", "B2BD590E-FB65-4101-A07A-616465A7AAAF", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
dealt: ["6D182BF3-DDAF-4487-A83E-4939A7AC452B", "5012E432-9972-4932-8444-1408FEA3188B", "B16870E9-71A8-46B0-AE73-D9E292EEFCB0", "96CD9D45-5EE6-4AD3-948D-0176A85BA11F", "48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "B998EB7A-9B09-44B8-A64F-D6DE62B7DCBE", "59637C10-E9F1-4A61-8CA2-A1796CC48523", "E6F26A49-8DC8-4926-95EE-DF9ACAB739FE", "B2BD590E-FB65-4101-A07A-616465A7AAAF", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
dealt: ["6D182BF3-DDAF-4487-A83E-4939A7AC452B", "5012E432-9972-4932-8444-1408FEA3188B", "B16870E9-71A8-46B0-AE73-D9E292EEFCB0", "96CD9D45-5EE6-4AD3-948D-0176A85BA11F", "48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "B998EB7A-9B09-44B8-A64F-D6DE62B7DCBE", "59637C10-E9F1-4A61-8CA2-A1796CC48523", "E6F26A49-8DC8-4926-95EE-DF9ACAB739FE", "B2BD590E-FB65-4101-A07A-616465A7AAAF", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
dealt: ["6D182BF3-DDAF-4487-A83E-4939A7AC452B", "5012E432-9972-4932-8444-1408FEA3188B", "B16870E9-71A8-46B0-AE73-D9E292EEFCB0", "96CD9D45-5EE6-4AD3-948D-0176A85BA11F", "48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "B998EB7A-9B09-44B8-A64F-D6DE62B7DCBE", "59637C10-E9F1-4A61-8CA2-A1796CC48523", "E6F26A49-8DC8-4926-95EE-DF9ACAB739FE", "B2BD590E-FB65-4101-A07A-616465A7AAAF", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
dealt: ["6D182BF3-DDAF-4487-A83E-4939A7AC452B", "5012E432-9972-4932-8444-1408FEA3188B", "B16870E9-71A8-46B0-AE73-D9E292EEFCB0", "96CD9D45-5EE6-4AD3-948D-0176A85BA11F", "48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "B998EB7A-9B09-44B8-A64F-D6DE62B7DCBE", "59637C10-E9F1-4A61-8CA2-A1796CC48523", "E6F26A49-8DC8-4926-95EE-DF9ACAB739FE", "B2BD590E-FB65-4101-A07A-616465A7AAAF", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]
dealt: ["6D182BF3-DDAF-4487-A83E-4939A7AC452B", "5012E432-9972-4932-8444-1408FEA3188B", "B16870E9-71A8-46B0-AE73-D9E292EEFCB0", "96CD9D45-5EE6-4AD3-948D-0176A85BA11F", "48F40FE1-8E4C-4DED-BE2C-35EED7FBD56E", "B998EB7A-9B09-44B8-A64F-D6DE62B7DCBE", "59637C10-E9F1-4A61-8CA2-A1796CC48523", "E6F26A49-8DC8-4926-95EE-DF9ACAB739FE", "B2BD590E-FB65-4101-A07A-616465A7AAAF", "29CD9E52-83A1-49CF-A280-7CEE9FDB13D3"]

Perhaps my question title isn't even the correct thing to be asking, but I'm very confused as to what's going on here. Why is an outer view container changing how my @State works? What's going wrong in the first version of the code?

1 Answers

If you change .onAppear { ... } to .task { ... }, it works.

.task, in case the course hasn't covered it yet, allows for asynchronous code execution, tied to the View's lifecycle. Apple's documentation for it is here.

Now, this definitely seems like a hack. What I'm not completely sure about is whether it is a hack to circumvent a SwiftUI bug, or to patch edge-case-y, yet correct, behavior given the combination of a Lazy view at the top level of a NavigationView (rather than pushed via a NavigationLink).

NavigationView is notorious for slightly counterintuitive behavior. One such behavior is the preloading of the destination of NavigationLinks. But firstly, you aren't using NavigationLinks, and secondly, your content is in a Lazy view, which has special behavior for when and how it inits its children. Which could in turn affect when the container's .onAppear is called (but your Color.clear should get around that ... theoretically). You see that I'm grasping at straws here, given that this is an intersection of two views, the recent pathologies of which I haven't kept an eye on. Especially in this combination.

On top of all this, there are some anti-patterns throughout the code that could affect this already somewhat tricky setup. I'm guessing that the course is going down this path to make points along the way and build up knowledge gradually, but I honestly mildly disagree with some stuff even in the guise of pedagogic exposition. That said, hopefully it will all fall into place as you go along. In the meantime, I hope that the .task solution above might allow you to explore a bit more from your current progress.

PS: If you absolutely want to avoid .task, you can also move .onAppear onto any ToolbarItem, e.g. shuffleButton.

Related