UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

SOLVED: Button WTHeck? (UPDATED WITH FULL REPRO)

Forums > SwiftUI

Okay, this is a bit long, apologies. So thanks to the folks here, I've figured out some stuff for my SOS game https://github.com/johncwelch/SOS-Swift

here's a much shorter repro case that has the exact same problem.

ContentView.swift contents:

import SwiftUI

struct ContentView: View {
    @State var gridSize = 3
    @State var selected = 0
    @State var aCount = 0
    @State var theToggle = true
    var theTitle = "test"
    @State var buttonBlank: Bool = true

    var body: some View {
        //@State var gridCellArr = buildGridCellArray(gridSize: gridSize)
        @State var gridCellArr = buildStructArray(theGridSize: gridSize)
        HStack {
            Button {

            } label: {
                Text("Commit Move")
            }
            .disabled(buttonBlank)
        }
        Grid(horizontalSpacing: 0, verticalSpacing: 0) {
            ForEach(0..<gridSize, id: \.self) {
                row in
                GridRow {
                    ForEach(0..<gridSize, id: \.self) { col in
                        GeometryReader { proxy in
                            let index = row * gridSize + col
                            Button {
                                var theTuple = doSomethingElseOnClick(for: gridCellArr[index].index, myArray: gridCellArr)

                                gridCellArr[index].backCol = theTuple.myColor
                                gridCellArr[index].title = theTuple.myTitle
                                //buttonBlank = theTuple.myCommitButtonStatus
                                //self.buttonBlank = false
                            } label: {
                                Text(gridCellArr[index].title)
                                    .font(.system(size: 36, weight: .heavy, design: .serif))
                                    //.fontWeight(.heavy)
                                    .frame(width: proxy.frame(in: .global).width,height: proxy.frame(in: .global).height)

                            }
                            .background(gridCellArr[index].backCol)
                            .border(Color.black)

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

All the classes and functions used:

@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
}

func buildStructArray(theGridSize: Int) -> [Cell] {
    var myStructArray: [Cell] = []
    let arraySize = (theGridSize * theGridSize) - 1
    for i in 0...arraySize {
        myStructArray.append(Cell())
    }

    for i in 0...arraySize {
        myStructArray[i].index = i
    }
    return myStructArray
}

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

    if myArray[myIndex].title == "Button" {
        print("it's a button")
    }
    var theColor: Color
    if myIndex <= 7 {
        let testIndex = myIndex + 1
        print("\(myArray[testIndex].index)")
        theColor = Color.green
    } else {
        let testIndex = myIndex - 1
        print("\(myArray[testIndex].index)")
        theColor = Color.blue
    }

    let theReturnTuple = (myColor: theColor, myTitle: myArray[myIndex].title, myCommitButtonStatus: theCommitButtonStatus)
    return theReturnTuple
}

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".

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 a way, it's a three click cycle as I expect, but buttonBlank never changes so the commit move button is always disabled.

I am absolutely sure this is related to changing the value of buttonBlank, because as soon as I put any statement with that in, even just: buttonBlank = false or self.buttonBlank = false in the grid button click action, I get that behavior. If i never try to change buttonBlank, then the weird behavior disappears

2      

Hi John,

After tinkering with your code i think the problem is having the @State var gridCellArr inside the body it gets called 9 times at first then it gets called after the first click and after the fourth click, if you put a print statement at the begining of the buildStructArray function you'll see what im talking about. i'm currently using macOS 13 so i don't have the @Observable wrapper but i put the var inside it's own ObservableObject class with @Published and it started working like it should. Hope this helps.

2      

@Hectorcrdna, do you have some sample code? I'm still pretty new to swiftui, so a lot of the subtleties of the various @vars are kind of confusing.

2      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

No problem, There are a few ways you could do this but for example since you're using iOS 17 you should be able to do something like this:

@Observable class Game {
    var gridSize = 3
    var gridCellArr: [Cell] = []

    init(gridSize: Int = 3) {
        self.gridSize = gridSize
        self.gridCellArr = buildStructArray(theGridSize: gridSize)
    }

    func buildStructArray(theGridSize: Int) -> [Cell] {
        var myStructArray: [Cell] = []
        let arraySize = (theGridSize * theGridSize)
        for i in 0..<arraySize {
            myStructArray.append(Cell(index: i))
        }
        return myStructArray
    }
}

and then ContentView:

struct ContentView: View {
//    @State var gridSize = 3
    @State var selected = 0
    @State var aCount = 0
    @State var theToggle = true
    var theTitle = "test"
    @State var buttonBlank: Bool = true
    @State var game = Game()

    var body: some View {
        //@State var gridCellArr = buildGridCellArray(gridSize: gridSize)
//        @State var gridCellArr = buildStructArray(theGridSize: gridSize)
        HStack {
            Button {

            } label: {
                Text("Commit Move")
            }
            .disabled(buttonBlank)
        }
        Grid(horizontalSpacing: 0, verticalSpacing: 0) {
            ForEach(0..<game.gridSize, id: \.self) {
                row in
                GridRow {
                    ForEach(0..<game.gridSize, id: \.self) { col in
                        GeometryReader { proxy in
                            let index = row * game.gridSize + col
                            Button {
                                let theTuple = doSomethingElseOnClick(for: game.gridCellArr[index].index, myArray: game.gridCellArr)

                                game.gridCellArr[index].backCol = theTuple.myColor
                                game.gridCellArr[index].title = theTuple.myTitle
                                buttonBlank = theTuple.myCommitButtonStatus
                                self.buttonBlank = false
                            } label: {
                                Text(game.gridCellArr[index].title)
                                    .font(.system(size: 36, weight: .heavy, design: .serif))
                                    //.fontWeight(.heavy)
                                    .frame(width: proxy.frame(in: .global).width,height: proxy.frame(in: .global).height)

                            }
                            .background(game.gridCellArr[index].backCol)
                            .border(Color.black)

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

2      

okay, so that makes a lot of sense and thank you!!!

Next question: in the "real" version of this, the grid size is selected via a picker with values from 3 to 10 in an @StateVar (gridSize). How do I incorporate the ability to change the gridSize? would I just call game.buildStructArray(theGridSize: gridSize)?

thank you so much for all of this, i keep feeling like i'm this close and it's that last part that is making me bonkers.

(the "real" project is up at: https://github.com/johncwelch/SOS-Swift)

2      

I was able to look at your code and i added the following:

In AppData:

@Observable
class Game {
    var gridSize: Int {
        didSet {
            gridCellArr = buildCellArray(theGridSize: gridSize)
        }
    }
    var gridCellArr: [Cell] = []

    init(gridSize: Int = 3) {
        self.gridSize = gridSize
        self.gridCellArr = buildCellArray(theGridSize: gridSize)
    }
}

In ContentView: i added a @Bindable game var outside the body

@Bindable var game = Game()

then inside the body i changed the Binding of the picker to $game.gridSize:

Picker("Board Size", selection: $game.gridSize) {
                    Text("3").tag(3)
                    Text("4").tag(4)
                    Text("5").tag(5)
                    Text("6").tag(6)
                    Text("7").tag(7)
                    Text("8").tag(8)
                    Text("9").tag(9)
                    Text("10").tag(10)
                }

and in the grid HStack made a few changes:

        HStack(alignment: .top) {

            Grid (horizontalSpacing: 0, verticalSpacing: 0) {

                //the view (grid) will refresh if you change the state var gridSize,
                //but, you have to include the id: \.self for it to work right, because
                //of how swift handles this. Note, you don't use the id, 
                //this is just telling the view what's going on.

                //row foreach
                ForEach(0..<game.gridSize, id: \.self) { row in
                    GridRow {
                        //column foreach
                        ForEach(0..<game.gridSize, id: \.self) { col in

                                //put a rectangle in each grid space
                                //the overlay is how you add text
                                //the border is how you set up grid lines
                                //the order is important. if foreground color comes after
                                //overlay, it covers the overlay
                                //gridCellSize is how we get the size
                            GeometryReader { gridCellSize in
                                //this sets up the index for gridCellArr so we "know" what button
                                //we're clicking
                                let myIndex = row * game.gridSize + col
                                //Rectangle()
                                    //.foregroundColor(.teal)
                                    //.overlay(Text("\(row),\(col)").fontWeight(.heavy))
                                    //.border(Color.black)
                                if myIndex <= (game.gridSize * game.gridSize - 1) {
                                    Button {
                                        //this is where we run the core function that does all the work
                                        let theTuple = buttonClickStuff(for: game.gridCellArr[myIndex].index, theTitle: game.gridCellArr[myIndex].title, myArray: game.gridCellArr, myCurrentPlayer: currentPlayer)

                                        game.gridCellArr[myIndex].title = theTuple.myTitle
                                        buttonBlank = theTuple.myCommitButtonStatus
                                        lastButtonClickedIndex = game.gridCellArr[myIndex].index
                                        //print statements to validate the button clicked properties
                                        //print("the current index is: \(gridCellArr[myIndex].index), the current button grid location is \(gridCellArr[myIndex].xCoord),\(gridCellArr[myIndex].yCoord)")
                                        //print("buttonBlank is: \(buttonBlank)")
                                        print(myIndex)

                                    } label: {
                                        //set the text of the button to be the title of the button
                                        Text(game.gridCellArr[myIndex].title)
                                        //set the font of the button text to be system with a
                                        //size of 36, a weight of heavy, and to be a serif font
                                            .font(.system(size: 36, weight: .heavy, design: .serif))

                                        //this ensures the buttons are always the right size
                                            .frame(width: gridCellSize.frame(in: .global).width,height: gridCellSize.frame(in: .global).height, alignment: .center)
                                    }
                                    //styles button. Since I only have to do this once, here, there's no
                                    //real point in building a separate button style
                                    //note that .background is necessary to avoid weird button display errors
                                    .foregroundStyle(buttonTextColor)
                                    //this allows the button color to change on commit
                                    .background(game.gridCellArr[myIndex].backCol)
                                    .border(Color.black)
                                    //once a move is committed, buttonDisabled is set to true, and the button is
                                    //disabled so it can't be used again
                                    .disabled(game.gridCellArr[myIndex].buttonDisabled)

                                    .onAppear(perform: {
                                        //this has each button set its own coordinates as it appears
                                        //which is IMPORTANT later on
                                        game.gridCellArr[myIndex].xCoord = col
                                        game.gridCellArr[myIndex].yCoord = row

                                    })
                                }
                            }
                        }
                    }
                }
                .id(game.gridSize)
            }
        }

Even with some of the changes i kept geting index out of range in myIndex var so i had to add an if statement to make sure the index was not out of range,

let me know if it works for you.

3      

OH GOD THAT WORKED!!

thank you SO MUCH!!!

2      

Hector, thanks for helping fix it; I'd also been looking at it.

What struck me about this is the implication that the .onChange handler is running on a background thread. (Because the UI is redrawing even in the middle of the .onChange handler).

This led me to think the ideal solution (rather than a workable solution) might be to force the variable change to happen on the main thread, which would have the effect of blocking UI updates.

Another thing I'd been thinking about was keeping ContentView's @State variable for gridSize BUT have the grid read from the game.gridSize variable and update that after the array had been updated, but that might still have timing issues when the array shrinks.

(Just wanted to document these thoughts in case others have similar problems and needed to consider other approaches.)

3      

What struck me about this is the implication that the .onChange handler is running on a background thread. (Because the UI is redrawing even in the middle of the .onChange handler).

@deirdresm you are right, i forgot to mention to @johncwelch that i removed the .onChange and the boardSize @State var since i was taking the size straight from game.gridSize.

Another thing I'd been thinking about was keeping ContentView's @State variable for gridSize BUT have the grid read from the game.gridSize variable and update that after the array had been updated, but that might still have timing issues when the array shrinks.

Yea it's still giving me the index out of range when the array shrinks; i was searching around and found a release note for iOS13 that says

Constant ranges of Int continue to be accepted: However, you shouldn’t pass a range that changes at runtime. If you use a variable that changes at runtime to define the range, the list displays views according to the initial range and ignores any subsequent updates to the range.

So that might be the issue, i was noodling around and made the game.gridCellArray a 2D array and had the grid read from the array directly which eliminated the need for the myIndex var and any index out of range issues, plus the forEach gives you the actual cell you click on, that also is another option.

2      

It seems you have a question or issue related to a "Button WTHeck?" or some form of button behavior. However, your message is quite brief, and I need more context and details to provide a specific answer or assistance.

Could you please provide more information about what you're experiencing or what you need help with regarding this button? Specifically, it would be helpful to know:

What is the button used for or where it is located (e.g., on a website, in an application)? What behavior or issue are you encountering with the button? Do you have any specific questions or tasks related to this button? The more details you can provide, the better I can assist you in understanding and resolving the issue.

2      

Thank you for useful information.

2      

such a useful information. thank you so much

2      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.