Search code examples
swiftforeachswiftuiscrollview

SwiftUI: ScrollView not working correctly with ForEach


I am making a card game and I'm trying to fit cards into a ScrollView. I'm doing this by iterating through an array of cards and subsequently building the view for each card with CardView.

struct ContentView: View {
    @ObservedObject var viewModel = SetGameViewModel()
    
    var body: some View {
        VStack {
            ScrollView {
                LazyVGrid(columns: [GridItem(),GridItem(),GridItem(),GridItem()]) {
                    ForEach(viewModel.cards) { card in
                        CardView(card: card)
                            //Individual card gets built depending on its data.
                            .aspectRatio(2/3, contentMode: .fill)
                        }
                }
            }
            DealThreeCardsButton()
                .onTapGesture { viewModel.dealThreeCards() }
        }
    }
}

Each card has a specific number of symbols (and several other characteristics, but those are not the issue right now.) which it should show. ->

struct CardView: View {
    let card: SetGame.Card
    private var color: Color {
        switch card.content.color {
        case .green:
            return Color(.green)
        case .purple:
            return Color(.purple)
        case .red:
            return Color(.red)
        }
    }
    
    
    var body: some View {
        if card.isFaceUp {
            ZStack{
                RoundedRectangle(cornerRadius: 20)
                    .foregroundColor(.clear)
                RoundedRectangle(cornerRadius: 20)
                    .stroke(lineWidth: 2.0)
                    .foregroundColor(/*@START_MENU_TOKEN@*/.blue/*@END_MENU_TOKEN@*/)
                VStack {
                    CardContentView(content: card.content)
                        //The contents of the cards are created
                        .foregroundColor(color)
                    Text("\(card.id), n: \(card.content.number)")
                }
            }
        } else {
            RoundedRectangle(cornerRadius: 20)
                .foregroundColor(.blue)
        }
    }
}


private struct CardContentView: View {
    let content: SetGame.CardContent
    var body: some View {
        ForEach(1..<content.number+1) { _ in
            //This ForEach should create the number of symbols held by the card struct
            //However this seems to not work.
            ZStack {
                switch content.symbol {
                case .oval:
                    DrawCircle(fillType: content.fillType.rawValue)
                case .diamond:
                    DrawDiamond(fillType: content.fillType.rawValue)
                case .squiggle:
                    DrawSquiggle(fillType: content.fillType.rawValue)
                }
            }
        }
    }
}

When I run this, the View builds correctly. The image shows the id and the n (number of symbols that should be on the card.)

Correctly built view

However when I scroll down and back up, the View gets rebuilt incorrectly. The number of symbols seems to be completely random. This only happens when the ScrollView is of a certain length, so when the number of cards is over 30 cards (depending on their size.) The cards aren't getting mixed up because the id remains the same and n is also unchanged.

Refreshed and incorrectly rebuilt View

Am I missing something? It must be something to do with the way ScrollView and ForEach interact. It seems to be a problem with the CardContentView struct and the ForEach statement within it. I just don't know what.

These are the errors I'm getting after refreshing;

ForEach<Range, Int, ZStack<_ConditionalContent<_ConditionalContent<DrawCircle, DrawDiamond>, DrawSquiggle>>> count (3) != its initial count (2). ForEach(_:content:) should only be used for constant data. Instead conform data to Identifiable or use ForEach(_:id:content:) and provide an explicit id!

Below; the entire Model which holds the CardContent and Card structs along with the init that creates the cards array;

struct SetGame {
    private(set) var cards: Array<Card>
    private(set) var deck: Array<Card>
    
    init() {
        deck = []
        cards = []
        var id = 1
        var color = CardContent.Color.red
        var fillType = CardContent.FillType.hollow
        var symbol = CardContent.Symbol.oval
        while deck.count < 81 {
            if deck.count % 27 == 0 {
                switch color {
                case .red:
                    color = .green
                case .green:
                    color = .purple
                case .purple:
                    color = .red
                }
            }
            if deck.count % 9 == 0 {
                switch symbol {
                case .oval:
                    symbol = .diamond
                case .diamond:
                    symbol = .squiggle
                case .squiggle:
                    symbol = .oval
                }
            }
            if deck.count % 3 == 0 {
                switch fillType {
                case .hollow:
                    fillType = .shaded
                case .shaded:
                    fillType = .filled
                case .filled:
                    fillType = .hollow
                }
            }
            deck.append(Card(id: id, content: CardContent(number: 1, color: color, fillType: fillType, symbol: symbol)))
            deck.append(Card(id: id+1, content: CardContent(number: 2, color: color, fillType: fillType, symbol: symbol)))
            deck.append(Card(id: id+2, content: CardContent(number: 3, color: color, fillType: fillType, symbol: symbol)))
            id += 3
        }
        //deck.shuffle()
        while cards.count < 81 {
            //When cards.count > 28; the view starts bugging
            cards.append(deck.popLast()!)
        }
    }
    
    mutating func dealThreeCards() {
        for _ in 0...2 {
            if deck.isEmpty {
                break
            } else {
                cards.append(deck.popLast()!)
            }
        }
        print("I was called :)")
    }
    
    struct Card: Identifiable {
        var isFaceUp = true
        var isMatched = false
        let id: Int
        let content: CardContent
    }
    
