Timer, Interactive Ticks Using TCA

mein
5 min readDec 11, 2022

By implementing the concurrency function, we will make a timer which not only ticks automatically, but also able to change ticking speed interactively or jump to any tick manually.

If we have a handy timer then we could plot any trajectory of time, including Bezier Curve. Because Bezier Curve is an equation of time.

If you use Bezier Curve everyday while always wandering what those control points for, you may find the animated answer in my final project, SwiftUI Animated Bezier Curve Using TCA. Those control points are for controlling the trajectory of time.

I am using swift-composable-architecture 0.47.2

Basic On/Off Timer

We start by a simple timer. When switch on, it will show ticks from 0 to 10.

We are seeing how an action could trigger another action. For example, the timer clock function is an asynchronous task. It could not change the State directly, instead, it sends an action, called stepForward. And in that action, we change the State accordingly.

  • Reducer
struct BasicTimerReducer: ReducerProtocol {

private enum TimerID {}

struct State: Equatable{
var currentStep : Double = 0.0
var totalSteps : Double = 10.0
var isTimerOn = false
}
enum Action : Equatable{
case stepForward
case startTask
case toggleTimer(Bool)
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action{
case .stepForward:
state.currentStep += 1
if state.currentStep > state.totalSteps{
state.currentStep = 0
}
return .none
case .toggleTimer(let value):
state.isTimerOn = value
if state.isTimerOn
{
return EffectTask(value: .startTask)
}
else{
return .cancel(id: TimerID.self)
}
case .startTask:
let clock = ContinuousClock()
return .run { sender in
while !Task.isCancelled{
do{
try await clock.sleep(for: .seconds(1))
await sender.send(.stepForward)
}
catch{
if !Task.isCancelled{fatalError()}
}
}
}
.cancellable(id: TimerID.self,cancelInFlight: true)
}
}
}
  • View
struct BasicTimerView: View {
let store: StoreOf<BasicTimerReducer>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
Form{
HStack{
Toggle(isOn: viewStore.binding(get: \.isTimerOn, send: BasicTimerReducer.Action.toggleTimer), label: {Text("isTimerOn")})
Text("\(Int(viewStore.currentStep))")

}
}
}
}
}

struct BasicTimerView_Previews: PreviewProvider {
static var previews: some View {
BasicTimerView(store: Store(initialState: .init(), reducer: BasicTimerReducer()))
}
}

Jump To Any Tick

We put a slider on the view so that user could slide to any tick.

For the reducer, this is quite simple. Just add an action, called jumpToTick. It does not need to change the async task, instead, it changes the State directly.

  • Reducer
case .jumpToTick(let value):
state.currentStep = value
return .none
  • View
Slider(value: viewStore.binding(get: \.currentStep, send: BasicTimerReducer.Action.jumpToTick), in: 0...viewStore.totalSteps)

Interactive Speed-up Timer

We need a new action, called setTimerSpeed. But the challenge is, in the async task, it could not read the inout state directly. If you change the timer speed in the State, the async task will not know.

There are two solutions:

  1. Restart the timer so that the async task could read the latest State.
  2. Store the timer speed parameter in an object (reference type, not value type) so that the async task could read the object.

I adopt the second solution. If you have any suggestion, please let me know.

  • Reducer
class TimerSpeedParameter{
var timerSpeed: Double = TimerSpeedParameter.timerSpeedInitValue
static let timerSpeedMax : Double = 100.0
static let timerSpeedInitValue : Double = 50.0
var timerInterval : Double {
return (TimerSpeedParameter.timerSpeedMax + 5 - timerSpeed) * 10
}
}
struct SpeedupTimerReducer: ReducerProtocol {
private var timerSpeedParameter = TimerSpeedParameter()
private enum TimerID {}

struct State: Equatable{
var currentStep : Double = 0.0
var totalSteps : Double = 10.0
var isTimerOn = false
var timerSpeed: Double = TimerSpeedParameter.timerSpeedInitValue
}
enum Action : Equatable{
case stepForward
case startTask
case toggleTimer(Bool)
case jumpToTick(Double)
case setTimerSpeed(Double)
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action{
case .stepForward:
state.currentStep += 1
if state.currentStep > state.totalSteps{
state.currentStep = 0
}
return .none
case .toggleTimer(let value):
state.isTimerOn = value
if state.isTimerOn
{
return EffectTask(value: .startTask)
}
else{
return .cancel(id: TimerID.self)
}
case .startTask:
let clock = ContinuousClock()
return .run { sender in
while !Task.isCancelled{
do{
try await clock.sleep(for: .milliseconds(timerSpeedParameter.timerInterval))
await sender.send(.stepForward)
}
catch{
if !Task.isCancelled{fatalError()}
}
}
}
.cancellable(id: TimerID.self,cancelInFlight: true)
case .jumpToTick(let value):
state.currentStep = value
return .none
case .setTimerSpeed(let value):
timerSpeedParameter.timerSpeed = value
state.timerSpeed = value
return .none
}
}
}
  • View
struct SpeedupTimerView: View {
let store: StoreOf<SpeedupTimerReducer>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
Form{
HStack{
Toggle(isOn: viewStore.binding(get: \.isTimerOn, send: SpeedupTimerReducer.Action.toggleTimer), label: {Text("isTimerOn")})
Text("\(Int(viewStore.currentStep))")
Slider(value: viewStore.binding(get: \.currentStep, send: SpeedupTimerReducer.Action.jumpToTick), in: 0...viewStore.totalSteps)
}
HStack{
Text("Timer Speed")
Slider(value: viewStore.binding(get: \.timerSpeed, send: SpeedupTimerReducer.Action.setTimerSpeed), in: 0...TimerSpeedParameter.timerSpeedMax, label: {Text("Timer Speed")},
minimumValueLabel: {Text(Image(systemName: "tortoise"))},
maximumValueLabel: {Text(Image(systemName: "hare"))}
)

}
}
}
}
}

Auto start/stop timer

In TCA tutorial, they reminds us to stop the async task when view disappear.

So we could add it in our view:

  • onAppear: toggleTimer(true) to auto start the timer
  • onDisappear: toggleTimer(false) to auto stop the timer
            .onAppear(perform: {
viewStore.send(.toggleTimer(true))
})
.onDisappear(perform: {
viewStore.send(.toggleTimer(false))
})

Conclusion

Imagine how would you implement all these functions without TCA library.

I feel TCA makes things a lot more tidy.

The team behind TCA is point free. They have more than 200 video episodes on their website now.

I am still learning from them. Though sometimes I find the video content too advanced.

I enjoy applying TCA on basic ideas. It will show its beauty in the simplest form even just for an entry level project.

--

--