FREE TRIAL: Accelerate your app development career with Hacking with Swift+! >>

SwiftUI Project Odd one out?

Forums > macOS

Once I finished the project "Odd One Out" from “SwiftUI edition of Hacking with macOS”, it does not seem to work. I also tried the finished project download from Paul and it does the same thing?

At any rate it does not work like the non-SwiftUI project 8 worked.

Has any one gotten this to work?

Thanks

2      

The problem is with the following statement:

if self.image(row, column) == "empty" {

All of the row and columns have empty so it never falls to the else?? Why are all the rows and columns are empty? So, here is the entire code:

import SwiftUI

struct ContentView: View {
static let gridSize = 10

@State var images = ["elephant", "giraffe", "hippo", "monkey", "panda", "parrot", "penguin", "pig", "rabbit", "snake"]
@State var layout = Array(repeating: "empty", count: gridSize * gridSize)

@State var currentLevel = 1
@State var isGameOver = false

var body: some View {
ZStack {
VStack {
Text("Odd One Out")
.font(.system(size: 36, weight: .thin))

ForEach(0..<Self.gridSize) { row in
HStack {
ForEach(0..<Self.gridSize) { column in
if self.image(row, column) == "empty" {
Rectangle()
.fill(Color.clear)
.frame(width: 64, height: 64)
} else {
Button(action: {
self.processAnswer(at: row, column)
}) {
Image(self.image(row, column))
.renderingMode(.original)
}
.buttonStyle(BorderlessButtonStyle())
}
}
}
}
}

}
.onAppear(perform: createLevel)
}
func image(_ row: Int, _ column: Int) -> String {
layout[row * Self.gridSize + column]
}
func createLevel() {
if currentLevel == 9 {
withAnimation {
isGameOver = true
}
} else {
let numbersOfItems = [0, 5, 15, 25, 35, 49, 65, 81, 100]
generateLayout(items: numbersOfItems[currentLevel])
}
}
func generateLayout(items: Int) {
// remove any existing layouts

layout.removeAll(keepingCapacity: true)
// randomize the image order, and consider the first image to be the correct animal
images.shuffle()
layout.append(images[0])
// prepare to loop through the other animals
var numUsed = 0
var itemCount = 1
for _ in 1 ..< items {
// place the current animal image and add to the counter
layout.append(images[itemCount])
numUsed += 1
// if we already placed two, move to the next animal image
if (numUsed == 2) {
numUsed = 0
itemCount += 1
}
// if we placed all the animal images, go back to index1.
if (itemCount == images.count) {
itemCount = 1
}
}
// fill the remainder of our array with empty rectangles then shuffle the layout

layout += Array(repeating: "empty", count: 100 -
layout.count)
layout.shuffle()
}
func processAnswer(at row: Int, _ column: Int) {
if self.image(row, column) == self.images[0] {
// they clicked the correct animal
self.currentLevel += 1
self.createLevel()
} else {
// they clicked the wrong animal
if self.currentLevel > 1 {
// take the current level down by 1 if we can
self.currentLevel -= 1
}

// create a new layout
self.createLevel()
} else {
            // they clicked the wrong animal
            if self.currentLevel > 1 {
                // take the current level down by 1 if we can
                self.currentLevel -= 1
            }

            // create a new layout
            self.createLevel()
        }
    }
}

   

The problems seems to be with generateLayout method. It is not doing what it looks like it should do. By setting some breakpoints I can see after it is called the layout still has 100 entries all containing "empty".

Anybody have any ideas???

   

I am experiencing the same problem. When the app displays the grid for the first time it is entirely blank.

After createLevel() is called, the content of layout is correct. It does contain both "empty" entries and random image names. For some reason the view is not being updating after layout changes. It was my understanding that when a property is tagged with the @State property wrapper, SwiftUI monitors the property for changes and update the view as necessary. This does not appear to be happening.

I have also tried the same code with an iPad and had the same result - the grid is entirely blank.

   

I have found a solution to this issue. Instead of checking if the cell in layout countains "empty" and inserting a Rectangle into the view, I have just drawn every cell as a Button. The "empty" cells use an "empty" image instead of an animal image. The modified body code is below:

var body: some View {
        ZStack {
            VStack {
                Text("Odd One Out")
                    .font(.system(size: 36, weight: .thin))

                ForEach(0 ..< Self.gridSize) { row in
                    HStack {
                        ForEach(0 ..< Self.gridSize) { column in
                            Button(action: {
                                self.processAnswer(at: row, column)
                            }){
                                Image(self.image(row, column))
                                    .renderingMode(.original)
                            }
                            .buttonStyle(PlainButtonStyle())
                        }
                    }
                }
            }
        }
        .onAppear(perform: createLevel)
    }

When the user clicks a cell you need to check to see if that cell is "empty" or contains an animal image. The modified processAnswer() code is below:

func processAnswer(at row: Int, _ column: Int) {
//        confirm the selected cell is not "empty"
        guard self.image(row, column) != "empty" else {
            return
        }

        if self.image(row, column) == self.images[0] {
//            They clicked the correct animal
            self.currentLevel += 1
            self.createLevel()
        } else {
//            they clicked the wrong animal
            if self.currentLevel > 1 {
//                take the current level down by 1 if we can
                self.currentLevel -= 1
            }

//            create a new layout
            self.createLevel()
        }
    }

It would appear that the if/else logic was not executing fast enough, and the draw loop "blew" right through it. Once the if/else logic is removed everything works as intented.

1      

@cpah  

I do not believe that the problem lies in if/else logic being too slow. I inserted some debugPrint statements into the code and found that self.image(row, column) is called 100 times before createLevel() is called by .onAppear. With Paul's original code self.image(row, column) is never called again thereafter and the displayed view is never updated. Running @mapersson's code results in 100 calls to self.image(row, column) (all "empty"), followed by calls to createLevel() and generateLayout(), followed in turn by a further 100 calls to self.image(row, column) (resulting in 95 "empty" plus 5 animal strings) and Hey Presto everything starts to work as intended. I believe that with Paul's original code no initial (predisplay) view, based on the layout array, is generated by SwiftUI, because self.image(row, column) is inside an if statement and not executed. As I understand it, SwiftUI views are updated to reflect a change in an @State variable (the layout array in this case). When generateLayout() is called by createLevel()/.onAppear the new view that should result has nothing to be compared against so the view actually displayed is built from the original layout array (as declared) rather than the new layout array. With @mapersson's code, because self.image(row, column) updates the Button label in the view, an initial (predisplay) view is generated and the new view, created when generateLayout() is called by createLevel()/.onAppear, is different from this initial view, so SwiftUI displays the new view, based on the changed layout array. @mapersson's code succeeds with its minimal editing of Paul's code because Paul added an "empty" image in Assets.xcassets. Paul's empty Rectangle (and its surrounding if statement) were never needed in the first place!

   

That's one way to do it. You can also get it working with the regular if/else statement with little effort by changing ForEach(0 ..< Self.gridSize) { row in and ForEach(0 ..< Self.gridSize) { col in to ForEach(0 ..< Self.gridSize, id: \.self) { row in and ForEach(0 ..< Self.gridSize, id: \.self) { col in respectively. The reason for this is that ForEach views, created using the init(_:content:) initializer don't watch their data array for changes and hence never update their views. They're intended to be used on static data and this is a performance optimization. For dynamic data we should be using init(_:id:content:) which does watch its data array for changes, and hence rebuilds its content when needed.

So your final view code would be

ForEach(0 ..< Self.gridSize, id: \.self) { row in
    HStack {
        ForEach(0 ..< Self.gridSize, id: \.self) { col in
            VStack {
                if self.image(for: row, col).isEmpty {
                    Rectangle()
                        .fill(Color.clear)
                        .frame(width: 64, height: 64)
                } else {
                    Button(action: {
                        self.processAnswer(at: row, col)
                    }) {
                        Image(self.image(for: row, col))
                            .renderingMode(.original)
                    }
                    .buttonStyle(BorderlessButtonStyle())
                }
            }
            .id(self.image(for: row, col))
        }
    }
}

   

Hacking with Swift is sponsored by Sentry

SPONSORED With Sentry’s error and performance monitoring for iOS you see mobile vitals that actually matter, can solve any latency issues quickly, and learn how each release is performing over time.

Get started

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

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

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.