classstructswiftuididset

Blinking symbol with didSet in SwiftUI


This is synthesized from a much larger app. I'm trying to blink an SF symbol in SwiftUI by activating a timer in a property's didSet. A print statement inside timer prints the expected value but the view doesn't update.

I'm using structs throughout my model data and am guessing this will have something to do with value vs. reference types. I'm trying to avoid converting from structs to classes.

import SwiftUI
import Combine

@main
struct TestBlinkApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

class Model: ObservableObject {
  @Published var items: [Item] = []
  
  static var loadData: Model {
    let model = Model()
    model.items = [Item("Item1"), Item("Item2"), Item("Item3"), Item("Item4")]
    
    return model
  }
}

struct Item {
  static let ledBlinkTimer: TimeInterval = 0.5
  
  private let ledTimer = Timer.publish(every: ledBlinkTimer, tolerance: ledBlinkTimer * 0.1, on: .main, in: .default).autoconnect()
  
  private var timerSubscription: AnyCancellable? = nil
  
  var name: String
  
  var isLEDon = false
  
  var isLedBlinking = false {
    didSet {
      var result = self
      print("in didSet: isLedBlinking: \(result.isLedBlinking) isLEDon: \(result.isLEDon)")
      guard result.isLedBlinking else {
        result.isLEDon = true
        result.ledTimer.upstream.connect().cancel()
        print("Cancelling timer.")
        return
      }
      result.timerSubscription = result.ledTimer
        .sink {  _ in
          result.isLEDon.toggle()
          print("\(result.name) in ledTimer isLEDon: \(result.isLEDon)")
        }
    }
  }
  
  init(_ name: String) {
    self.name = name
  }
}


struct ContentView: View {
  @StateObject var model = Model.loadData
  
  let color = Color(UIColor.label)
  
  public var body: some View {
    VStack {
      Text(model.items[0].name)
      Image(systemName: model.items[0].isLEDon ? "circle.fill" : "circle")
        .foregroundColor(model.items[0].isLEDon ? .green : color)
      Button("Toggle") {
        model.items[0].isLedBlinking.toggle()
      }
    }
    .foregroundColor(color)
  }
}

Touching the "Toggle" button starts the timer that's suppose to blink the circle. The print statement shows the value changing but the view doesn't update. Why??


Solution

  • You can use animation to make it blink, instead of a timer.

    The model of Item gets simplified, you just need a boolean variable, like this:

    struct Item {
        
        var name: String
        
        // Just a toggle: blink/ no blink
        var isLedBlinking = false
    
        init(_ name: String) {
            self.name = name
        }
    }
    

    The "hard work" is done by the view: changing the variable triggers or stops the blinking. The animation does the magic:

    struct ContentView: View {
        @StateObject var model = Model.loadData
        
        let color = Color(UIColor.label)
        
        public var body: some View {
            VStack {
                Text(model.items[0].name)
                    .padding()
                
                // Change based on isLedBlinking
                Image(systemName: model.items[0].isLedBlinking ? "circle.fill" : "circle")
                    .font(.largeTitle)
                    .foregroundColor(model.items[0].isLedBlinking ? .green : color)
                
                    // Animates the view based on isLedBlinking: when is blinking, blinks forever, otherwise does nothing
                    .animation(model.items[0].isLedBlinking ? .easeInOut.repeatForever() : .default, value: model.items[0].isLedBlinking)
                    .padding()
                
                Button("Toggle: \(model.items[0].isLedBlinking ? "Blinking" : "Still")") {
                    model.items[0].isLedBlinking.toggle()
                }
                .padding()
            }
            .foregroundColor(color)
        }
    }