very weird swiftui button behavior

apologies for the title, but I can't think of a more descriptive one. This is a bit long, apologies. I have my SOS game: https://github.com/johncwelch/SOS-Swift

Now, there's one thing making me a bit batty. So I have my grid:

        Grid (horizontalSpacing: 0, verticalSpacing: 0){
            ForEach(0..<boardSize, id: \.self) { row in
                GridRow {
                    ForEach(0..<boardSize, id: \.self) { col in
                        GeometryReader { gridCellSize in
                            Button {
                                var theTuple = buttonClickStuff(for: gridCellArr[myIndex].index, theTitle: gridCellArr[myIndex].title, myArray: gridCellArr)

                                gridCellArr[myIndex].title = theTuple.myTitle
                                buttonBlank = theTuple.myCommitButtonStatus
                                lastButtonClickedIndex = gridCellArr[myIndex].index
                     } label: {
                                Text(gridCellArr[myIndex].title)
                                    .font(.system(size: 36, weight: .heavy, design: .serif))
                                    .frame(width: gridCellSize.frame(in: .global).width,height: gridCellSize.frame(in: .global).height, alignment: .center)
                            }
                            .background(gridCellArr[myIndex].backCol)
                            .border(Color.black)

                            .onAppear(perform: {
                                gridCellArr[myIndex].xCoord = col
                                gridCellArr[myIndex].yCoord = row
                            })
                        }
                    }
                }
            }
        }
    }

and here's the function buttonClickStuff():

func buttonClickStuff(for myIndex: Int, theTitle: String, myArray: [Cell]) -> (myColor: Color, myTitle:String, myCommitButtonStatus: Bool) {
     var theCommitButtonStatus: Bool = false
     var theCellTitle: String = ""
     switch theTitle {
          case "":
               theCellTitle = "S"
               theCommitButtonStatus = false
          case "S":
               theCellTitle = "O"
               theCommitButtonStatus = false
          case "O":
               theCellTitle = ""
               theCommitButtonStatus = true
          default:
               print("Something went wrong, try restarting the app")
     }
     let theColor: Color = Color.blue
     let theReturnTuple = (myColor: theColor, myTitle: theCellTitle,      myCommitButtonStatus: theCommitButtonStatus)
     return theReturnTuple
}

and just for completeness sake, here's Cell that makes up the cell array:

@Observable
class Cell: Identifiable {
let id = UUID()
var title: String = ""
var buttonToggled: Bool = false
var index: Int = 0
var xCoord: Int = 0
var yCoord: Int = 0
var backCol: Color = .gray
}

so now I have a button that I need to be disabled or enabled based on the contents of a cell:

Button {
    gridCellArr[lastButtonClickedIndex].backCol = .green
} label: {
    Text("Commit Move")                 
}
.disabled(buttonBlank)

and buttonBlank is an @State var along with boardSize:

@State var boardSize: Int = 3
@State var buttonBlank: Bool = true

so the idea is, the board is built, all cells have a title of "" (blank) and Commit Move is disabled:

  1. the first time a button is clicked, its title should change to S and commit move is enabled
  2. second click, title changes to O, commit move is still enabled
  3. third click, title changes to "", commit move is disabled.

So a three-click rotation: "", "S", "O" what i'm actually getting is a four click rotation:

  1. first click, button doesn't visibly change, but it thinks the title is now "S" and the commit move button is enabled
  2. second click, Button does visibly change to "S", title is STILL "S", and the commit move button is enabled
  3. third click, button visibly changes to "O", title is "O" and the commit move button is enabled
  4. fourth click, button visibly changes to "", title is "" and the commit move button is disabled

that's the cycle, but it gets weirder.

suppose on button 0, i leave it at "S" or "O".

Then i click on button 1. I get:

  1. first click: button does visibly change to "S", title is "S", and the commit move button is enabled
  2. second click: button visibly changes to "O", title is "O" and the commit move button is enabled
  3. third click: BOTH buttons change to "", title for both is "" and the commit move button is disabled

if I keep clicking, it goes back to a four-click cycle

