How to create a custom AsyncSequence
How to create a custom AsyncSequence êŽë š
Updated for Xcode 15
There are only three differences between creating an AsyncSequence
and creating a regular Sequence
:
- We need to conform to the
AsyncSequence
andAsyncIteratorProtocol
protocols. - The
next()
method of our iterator must be markedasync
. - We need to create a
makeAsyncIterator()
method rather thanmakeIterator()
.
That last point technically allows us to create one type that is both a synchronous and asynchronous sequence, although Iâm not sure when that would be a good idea.
Weâre going to build two async sequences so you can see how they work, one simple and one more realistic. First, the simple one, which is an async sequence that doubles numbers every time next()
is called:
struct DoubleGenerator: AsyncSequence, AsyncIteratorProtocol {
typealias Element = Int
var current = 1
mutating func next() async -> Element? {
defer { current &*= 2 }
if current < 0 {
return nil
} else {
return current
}
}
func makeAsyncIterator() -> DoubleGenerator {
self
}
}
let sequence = DoubleGenerator()
for await number in sequence {
print(number)
}
Tips
In case you havenât seen it before, &*=
multiples with overflow, meaning that rather than running out of room when the value goes beyond the highest number of a 64-bit integer, it will instead flip around to be negative. We use this to our advantage, returning nil
when we reach that point.
If you prefer having a separate iterator struct, that also works as with Sequence
and you donât need to adjust the calling code:
struct DoubleGenerator: AsyncSequence {
typealias Element = Int
struct AsyncIterator: AsyncIteratorProtocol {
var current = 1
mutating func next() async -> Element? {
defer { current &*= 2 }
if current < 0 {
return nil
} else {
return current
}
}
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator()
}
}
let sequence = DoubleGenerator()
for await number in sequence {
print(number)
}
Now letâs look at a more complex example, which will periodically fetch a URL thatâs either local or remote, and send back any values that have changed from the previous request.
This is more complex for various reasons:
- Our
next()
method will be markedthrows
, so callers are responsible for handling loop errors. - Between checks weâre going to sleep for some number of seconds, so we donât overload the network. This will be configurable when creating the watcher, but internally it will use
Task.sleep()
. - If we get data back and it hasnât changed, we go around our loop again â wait for some number of seconds, re-fetch the URL, then check again.
- Otherwise, if there has been a change between the old and new data, we overwrite our old data with the new data and send it back.
- If no data is returned from our request, we immediately terminate the iterator by sending back
nil
. - This is important: once our iterator ends, any further attempt to call
next()
must also returnnil
. This is part of the design ofAsyncSequence
, so stick to it.
To add to the complexity a little, Task.sleep()
measures its time in nanoseconds, so to sleep for one second you should specify 1 billion as the sleep amount.
Like I said, this is more complex, but itâs also a useful, real-world example of AsyncSequence
. Itâs also particularly powerful when combined with SwiftUIâs task()
modifier, because the network fetches will automatically start when a view is shown and cancelled when it disappears. This allows you to constantly watch for new data coming in, and stream it directly into your UI.
Anyway, hereâs the code â it creates a URLWatcher
struct that conforms to the AsyncSequence
protocol, along with an example of it being used to display a list of users in a SwiftUI view:
struct URLWatcher: AsyncSequence, AsyncIteratorProtocol {
typealias Element = Data
let url: URL
let delay: Int
private var comparisonData: Data?
private var isActive = true
init(url: URL, delay: Int = 10) {
self.url = url
self.delay = delay
}
mutating func next() async throws -> Element? {
// Once we're inactive always return nil immediately
guard isActive else { return nil }
if comparisonData == nil {
// If this is our first iteration, return the initial value
comparisonData = try await fetchData()
} else {
// Otherwise, sleep for a while and see if our data changed
while true {
try await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000)
let latestData = try await fetchData()
if latestData != comparisonData {
// New data is different from previous data,
// so update previous data and send it back
comparisonData = latestData
break
}
}
}
if comparisonData == nil {
isActive = false
return nil
} else {
return comparisonData
}
}
private func fetchData() async throws -> Element {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
func makeAsyncIterator() -> URLWatcher {
self
}
}
// As an example of URLWatcher in action, try something like this:
struct User: Identifiable, Decodable {
let id: Int
let name: String
}
struct ContentView: View {
@State private var users = [User]()
var body: some View {
List(users) { user in
Text(user.name)
}
.task {
// continuously check the URL watcher for data
await fetchUsers()
}
}
func fetchUsers() async {
let url = URL(fileURLWithPath: "FILENAMEHERE.json")
let urlWatcher = URLWatcher(url: url, delay: 3)
do {
for try await data in urlWatcher {
try withAnimation {
users = try JSONDecoder().decode([User].self, from: data)
}
}
} catch {
// just bail out
}
}
}
To make that work in your own project, replace âFILENAMEHEREâ with the location of a local file you can test with. For example, I might use /Users/twostraws/users.json, giving that file the following example contents:
[
{
"id": 1,
"name": "Paul"
}
]
When the code first runs the list will show Paul, but if you edit the JSON file and re-save with extra users, they will just slide into the SwiftUI list automatically.