Home > Enterprise >  Swift UI ForEach should only be used for *constant* data
Swift UI ForEach should only be used for *constant* data

Time:11-09

So i get this error when running my set game app:

Image of the game screen when more of the same cards appear (All cards in the game should be unique)

ForEach<Range<Int>, Int, ModifiedContent<_ConditionalContent<_ConditionalContent<_ConditionalContent<_ConditionalContent
<ZStack<TupleView<(_ShapeView<Squiggle, ForegroundStyle>, 
_StrokedShape<Squiggle>)>>, ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>, 
_ClipEffect<Squiggle>>, _StrokedShape<Squiggle>)>>>, _StrokedShape<Squiggle>>, 
_ConditionalContent<_ConditionalContent<ZStack<TupleView<(_ShapeView<Capsule, ForegroundStyle>, _StrokedShape<Capsule>)>>, 
ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>, _ClipEffect<Capsule>>, 
_StrokedShape<Capsule>)>>>, _StrokedShape<Capsule>>>, _ConditionalContent<_ConditionalContent<ZStack<TupleView<(_ShapeView<Diamond, 
ForegroundStyle>, _StrokedShape<Diamond>)>>, ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>, _ClipEffect<Diamond>>, 
_StrokedShape<Diamond>)>>>, _StrokedShape<Diamond>>>,
 _FrameLayout>> count (2) != its initial count (1). `ForEach(_:content:)`
 should only be used for *constant* data. Instead conform data to `Identifiable` 
or use `ForEach(_:id:content:)` and provide an explicit `id`!

It doesnt seem like the card models underneath the view themselves change. Everything but the numberOfShapes on the cards stay the same. So a card that is displayed wrongly will only match if the underlying model is matched correctly and will not match like shown on view.

The error only seem to appear when i either get a set correct and press "show three more cards" or when i display more cards than is visible and then scroll up and down the screen.

The app doesnt stop with the error, but the numberOfShapes on each card changes dynamically (Like on the screenshot above). (This is most clear when i scroll to the top of the screen and then to the bottom and the number of shapes on some cards changes each time)

So i think the problem might be in the card view:

import SwiftUI

struct CardView: View {
    
    var card: Model.Card
    
    var body: some View {
        GeometryReader { geometry in
            ZStack{
                let shape = RoundedRectangle(cornerRadius: 10)
                if !card.isMatched {
                    shape.fill().foregroundColor(.white)
                    shape.strokeBorder(lineWidth: 4).foregroundColor(card.isSelected ? .red : .blue)
                }
            
                VStack {
                    Spacer(minLength: 0)
                    //TODO: Error here?
                    ForEach(0..<card.numberOfShapes.rawValue) { index in
                        cardShape().frame(height: geometry.size.height/6)
                    }
                    Spacer(minLength: 0)
                }.padding()
                .foregroundColor(setColor())
                .aspectRatio(CGFloat(6.0/8.0), contentMode: .fit)
                .frame(width: geometry.size.width, height: geometry.size.height)
            }
        }
    }
    
    @ViewBuilder private func cardShape() -> some View {
        switch card.shape {
        case .squiglle:          shapeFill(shape: Squiggle())
        case .roundedRectangle:  shapeFill(shape: Capsule())
        case .diamond:           shapeFill(shape: Diamond())
        }
    }
    
    private func setColor() -> Color {
        switch card.color {
        case .pink: return Color.pink
        case .orange: return Color.orange
        case .green: return Color.green
        }
    }
    
    @ViewBuilder private func shapeFill<setShape>(shape: setShape) -> some View
    //TODO: se løsning, brug rawvalues for cleanness
    where setShape: Shape {
        switch card.fill {
        case .fill:       shape.fillAndBorder()
        case .stripes:    shape.stripe()
        case .none:       shape.stroke(lineWidth: 2)
        }
    }
}

The cardviews are displayed in a flexible gridlayout:

import SwiftUI

struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
    var items: [Item]
    var aspectRatio: CGFloat
    var content: (Item) -> ItemView
    let maxColumnCount = 6
    
    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)
                    ScrollView{
                    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
    }

View:

import SwiftUI

struct SetGameView: View {
    @StateObject var game: ViewModel
    
    var body: some View {
        VStack{
            HStack(alignment: .bottom){
                Text("Set Game")
                    .font(.largeTitle)
            }
            AspectVGrid(items: game.cards, aspectRatio: 2/3) { card in
                    CardView(card: card)
                        .padding(4)
                        .onTapGesture {
                            game.choose(card)
        
                }
            }.foregroundColor(.blue)
            .padding(.horizontal)
            Button("show 3 more cards"){
                game.showThreeMoreCardsFromDeck()
            }
        }
    }
}

Any help solving this is very much appreciated. I have spent so much time on this already.

CodePudding user response:

Your problem is here:

ForEach(0..<card.numberOfShapes.rawValue) { index in
    cardShape().frame(height: geometry.size.height/6)
}

This ForEach initializer's first argument is a Range<Int>. The documentation says (emphasis added):

The instance only reads the initial value of the provided data and doesn’t need to identify views across updates. To compute views on demand over a dynamic range, use ForEach/init(_:id:content:).

Because it only reads “the initial value of the provided data”, it's only safe to use if the Range<Int> is constant. If you change the range, the effect is unpredictable. So, starting in Xcode 13, the compiler emits a warning if you pass a non-constant range.

Now, maybe your range (0..<card.numberOfShapes.rawValue) really is constant. But the compiler doesn't know that. (And based on your error, it's not a constant range.)

You can avoid the warning by switching to the initializer that takes an id: argument:

ForEach(0..<card.numberOfShapes.rawValue, id: \.self) { index in
                                     // ^^^^^^^^^^^^^
                                     //    add this
    cardShape().frame(height: geometry.size.height/6)
}
  • Related