Episode #586

Control time with Clocks

20 minutes
Published on December 4, 2024

This video is only available to subscribers. Get access to this video and 586 others.

In this episode, we explore the Swift Clock protocol, particularly the utility in testing and Xcode previews. Clocks are essential for managing time-related functionality in code but can complicate tests and previews by causing delays. By creating custom clocks, developers can control time advancement and sleeping, making tests and previews more efficient. Two custom clocks are implemented: an ImmediateClock and a ScaledClock. These allow you to bypass or scale the delay, allowing for quicker iterations in previews. The Immediate Clock skips delays entirely, while the Scaled Clock speeds up the delay by a specified factor. These custom clocks can also be used in tests to avoid unnecessary waiting, enhancing development efficiency.

This episode uses Xcode 16.1, Swift 6.0.

In this episode, we will be exploring the concept of custom clocks and their usefulness in testing an Xcode previews.

Clocks play a crucial role in managing time-related functionality in our code, but can wreak havoc in tests and previews.

By default, any sleep we add will cause test runs to take longer than they should, which can cost you on CI.

Additionally, Xcode previews can be frustrating to work with, which limits the benefits that they bring.

By creating custom clocks, we can control how sleeping and the advancement of time works.

So let's dig into it.

Here I have an example application that when I click the Change Color button, the interface disables.

We see a loading indicator.

And after two seconds to simulate some sort of loading delay, the tint color of the UI changes.

And for me to test this in a preview, to make sure that it's working, I'm actually having to wait for two whole seconds.

And this isn't really what I want.

So let's just assume that this is something that we wanted to iterate on, and this two second delay is something that we can control.

We just want to be able to iterate on this more quickly in previews.

So the answer lies in the clock protocol.

So let's go over to Search Clock in the Concurrency Framework.

And we can see that this is a protocol that's generic over any duration.

And this type must be sendable.

And this is because this is used in the concurrency system.

So it has an associated type for this duration.

And it also has an associated type for an instant.

That instant must conform to instant protocol, and then the duration's instant must match it.

It has a few things that we need to fill out.

One of them is, what is the current time, which returns an instant in time.

What is the minimum resolution, and then a sleep function.

And that's all there is to create a custom clock.

So let's go ahead and explore what it would be like to create our own clock.

So I'm going to create a clock.

I'm going to call this Immediate Clock.

And this is going to conform to that clock protocol.

Now the first thing it's going to complain is that we don't have a duration type, and we don't have an instant type.

So the duration type is...

I just want to use the type that comes with Swift.

So if we do Swift.duration, this standard library type is how we represent things in milliseconds, seconds, etc.

And this is essentially all we would probably want to work with.

I'm not quite sure how we would want to use a different type.

So we're just going to bake in the fact that this Immediate Clock only works with this Swift.duration type.

The next thing we need is an instant, and I'm going to nest this directly in here.

So we're going to call this instant.

And that's going to conform to the instant protocol.

Okay, the first thing we're going to need here is an offset that we want to work with, and that's going to be our duration.

And this duration is going to use this same duration that we are nested inside of.

So that's going to define that relationship between the two.

And we're going to init with an offset that takes a duration, and it will start off with zero.

Then we're going to assign the offset like that.

Let's take a look at the other requirements for instant protocol.

It has to be comparable, hashable, and sendable.

And then it has the associated type duration, and it's got these two functions.

AdvancedByDuration and DurationToOther.

So let's do the AdvancedByDuration first.

When we do that, we're actually going to create an instance of self.

So that's going to be an instant with the offset of our offset plus this duration.

Then we need the DurationTo method.

And we can use self here.

And here we're going to take other offset and minus our offset.

That's going to give us a duration.

And then finally, this has to be comparable.

So we need a static func less than, which will give us our left hand side of self, right hand side of self.

And we'll return if the left hand side offset is less than the right hand side offset.

And that's all we need to make that comparable.

So now our instant protocol is correct.

Let's move down and implement the remaining things that we want here.

So we need to specify a now instant for our type here.

And I'm going to make this private set because I don't want it to be settable externally.

We also have a private set var for the minimum resolution, which is a duration.

And we're going to say our minimum resolution is zero.

And we're automatically seeing this issue here.

