Home > Net >  Using reduce to change struct in Swift
Using reduce to change struct in Swift

Time:10-25

I'm trying to understand how to change a struct (or class?) in an array by using reduce. Creating 4 countdown timers, on tap pause the current timer and start the next. So I tried something like this:

    var timer1 = CountdownTimer()
    // var timer2 = CountdownTimer() etc.
    .onTapGesture(perform: {
        var timers: [CountdownTimer] = [timer1, timer2, timer3, timer4]
        var last = timers.reduce (false) {
          (setActive: Bool, nextValue: CountdownTimer) -> Bool in
          if (nextValue.isActive) {
            nextValue.isActive = false;
            return true
          } else {
            return false
          }
        }
        if (last) {
          var timer = timers[0]
          timer.isActive = true
        }
    })


############# CountdownTimer is a struct ######

struct CountdownTimer {
  var timeRemaining: CGFloat = 1000
  var isActive: Bool = false
}

This does not work, two errors I'm seeing

  1. the timers in the array are copies of the timers, not the actual timer so changing them doesn't actually change the timers being displayed on screen.
  2. nextValue (i.e. the next timer) can't be changed because it's a let variable in the reduce declaration. I don't know how to change this (or if it's even relevant because presumably it's a copy of the copy of the timer and not the one I actually want to change).

Am I approaching this in a way thats idiomatically wrong for Swift? How should I be changing the original timers?

CodePudding user response:

I agree with Paul about the fact that this should likely all be pulled out into an observable model object. I'd make that model hold an arbitrary list of timers, and the index of the currently active timer. (I'll give an example of that at the end.)

But it's still worth exploring how you would make this work.

First, SwiftUI Views are not the actual view on the screen like in UIKit. They are descriptions of the view on the screen. They're data. They can be copied and destroyed at any time. So they're readonly objects. The way you keep track of their writable state is through @State properties (or similar things like @Binding, @StateObject, @ObservedObject and the like). So your properties need to be marked @State.

@State var timer1 = CountdownTimer()
@State var timer2 = CountdownTimer()
@State var timer3 = CountdownTimer()
@State var timer4 = CountdownTimer()

As you've discovered, this kind of code doesn't work:

var timer = timer1
timer.isActive = true

That makes a copy of timer1 and modifies the copy. Instead, you want WriteableKeyPath to access the property itself. For example:

let timer = \Self.timer1  // Note capital Self
self[keyPath: timer].isActive = true

Finally, reduce is the wrong tool for this. The point of reduce is to reduce a sequence to a single value. It should never have side-effects like modifying the values. Instead, you just want to find the right elements, and then change them.

To do that, it would be nice to be able to easily track "this element and the next one, and the last element is followed by the first." That seems very complicated, but it's surprisingly simple if you include Swift Algorithms. That gives cycled(), which returns a Sequence that repeats its input forever. Why is that useful? Because then you can do this:

zip(timers, timers.cycled().dropFirst())

This returns

(value1, value2)
(value2, value3)
(value3, value4)
(value4, value1)

Perfect. With that I can fetch the first active timer (keypath) and its successor, and update them:

let timers = [\Self.timer1, \.timer2, \.timer3, \.timer4]

if let (current, next) = zip(timers, timers.cycled().dropFirst())
    .first(where: { self[keyPath: $0.0].isActive })
{
    self[keyPath: current].isActive = false
    self[keyPath: next].isActive = true
}

That said, I wouldn't do that. There are subtle requirements here that should be captured in a type. In particular, you have this assumption that there is only one active timer, but nothing enforces that. If that's what you mean, you should make a type that says so. For example:

class TimerBank: ObservableObject {
    @Published private(set) var timers: [CGFloat] = []
    @Published private(set) var active: Int?
    var count: Int { timers.count }

    init(timers: [CGFloat]) {
        self.timers = timers
        self.active = timers.startIndex
    }

    func addTimer(timeRemaining: CGFloat = 1000) {
        timers.append(timeRemaining)
    }

    func start(index: Int? = nil) {
        if let index = index {
            active = index
        } else {
            active = timers.startIndex
        }
    }

    func stop() {
        active = nil
    }

    func cycle() {
        if let currentActive = active {
            active = (currentActive   1) % timers.count
            print("active = \(active)")
        } else {
            active = timers.startIndex
            print("(init) active = \(active)")
        }
    }
}

With this, timerBank.cycle() replaces your reduce.

CodePudding user response:

By using the modulus operator ( % ) on Index, we could cycle through last to first without zipping.

let timers = [\Self.timer1, \.timer2, \.timer3, \.timer4]

if let onIndex = timers.firstIndex(where: { self[keyPath: $0].isActive }) {
    self[keyPath: timers[onIndex]].isActive = false

    let nextIndex = (onIndex   1) % 4 // instead of 4, could use timers.count
    self[keyPath: timers[nextIndex]].isActive = true
}
  • Related