How do I reset a button after a period of time?

As my first project with Swift, intended only for exploration, I'm doing a simple memory game. 3x3 grid of doors, when you click on a door it transforms to show the symbol behind it. After a second or two the symbol reverts to being a door. Eventually I will have it so that it waits until two doors are open so that you can see if you successfully matched, then closes both at the same time, then make it remove successfully matched doors, etc.

My current problem is getting the door to reset. I had thought I could do this by running an async function inside the Button initializer but that fails.

Here is my ContentView with the errors and issues marked in comments:

struct ContentView: View {
    @Environment(ModelData.self) var modelData
    
    var body: some View {
        @Bindable var modelData = modelData
        VStack {
                ForEach(modelData.boardState, id: \.self) { row in
                // NB:  If I remove "id: \self" from the previous line
                // I get "Referencing initializer 'init(_:content:)' on 'ForEach' requires that '[Item]' conform to 'Identifiable'"
                // which surprises me.  Shouldn't an array be 
                // Identifiable based on its contents?
                    HStack {
                        ForEach(row) { item in
                            Button { // ERROR:  Cannot pass function of type '() async -> Void' to parameter expecting synchronous function type
                                item.revealed = !item.revealed
                                await resetButton(door: item)
                            } label: {
                                Text(item.revealed ? item.value : door)
                                    .font(.system(size: 72))
                            }
                        }
                    }
                }
            }
            .padding()
    }
    private func resetButton(door : Item)  async -> Void {
        sleep(1)
        door.revealed = false
    }
}

After staring at this for a bit I think I see the issue; the Button initializer expects that it will be able to complete immediately so that it can render immediately, which means it can't pause for an indeterminate time to do an await. I experimented with various other options but couldn't find anything that worked.

Can someone point me in the right direction?

For the record, ModelData looks like this:

import Foundation

let door = "🚪"

extension Hashable where Self: AnyObject {
    func hash(into hasher : inout Hasher) {
        hasher.combine(ObjectIdentifier(self))
    }
}
    
@Observable
class Item : Identifiable, Hashable {
    let id : UUID
    var revealed : Bool
    var value: String
    
    init(revealed: Bool = false, value : String) {
        self.id = UUID()
        self.revealed = revealed
        self.value = value
    }
}

@Observable
class ModelData {
    //  Manually specifying the icon will work
    var boardState : [[Item]] = [
        ["😀", "😴", "😲"].map { Item.init(value: $0) },
        ["😀", "😴", "😲"].map { Item.init(value: $0) },
        ["😈", "❤️‍🩹", "👋"].map { Item.init(value: $0) },
    ]
}

Take a look at Timer, you can use it to run a closure after an amount of time.

IMO, async/await only adds unneeded complexity in this case. Timer is the simplest solution.

Implementation of the Hashable and Equatable should be in sync. If you provide custom implementation of the Hashable, you must provide relevant implementation of Equatable too. But so far, I can see it is used only to make [Item] also conform to Hashable to satisfy requirements of the first ForEach. Since you are not inserting or removing rows there, you can use ForEach(0..<modelData.boardState.count) { index in . And then you can remove Hashable conformance altogether.

Note that Identifiable has this extension:

extension Identifiable where Self : AnyObject {
    /// The stable identity of the entity associated with this instance.
    public var id: ObjectIdentifier { get }
}

So, you don't need to provide any implementations to conform class to Identifiable. You don't need UUID, you already have memory allocator as a generator of unique identifiers.

Since optionality of the Timer and value of revealed are in sync, it makes sense to merge them into single optional or custom enum:

@Observable
class Item : Identifiable {
    private var revealedTimer: Timer?
    var value: String
    
    init(value : String) {
        self.value = value
    }

    var isRevealed: Bool { revealedTimer != nil }
    func toggleState() { ... }
}
2 Likes

Overall have several suggestions that better to keep in mind while coding in SwiftUI:

  1. Views should be dumb, meaning don't put much extra logic to them. So in this case just move logic to observable class. This is straight forward solution with row and index, better improve with better modelling, preventing data races and etc. (afaik strict swift 6 mode will show warnings), but should work:
struct Item: Identifiable, Hashable {
  let id: UUID
  let value: String
  var revealed: Bool
  
  init(
    value: String
  ) {
    self.id = UUID()
    self.revealed = false
    self.value = value
  }
}

@Observable
class ModelData {
  
  static let door = "🚪"
  
  private struct Key: Hashable {
    let row: Int
    let column: Int
  }
  
  @MainActor var boardState: [[Item]]
  private var revealationTasks: [Key: Task<Void, Never>] = [:]

  @MainActor
  func reveal(row: Int, column: Int) {
    // key for tasks dict
    let key = Key(row: row, column: column)
    // let's check if it's not running already
    guard self.revealationTasks[key] == .none else { return }
    // now let's change the state, view will update
    self.boardState[row][column].revealed = true
    // and in the end fire a task
    self.revealationTasks[key] = Task { @MainActor [weak self] in
      // defer will run in the end and clean dict
      defer { self?.revealationTasks.removeValue(forKey: key) }
      // Sleep and update again
      try? await Task.sleep(for: .seconds(1))
      self?.boardState[row][column].revealed = false
    }
  }
  
  @MainActor
  init() {
    self.boardState = [
      ["😀", "😴", "😲"].map(Item.init),
      ["😀", "😴", "😲"].map(Item.init),
      ["😈", "❤️‍🩹", "👋"].map(Item.init),
    ]
  }
}

struct ContentView: View {
  
  @Environment(ModelData.self) var modelData
  
  var body: some View {
    VStack {
      ForEach(
        Array(modelData.boardState.enumerated()), id: \.offset
      ) { (row: Int, items: [Item]) in
        HStack {
          ForEach(
            Array(items.enumerated()), id: \.offset
          ) { (column: Int, item: Item) in
            Button {
              self.modelData.reveal(row: row, column: column)
            } label: {
              Text(item.revealed ? item.value : ModelData.door)
                .font(.system(size: 72))
            }
          }
        }
      }
    }
    .padding()
  }
}
  1. Also it's better to model with structs, e.g. in the example Item is struct now. SwiftUI view is representation of state and struct work better for that.

Now, regarding comment:

// NB:  If I remove "id: \self" from the previous line
                // I get "Referencing initializer 'init(_:content:)' on 'ForEach' requires that '[Item]' conform to 'Identifiable'"
                // which surprises me.  Shouldn't an array be 
                // Identifiable based on its contents?

In Swift types should explicitly conform to protocols, it doesn't matter what are contents inside.

2 Likes

Thank you both, this is very helpful.

I missed this bit, sorry.

Item does conform to Identifiable, the problem is that [Item] does not. I wasn't sure how to fix that, so I went with the .id option.