The stored property of now of sending conforming class immediate clock is mutable.

This is an error in the Swift6 language mode.

So what this is saying is that because this type is sendable, or this protocol is sendable, which means this type is sendable, it's now saying that we can't actually mutate to this instant here.

If we were to make this a let, it would be fine.

We could say let now equals instant, but then we also have this problem.

Because we don't actually want to change this one, we can change it.

But if we wanted to be able to change the concept of now, then we're going to have to deal with the fact that this is a sendable type.

And in this type, I do want to be able to change the concept of now.

So we need it to be a private set, which means that we're going to have to come up with our own mechanism to synchronize access to this property and avoid mutating it from multiple actors.

The way we're going to do that is with unchecked sendable, which is basically a pinky swear to the compiler that we are going to handle some sort of... that we are going to handle the access to any of the storage inside of this type.

And so to do that, I'm going to create a private var lock, which is an NSLock.

There are multiple ways that you can do this, but the lock is certainly the easiest.

Now let's create the initializer here.

We will start off with an instant, which we're just going to say .init.

And then we can say self.now = now.

And then the last thing we need is that sleep function.

So the point of this one would normally be to say, okay, I'm actually going to say, you know, whatever I need to do to sleep until that deadline.

And I can do this asynchronously, so we can use task.sleep here.

One thing you might want to do is say task.checkCancellation, which throws if this task has been canceled, which means you don't actually need to sleep anymore.

So that's a good thing to throw in there.

But because this is an immediate clock, we're going to basically ignore any of the sleeping and just immediately set the now reference to what our new instance should be.

So here we're sleeping until some deadline, which is some point in the future.

So what we can do now is say that we're going to say now = deadline.

Now we cannot do this without using our lock.

And so the way we do that is by saying lock.withLock.

And then inside of there we mutate.

So that way if multiple actors try to enter this method at the same time, they're all going to have to wait until they acquire a lock before they mutate the value.

So that is essentially the completed immediate clock.

Now we need to figure out how we can use it.

So I'm going to have my content view take a clock.

And this is going to be any clock that is generic over the Swift duration protocol.

Then instead of using task.sleep, we're going to use try/await clock.sleep.

And this is going to take the same duration that we had before.

And because we're using the Swift duration type, we can use seconds like we did before.

So this is the difference.

Instead of using task.sleep, we're using clock.sleep.

Okay, so this is going to require us to add this clock anytime we use it.

So here we're going to say clock is going to be immediate clock.

And we also have a compile error over in our app.swift right here.

And this is going to need a clock.

So in a real application we're going to use a built-in clock.

And there are two clocks that we can use.

We can use the continuous clock or a suspending clock.

A continuous clock is going to just continue to accumulate time even if your app is backrounded.

And a suspending clock is going to suspend that time and only accumulate time when your app is in the foreground.

And so depending on your use case, you'd want to use one or the other.

Typically I think you would want to use continuous.

Now I ran into an error when I used this extension here.

So I'm just going to use the actual type name.

But that seems like a compiler bug that could be fixed soon.

So now our real application is going to use a continuous clock.

But in our preview we're going to use an immediate clock.

Okay, so now let's run our preview.

Now I want to hit change color.

It's happening immediately without having to wait for any wall clock time.

Which is exactly what we wanted.

We could do the same thing if we wanted to test this functionality.

We wouldn't have to wait in our test for any of this code to work either.

Okay, let's try one other example.

I'm going to create a new file here.

So let's go over here and we're going to create a new file.

And we're going to call this one "countdown".

This will be a countdown view.

And I'm just going to paste in a countdown view that we can use here.

This countdown view is going to start off with a starting number and then every second.

So over here in the task we're going to assign our starting number to this value.

Every second in our loop we're going to sleep for one second.

And then with an animation subtract one from the value.

When the value is, you know, when it gets to zero we'll skip and say "on complete".

So then in our application we've got a ZStack which basically puts all of these numbers around an if statement.

And when that value becomes the number this enters the hierarchy.

And we do that so that we trigger this transition to enter and leave the view hierarchy in an animated fashion.

So let's go ahead and add a preview for this.

We're going to add a countdown view.

And here we'll do starting number is 10 and then on complete we can print "complete".

