You are currently viewing How to Create Callback-like Behavior Using AsyncStream in Swift

How to Create Callback-like Behavior Using AsyncStream in Swift

Without a doubt, Swift Concurrency has revolutionized the way we handle asynchronous code in Swift. One powerful component of it is AsyncStream, a specialized form of AsyncSequence that is well-suited to achieve callback- or delegate-like behavior using the async/await syntax.

Prior to Swift Concurrency, developers had to rely on closures to trigger callbacks and inform callers about certain events during asynchronous operations. However, with the introduction of AsyncStream, this closure-based approach can now be replaced with a more intuitive and straightforward async/await syntax.

In this article, let’s explore a simple yet illustrative example of how AsyncStream can be utilized to track the progress of a download operation. By the end of this reading, you will have a good understanding of how AsyncStream works and start using it in your own projects.

So, without further ado, let’s get started.


The Sample App

In order to showcase the power of AsyncStream, let’s create a sample app that will simulate a download operation and display the download progress using a progress bar.

How to Create Callback-like Behavior Using AsyncStream in Swift

To emulate the waiting period typically associated with a file download, I have created a File struct with a performDownload() method that will sleep for a random amount of duration.

struct File {
    
    let name: String
    
    func performDownload() async {
        
        // Sleep for a random amount of time to emulate the wait required to download a file
        let downloadTime = Double.random(in: 0.03...0.5)
        try? await Task.sleep(for: .seconds(downloadTime))
    }
}

In a real-life situation, this performDownload() method will most likely consist of code that connects to a server and waits for its response.

With that explanation out of the way, let’s delve into the interesting part.


Creating an AsyncStream

First of all, let’s create a downloader class (FileDownloader) that accept an array of File object and download them one by one, and after each successful download, it will inform the caller by providing the filename of the downloaded file.

To achieve such a behavior, the closure-based approach most likely looks something like this:

static func download(_ files: [File], completion: (String) -> Void) {
    
    // Download each file and trigger completion handler
    // ...
    // ...
}

However, if we opt for the async/await syntax, we will need to replace the completion handler with an AsyncStream.

static func download(_ files: [File]) -> AsyncStream<String> {
    
    // Init AsyncStream with element type = `String`
    let stream = AsyncStream(String.self) { continuation in
    
        // Perform download operation and yield the downloaded file's filename
        // ...
        // ...
    }    

    return stream
}

As shown in the code above, we can initialize an AsyncStream by giving it an element type and a custom closure that yields elements to the AsyncStream. In our case, we will set the element type as String because our closure will yield the downloaded file’s filename every time a download is successful.

With that in place, we can implement the custom closure that performs the download operation like so:

// Init AsyncStream with element type = `String`
let stream = AsyncStream(String.self) { continuation in
    
    Task {
        for file in files {
            
            // Download the file
            await file.performDownload()
            
            // Yield the element (filename) when download is completed
            continuation.yield(file.name)
        }
        
        // All files are downloaded
        // Call the continuation’s finish() method when there are no further elements to produce
        continuation.finish()
    }
}

One essential point to remember while using an AsyncStream is to call the continuation’s finish() method after completing all the operations. This step is crucial as failure to do so would lead to indefinite waiting at the call site, causing unintended and undesirable behavior in our apps.


Consuming the AsyncStream

With the FileDownloader in place, it’s time to integrate it with the user interface to display download progress. To get started, we’ll create 50 File objects and trigger the download process.

let totalFile = 50

// Generate file objects
let files = (1...totalFile).map { File(name: "Image_\($0).jpg") }

// Start download
let downloaderStream = FileDownloader.download(files)

Now, to display the download progress on the UI, we’ll utilize the given AsyncStream instance (downloaderStream), and leverage the forawaitin syntax to process each filename as the stream produces it when the continuation’s yield() method being called.

Task {
    var downloadedFile = 0
    for await filename in downloaderStream {

        downloadedFile += 1
        
        // Update progress bar
        progressBar.progress = Float(downloadedFile) / Float(totalFile)
        
        // Update status label
        statusLabel.text = "Downloaded \(filename)"
    }
    
    statusLabel.text = "Download completed"
}

As mentioned earlier, calling the continuation’s finish() method is an essential step. Without this step, the for loop will be awaiting indefinitely, and the status message will not change to “Download completed”.

If you’d like to try this out yourself, you can find the full sample code on GitHub.


Further Readings


I hope you find this article helpful. Feel free to follow me on Twitter and LinkedIn and subscribe to my newsletter so that you won’t miss out on any of my upcoming articles.

Thanks for reading. 👨🏻‍💻


👋🏻 Hey!

While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.

  • Bartender: Superpower your menu bar and take full control over your menu bar items.
  • CleanShot X: The best screen capture app I’ve ever used.
  • PixelSnap: Measure on-screen elements with ease and precision.
  • iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.