    struct CardContent: Equatable {
        let number: Int
        let color: Color
        let fillType: FillType
        let symbol: Symbol
        enum Color { case red, green, purple }
        enum FillType: Double {
            case hollow = 0.0
            case shaded = 0.2
            case filled = 1.0
        }
        enum Symbol { case oval, diamond, squiggle }
    }
}

Below; the entire ViewModel which is used in the ContentView. The app is extremely simple, yet I don't understand what's not working.

class SetGameViewModel: ObservableObject {
    @Published private(set) var game: SetGame
    
    
    init() {
        game = SetGame()
    }
    
    var cards: Array<SetGame.Card> {
        game.cards
    }
    
    func dealThreeCards() {
        game.dealThreeCards()
        print("I was called :)))")
    }
}

Minimal Reproducible Example

Main:

import SwiftUI

@main
struct mreApp: App {
    var body: some Scene {
        let game = ViewModel()
        WindowGroup {
            ContentView(viewModel: game)
        }
    }
}

Model:

import Foundation

struct CardGameModel {
    private(set) var cards: Array<Card>
    private(set) var deck: Array<Card>
    
    
    init() {
        //the init only takes care of creating the two arrays
        //deck and cards. It seems to be working correctly and nothing
        //is wrong here, I believe.
        deck = []
        cards = []
        var id = 1
        var color = CardContent.Color.red
        var fillType = CardContent.FillType.hollow
        var symbol = CardContent.Symbol.oval
        while deck.count < 81 {
            if deck.count % 27 == 0 {
                switch color {
                case .red:
                    color = .green
                case .green:
                    color = .purple
                case .purple:
                    color = .red
                }
            }
            if deck.count % 9 == 0 {
                switch symbol {
                case .oval:
                    symbol = .diamond
                case .diamond:
                    symbol = .squiggle
                case .squiggle:
                    symbol = .oval
                }
            }
            if deck.count % 3 == 0 {
                switch fillType {
                case .hollow:
                    fillType = .shaded
                case .shaded:
                    fillType = .filled
                case .filled:
                    fillType = .hollow
                }
            }
            deck.append(Card(id: id, content: CardContent(numberOfShapes: 1, color: color, fillType: fillType, symbol: symbol)))
            deck.append(Card(id: id+1, content: CardContent(numberOfShapes: 2, color: color, fillType: fillType, symbol: symbol)))
            deck.append(Card(id: id+2, content: CardContent(numberOfShapes: 3, color: color, fillType: fillType, symbol: symbol)))
            id += 3
        }
        //deck.shuffle()
        while cards.count < 81 {
            //When cards.count > 28; the view starts bugging.
            //However it also depends on the amount of columns in the
            //LazyVGrid. If more columns are included, the number of cards
            //displayable before bugs is greater.
            
            //Optional
            cards.append(deck.popLast()!)
        }
    }
    
    struct Card: Identifiable {
        let id: Int
        let content: CardContent
    }
    
    struct CardContent: Equatable {
        let numberOfShapes: Int
        let color: Color
        let fillType: FillType
        let symbol: Symbol
        enum Color { case red, green, purple }
        enum FillType: Double {
            case hollow = 0.0
            case shaded = 0.2
            case filled = 1.0
        }
        enum Symbol { case oval, diamond, squiggle }
    }
}

ViewModel:

import Foundation

class ViewModel: ObservableObject {
    @Published private(set) var game: CardGameModel
    
    init() {
        game = CardGameModel()
    }
    
    var cards: Array<CardGameModel.Card> {
        game.cards
    }
}

View:

import SwiftUI

struct ContentView: View {
    @ObservedObject var viewModel: ViewModel
    
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(),GridItem(),GridItem(),GridItem()]) {
                ForEach(viewModel.cards) { card in
                    CardView(card: card)
                        .aspectRatio(2/3,contentMode: .fill)
                }
            }
        }
    }
}


struct CardView: View {
    let card: CardGameModel.Card
    private var color: Color {
        switch card.content.color {
        case .green:
            return Color(.green)
        case .purple:
            return Color(.purple)
        case .red:
            return Color(.red)
        }
    }
    
    var body: some View {
        ZStack{
            RoundedRectangle(cornerRadius: 20)
                .stroke(lineWidth: 2.0)
                .foregroundColor(/*@START_MENU_TOKEN@*/.blue/*@END_MENU_TOKEN@*/)
            VStack {
                CardContentView(content: card.content)
                    //The contents of the cards are created
                    .foregroundColor(color)
                Text("\(card.id), n: \(card.content.numberOfShapes)")
            }
        }
    }
}

struct CardContentView: View {
    let content: CardGameModel.CardContent
    var body: some View {
        VStack {
            ForEach(0..<content.numberOfShapes) { _ in
                switch content.symbol {
                case .oval:
                    Circle()
                case .squiggle:
                    RoundedRectangle(cornerRadius: 35.0)
                case .diamond:
                    Rectangle()
                }
            }
        }
    }
    
}

Solution

  • In CardContentView add in the ForEach the id identifier as the error message requests it:

    ForEach(0..<content.numberOfShapes, id: \.self) { _ in
           ...
    }
    

    Tested and working.