SwiftUI - Update state on change of props

How can I update state in SwiftUI on change of props (in React terminology)?

In React there is getDerivedStateFromProps(), in Flutter there is didUpdateWidget(). What is the SwiftUI equivalent?

Motivational example:

There is DiscoView which is just a hardcoded text that should blink with the given period.
There is a parent view which may change that period. When period changes, I need to update the timer somehow.

let colors: [Color] = [.red, .green, .black, .yellow, .pink, .blue]

struct DiscoView: View {
    var period: TimeInterval

    @State var color: Int = 0
    @State var timer: Timer? = nil

    var body: some View {
        Text("DISCO")
        .foregroundColor(colors[color])
        .onAppear {
            self.timer = Timer.scheduledTimer(withTimeInterval: self.period, repeats: true) { _ in
                self.color = (self.color + 1) % colors.count
            }
        }
        .onDisappear {
            self.timer?.invalidate()
        }
    }
}

struct DiscoManager: View {
    @State var period: TimeInterval = 1

    var body: some View {
        VStack {
            DiscoView(period: self.period)
            HStack {
                Button("Slower") {
                    self.period *= 2
                }
                Button("Faster") {
                    self.period /= 2
                }
            }
        }
    }
}

I think you need a @Binding... however, my solution doesn't really seem to solve your problem as when I watch the colors, the blinking rate doesn't look different even though the period variable is definitely changing...

let colors: [Color] = [.red, .green, .black, .yellow, .pink, .blue]

struct DiscoView: View {
  @Binding var period: TimeInterval

  @State var color: Int = 0
  @State var timer: Timer? = nil

  var body: some View {
    Text("DISCO - period: \(String(period))")
      .foregroundColor(colors[color])
      .onAppear {
        self.timer = Timer.scheduledTimer(withTimeInterval: self.period, repeats: true) { _ in
          self.color = (self.color + 1) % colors.count
        }
    }
    .onDisappear {
      self.timer?.invalidate()
    }
  }
}

struct DiscoManager: View {
  @State var period: TimeInterval = 1

  var body: some View {
    VStack {
      DiscoView(period: self.$period)
      HStack {
        Button("Slower") {
          self.period *= 2
        }
        Button("Faster") {
          self.period /= 2
        }
      }
    }
  }
}

I'm circumventing the more general question, but in this case you could just do it like this, by making Timer a publisher:

let colors: [Color] = [.red, .green, .black, .yellow, .pink, .blue]

struct DiscoView: View {
    var period: TimeInterval

    @State var color: Int = 0

    var body: some View {
        Text("DISCO")
        .foregroundColor(colors[color])
        .onReceive(
            Timer.publish(every: period, on: .main, in: .default).autoconnect()
        ) { _ in
            self.color = (self.color + 1) % colors.count
        }
    }
}

struct DiscoManager: View {
    @State var period: TimeInterval = 1

    var body: some View {
        VStack {
            DiscoView(period: self.period)
            HStack {
                Button("Slower") {
                    self.period *= 2
                }
                Button("Faster") {
                    self.period /= 2
                }
            }
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        DiscoManager()
    }
}

if you separate model from view, it becomes much easier to manage your state

let colors: [Color] = [.red, .green, .black, .yellow, .pink, .blue]
class DiscoManager: ObservableObject {
    @Published var currentColor: Color = colors.first!

    private var discoState: Int = 0

    var period: TimeInterval = 1 {
        didSet {
            self.timer.invalidate()
            self.timer = Timer.scheduledTimer(
                withTimeInterval: period,
                repeats: true,
                block: { _ in
                    self.discoState = (self.discoState + 1) % colors.count
                    self.currentColor = colors[self.discoState]
                }
            )
        }
    }

    private var timer: Timer = Timer()

    init() { }
}

struct DiscoView: View {
    @EnvironmentObject var discoManager: DiscoManager
    
    var body: some View {
        Text("DISCO")
            .foregroundColor(self.discoManager.currentColor)
    }
}

struct DiscoController: View {
    let discoManager: DiscoManager = DiscoManager()
    var body: some View {
        VStack {
            DiscoView()
            HStack {
                Button("Slower") {
                    self.discoManager.period *= 2
                }
                Button("Faster") {
                    self.discoManager.period /= 2
                }
            }
        }.environmentObject(discoManager)
    }
}
1 Like
.onReceive(
    Timer.publish(every: period, on: .main, in: .default).autoconnect()
) { _ in
    self.color = (self.color + 1) % colors.count
}

This creates new instance of NSTimer on every execution of body. :frowning_face:

Yes, you are right, that solution was probably a bit too quick and dirty.

Update for iOS 14.

Looks like the newly introduced View.onChange(of:perform:) is an answer to this:

struct DiscoView: View {
    var period: TimeInterval

    @State var color: Int = 0
    @State var timer: Timer? = nil

    var body: some View {
        Text("DISCO")
        .foregroundColor(colors[color])
        .onAppear {
            self.timer = makeTimer()
        }
        .onDisappear {
            self.timer?.invalidate()
        }
        .onChange(of: period) { [period] newPeriod in
            print("Period changed from \(period) to \(newPeriod)")
            if self.timer?.timeInterval != newPeriod {
                self.timer?.invalidate()
                self.timer = makeTimer()
            }
        }
    }

    func makeTimer() -> Timer {
        Timer.scheduledTimer(withTimeInterval: self.period, repeats: true) { _ in
            self.color = (self.color + 1) % colors.count
        }
    }
}
2 Likes