OK, so we can see our countdown view working.

And let's say that we want this to be bold.

OK, now I've got to wait again.

Let's say I want the foreground style to be color.pink.

OK, so every time I do this we have to see that everything starts over.

And let's say I wanted to do something interesting in the ending state.

I would have to wait 10 whole seconds for that to happen.

And we don't want to do that.

So let's go ahead and add a clock.

So we're going to do the same thing as before.

This is going to be generic over the Swift Duration Protocol.

And we need to use "any" here.

Then over here, instead of sleeping, we can do "try a wait clock.sleep" for one second.

And then in our preview here we can add the clock as an immediate clock with a starting number of 10.

And I think I need to do that in the other order.

Immediate clock.

Let's move that up here.

OK, so now when I run the preview you can see it just skips over all of the items.

All of them end up animating at the same time.

Which, maybe that's also not what we want here.

Maybe what we want is to be able to quickly run through this.

So for instance, if this was a 50 second clock, maybe we want to see it speed through the animations just a little bit.

And so immediate clock here doesn't really help us.

So let's do something a little bit more advanced.

So we're going to again create a class called, this time it will be "ScaledClock".

It will be a clock.

And we'll also need to add unchecked sendable.

So it's going to be essentially the same thing as our immediate clock.

So I'm just going to copy the whole thing.

We're going to go over here and I'll paste it in.

We're going to delete those curly braces and then re-indent.

OK, so now our scaled clock is going to do the same thing as the immediate clock.

So one thing I want to add here is a private...

We could probably just do a let and we'll do scale factor.

It's going to be a double.

And then here we're going to pass in that scale factor in our init.

The next thing I want to do is add an extension on our duration type.

And we're going to add a func called "scaledByFactor".

And we're going to add a func called "scaledByFactor" that takes a double and returns a duration.

This is going to start off with a constant.

We have nsec/s, which is nanoseconds per second, which is going to be useful.

But we also need to know the attaseconds per nanosecond.

And that's not a constant we can use, so we're going to use...

We're just going to create one. asec/nsec is a "uint64" and that equals 1 billion.

OK, then we're going to take the total nanoseconds equals a "uint64" for components.seconds.

And we're going to multiply that by nsec/s.

Then we're going to add that to a "uint64" divided by this time, the attaseconds per nsec.

So this gives us our...

And that's not nanoseconds, that's attaseconds.

This gives us our total nanoseconds.

Now we can scale it by taking that, converting it to a double, total nanoseconds, times the scale factor.

And now we can return a new duration, which is going to be nanoseconds, with an "n64" of the scaled nanoseconds rounded.

So it's a little bit hard to put your head around, but if you think about the math, this should work.

So now we have a way to take a duration like 1 second and multiply it by 0.5 to get half a second.

Ok, so now we have our scale factor, we have our sleep function here.

So here we're going to get a duration, which is going to be "now.durationTo" the deadline.

So that's going to be like, you know, if we're sleeping for 1 second, this duration is going to be 1 second.

Then we're going to take the scaled duration.

So "now.durationTo = duration.scaledBy" and this is going to be our scale factor.

Then we can try await task.sleep and this one is going to sleep for that scaled duration.

And then we can set "now" equal to the deadline.

Ok, so with this change now, we are still sleeping, but we're sleeping by a scaled amount.

So let's go down here and instead of the immediate clock, let's use a scaled clock whose scale factor is 0.5.

Now you can see that this is happening twice as fast.

Let's do quarter second.

And that's pretty cool.

So now while this is running, I can actually see the animation happening, I can see everything that's going on.

So let's go to our transition.

Let's say that this opacity for insertion, let's look at removal.

We're using scale combined with opacity.

Maybe we also want to combine that with offset.

And we'll do Y10.

So now I can add some offset here and now we can see that the numbers are kind of moving down as well as scaling down as well as fading out.

And so I think the scaled clock is actually really handy for us to see what's happening in sort of a timed fashion without actually committing to watching the entire thing.

And so we can tweak this scale factor as needed.

So those are two examples of custom clocks that we can use that use the Swift duration type to control time.

Here I just showed previews, but you could also use the same thing for tests to make your tests run fast without actually waiting. waiting.