if I move the buttonBlank @state var underneath body, the "all buttons blank" problem goes away, it's a three click cycle as I expect, but buttonBlank never changes so the commit move button is always disabled.

what the heck? I am SO confused right now.

Replies

if I move the buttonBlank @state var underneath body

what do you mean ?

I you want help you should post complete enough code so that we can run and understand what's happening.

Add a Comment

the ContentView.swift alone is over three hundred lines, so that seemed a bit much to paste in, which is why I provided the link to the full project on github.

When I said "underneath body" I meant how, in ContentView.swift, you have the basic structure of the file:

import SwiftUI
struct ContentView: View {
     var body: some View {
          <most code goes here>
     }
}

#Preview {
    ContentView()
}

the current issue i'm seeing happens when I have @State var buttonBlank: Bool = true after the ContentView: View line but before the var body: some View line.

If I put it after var body: some View, then I don't get the weird 4 cycle-blank other buttons weirdness, but buttonBlank never updates.

sorry if that bit was less than clear.

Some questions first:

  • you declare @State inside body. Does it compile ?
  • yes, it does. That's part of what's weird. It compiles and it even mostly runs until i try to set buttonBlank.

    if I move it up with the rest of the @state vars, then the weird behavior goes away, but I have to use an actual integer to init gridCellArr, and then I can't resize the grid. So that's replacing one problem with another.

Add a Comment

Problem is that in Grid, you call

            ForEach(0..<gridSize, id: \.self) { row in

this does not change when you change a cell, so no update.

  • I'm unclear how that's a problem. When I change gridSize, the grid redraws with the new size as expected.

Add a Comment

I could not test with Xcode 15, so I adapted to Xcode 14.2.

I hade the same problem: no change when tapping a cell.

To make it work, I had to essentially change as follows (a bit more to adapt to Xcode 14.2. There is certainly a better way to do it with appropriate Published properties, but that shows at least a solution):

  • create a dummy state var
struct ContentView: View {
     @State private var dummy = false
  • change its state in button action:
        Grid (horizontalSpacing: 0, verticalSpacing: 0){
            ForEach(0..<boardSize, id: \.self) { row in
                GridRow {
                    ForEach(0..<boardSize, id: \.self) { col in
                        GeometryReader { gridCellSize in
                            Button {
                                 dummy.toggle()  // To change the state
  • And that's where it is a bit weird, test dummy to force a redraw of the grid:
                     } label: {
                        if dummy || !dummy {  // <<-- always true but that cheats the compiler into believing there was a state change !!!
                                Text(gridCellArr[myIndex].title)
                                    .font(.system(size: 36, weight: .heavy, design: .serif))
                                    .frame(width: gridCellSize.frame(in: .global).width,height: gridCellSize.frame(in: .global).height, alignment: .center)
                            }
                       }

Could you test and tell if it works for you ?

  • that's working.

    here's what I'm seeing now. If I set the initial board size as a state var and pass it an integer like 3, I get a 3x3 grid, everything works fine including the button state.

    however, that creates a different problem in that in my "main" game, there's a picker that lets you set a gridSize between 3x3 and 10x10, so it changes boardSize right? from 3 to 10, but if I'm hardcoding the board size when I init gridCellArr, changing boardsize causes index out of range errors.

  • But I can't figure out how to init gridCellArr like that. So:

    @State var gridCellArr = buildCellArray(theGridSize: 3) works @State var gridCellArr = buildCellArray(theGridSize: boardSize) does not because using before init. I tried initially doing @State var gridCellArr: [Cell], and then:

    init() { gridCellArr = buildCellArray(theGridSize: _boardSize.wrappedValue) }

    but as soon as I change the value for boardSize in the picker, BOOM.

Add a Comment

Could you show how you declare gridSize in main (where the picker is) ? Do you use a Binding ?

I did a simple test, by changing gridSize in commit button:

        HStack {
            Button {
                changed.toggle()
                gridSize = 4
                gridCellArr.theCells = buildStructArray(theGridSize: gridSize)
            } label: {
                Text("Commit Move")
            }