This video uses Xcode 14’s Task.sleep(until:clock:)
. If you use Xcode 13, replace this with Task.sleep(nanoseconds: 1_000_000_000)
.
Final Blabber app
For the rest of this part of the course, you’ll work on this messaging app. In the next two episodes, you’ll add a countdown timer and show notifications of users arriving and leaving. And in episodes 7 and 8, you’ll implement this Location button.
Run the course server
Most of the projects in this course interact with a server. It’s included in the course materials.
In Finder, open the course materials folder and find the CourseServer folder. CourseServer* is a Vapor server that you run in Terminal, so open Terminal next to your Finder window.
First, you need to change directory to CourseServer: In Terminal, type cd
then drag the CourseServer folder from Finder into Terminal and press Return.
Type ls
to list the files in CourseServer. This Package.swift file is what you’ll run to start the server.
Type swift run
to run the Swift package. This takes a few minutes to fetch all the dependencies and build the server.
When you see this Server starting...
message, check the connection by opening a browser window and loading localhost:8080/hello
. Stop the server by pressing Control-C.
And restart it with swift run
: Press the up-arrow to get swift run
back then press Return. Restarting is quick because you already downloaded everything.
If you leave this server running, it might stop serving data correctly. If the app doesn’t work as you expect, stop and restart the course server.
Starter Blabber app
In the course materials, locate the Blabber starter project and open it.
In the project navigator, open the Views and Model groups.
Starter projects in this course have all the views, navigation and data model already set up, so you can concentrate on writing the concurrency code to make the app work.
The views you’ll be working on are outside the Views group, for quick access. Build and run the app.
You can login to see the chat screen with the status message “1 active users” and maybe a random message from Bottley the chat bot.
You can send messages, but it’s not much fun chatting with only Bottley.
Build and run on another simulator, login as your favorite author and make up a conversation.
Here’s a quick walk through of the code that’s implementing this basic chat app.
Starter code
In BlabberModel, the MainActor
chat()
method already has the usual URLSession
code:
let (stream, response) = try await liveURLSession.bytes(from: url)
Like the LittleJohn stock ticker app in the preceding course, Blabber uses the liveURLSession
custom session. It makes requests that never expire or time out so the app can keep receiving from the server indefinitely.
Scroll down a little to see some try await
code:
try await withTaskCancellationHandler {
print("End live updates")
messages = []
} operation: {
try await readMessages(stream: stream)
}
Once it establishes a connection, chat()
calls readMessages(stream:)
as the operation
of the withTaskCancellationHandler
method. This executes its operation and immediately invokes the handler if the task is canceled.
Parsing the server responses
Scroll down a little to readMessages(stream:)
:
readMessages(stream:)
looks a lot like what you did in LittleJohn, fetching and showing stock prices, but there’s one difference: The asynchronous sequence lines are not all the same. You have to process the first line separate from the users’ chat messages.
When your app opens a connection to the server, the first line it receives is a status message like “activeUsers”: 4. The chat messages follow, on separate text lines.
So you handle the status message first, with this iterator,
var iterator = stream.lines.makeAsyncIterator()
guard let first = try await iterator.next() else {
throw "No response from server"
}
You use an iterator when you know exactly how many lines you want to handle — in this case, just one.
iterator.next()
is the first line, the status message.
The text line is converted to Data
and then decoded to a ServerStatus
message:
guard
let data = first.data(using: .utf8),
let status = try? JSONDecoder()
.decode(ServerStatus.self, from: data) else {
throw "Invalid response from server"
}
Jump to the definition of ServerStatus
: It’s a data model containing the single property activeUsers
— this keeps track of how many users are in the chat at the moment.
Storing and using the chat information
There’s also a Message
data model. It has a convenience initializer that only needs a message
value.
Click back to BlabberModel
. Scroll up. There’s this published array of messages.
Now, jump back to readMessages()
. And this is where the first message gets stored:
messages.append(
Message(
message: "\(status.activeUsers) active users"
)
)
This first status line in the server response uses the Message
convenience initializer to create a system message.
After this, there’s an open-ended sequence of user messages, processed in a for
loop, just like the stock prices in LittleJohn:
for try await line in stream.lines {
if let data = line.data(using: .utf8),
let update = try? JSONDecoder().decode(Message.self, from: data) {
messages.append(update)
}
}
If a line decodes as a Message
, it’s added to the messages
array. So the starter app has the basic chat facility. It’s time to add your first feature.
Creating an asynchronous timer with AsyncStream
In the simulator, the chat view has two buttons next to the Message field: You’ll implement the show-location button in episode 7, and you’ll implement this countdown button now.
In a chat app, a countdown feature adds an element of drama by counting down before showing the latest message. It will start at 3, count down to 1, then terminate with the user’s message.
You’ll implement this with a pull-based AsyncStream
.
Open ChatView and scroll down to the second Button
in the HStack
.
Button(action: {
Task {
do {
let countdownMessage = message
message = ""
try await model.countdown(to: countdownMessage)
} catch {
lastErrorMessage = error.localizedDescription
}
}
}, label: {
Image(systemName: "timer")
.font(.title)
.foregroundColor(Color.gray)
})
This button’s action calls the model’s countdown(to:)
method.
Jump to countdown(to:)
in BlabberModel. Add some code:
var countdown = 3
let counter = AsyncStream<String> {
}
You initialize your counter variable countdown
to 3, then create a pull-based AsyncStream
that produces String
values.
In its asynchronous closure, you need to return the next sequence value. First, wait 1 second:
do {
try await Task.sleep(until: .now + .seconds(1),
clock: .continuous)
} catch {
return nil
}
Task.sleep(...)
throws a CancellationError
if the current task is canceled while it’s sleeping. Throwing an error is the quickest way to cleanly and safely exit the current execution without waiting the specified amount of time.
After the 1-second wait, return a message, depending on the countdown
value:
switch countdown {
case (1...): return "\(countdown)..."
case 0: return "🎉 " + message
default: return nil
}
If countdown
is 3, 2 or 1, return that value. If countdown
is 0, return the user’s message. When countdown
becomes -1, return nil
to signal the end of the sequence.
Now, just before the switch
, decrement countdown
:
defer { countdown -= 1 }
🟥
switch countdown {
case (1...): return "\(countdown)..."
case 0: return "🎉 " + message
default: return nil
}
All the switch
cases exit the closure, so you use a defer
statement to squeeze in the decrement.
Add this code at the bottom of the method, outside the counter
closure:
for await countdownMessage in counter {
try await say(countdownMessage)
}
This for-await
loop asks for the next element in the counter
async sequence until it gets nil
.
Build and run the app. Login, type a message, then tap the timer button. You get a 3-2-1 countdown and then your message appears.
Well done, you’ve created a timer with an unfolding, pull-based AsyncStream
. In the next episode, you’ll create a buffered AsyncStream
to monitor notifications.