UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

My favorite new Swift API from iOS 15

AsyncSequence and effectful read-only properties combine to make something beautiful.

Paul Hudson       @twostraws

WWDC21 came packed with lots of new features for Swift, SwiftUI, Foundation, and more, but in all the WWDC videos I’ve watched only one made me do a double take – I had to rewind and rewatch it just to make sure I hadn’t misheard.

The feature sounds simple, but makes for quite astonishing code: it’s the lines property on URL, which downloads and returns lines of text from a URL as they are fetched. Internally this builds on a huge amount of functionality, not least effectful read-only properties and AsyncSequence, and the end result is quite remarkable in terms of its simplicity.

In its simplest form this means we can access string data directly from a URL, either local or remote. This is perfect for simpler APIs that vend data line by line, such as plain-text files or CSV – Swift even keeps the connection open as long as necessary so new data can be streamed in as needed.

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Trying it out with SwiftUI

Using this API, we could write a small app to fetch a collection of quotes from a server and display them in a list, all using hardly any code:

struct ContentView: View {
    @State private var quotes = [String]()

    var body: some View {
        List(quotes, id: \.self, rowContent: Text.init)
            .task {
                do {
                    let url = URL(string: "https://hws.dev/quotes.txt")!

                    for try await quote in url.lines {
                        quotes.append(quote)
                    }
                } catch {
                    // Stop adding quotes when an error is thrown
                }
        }
    }
}

Perhaps you can see what made me do the double take in the first place – I was surprised to be able to access lines directly on a URL, rather than routing through URLSession or similar. Even better, if multiple lines arrive at once, @State is smart enough to batch its view reloads to avoid creating extra work.

Parsing a CSV with AsyncSequence

When working with data in CSV format, you can split your data up by commas then assign them to properties in whatever data type you’re using. This is made particularly convenient thanks to AsyncSequence providing a compactMap() method that transforms elements as they arrive, so we can transfer a CSV line into a Swift struct using a custom initializer like this:

struct User: Identifiable {
    let id: Int
    let firstName: String
    let lastName: String
    let country: String

    init?(csv: String) {
        let fields = csv.components(separatedBy: ",")
        guard fields.count == 4 else { return nil }
        self.id = Int(fields[0]) ?? 0
        self.firstName = fields[1]
        self.lastName = fields[2]
        self.country = fields[3]
    }
}

struct ContentView: View {
    @State private var users = [User]()

    var body: some View {
        List(users) { user in
            VStack(alignment: .leading) {
                Text("\(user.firstName) \(user.lastName)")
                    .font(.headline)
                Text(user.country)
            }
        }
        .task {
            do {
                let url = URL(string: "https://hws.dev/users.csv")!
                let userData = url.lines.compactMap(User.init)

                for try await user in userData {
                    users.append(user)
                }
            } catch {
                // Stop adding users when an error is thrown
            }
        }
    }
}

The power of compactMap() is that it returns a new AsyncSequence that automatically applies a transformation as the lines arrive – it won’t force us to await the data before transforming everything.

Streaming in data

One of the most powerful features of URL.lines is that its underlying AsyncSequence will keep the remote connection open for as long as it takes for all the data to arrive. This means your server can deliver initial results as soon as they are available, then send more and more over time, until eventually the request is complete.

Important: When you’re using URL.lines in this way, the system implements a 16KB buffer so that your results get batched up. This stops it from spinning over a loop for thousands of tiny lines, but also means sometimes it won’t execute your loop’s block immediately because it’s waiting for more data.

To try this out, I wrote a simple server-side script to send lines of information separated by one second each, and we can write a small SwiftUI app to see that data arriving piece by piece:

struct ContentView: View {
    @State private var lines = [String]()
    @State private var status = "Fetching…"

    var body: some View {
        VStack {
            Text("Count: \(lines.count)")
            Text("Status: \(status)")
        }
        .task {
                do {
                    let url = URL(string: "https://hws.one/slow-fetch")!

                    for try await line in url.lines {
                        lines.append(line)
                    }

                    status = "Done!"
                } catch {
                    status = "Error thrown."
                }
            }
    }
}

When that runs you’ll see the line count move upwards until the loop finally finishes – and it’s all using await so it won’t block the UI while the network fetch happens.

And there’s more…

We’re using URL.lines here, but really that’s just a helpful convenience wrapper around the data arriving asynchronously. If you want the data immediately, you can bypass the 16KB buffer entirely and read the URL’s resourceBytes property – you’ll get every single byte delivered to you individually to do with as you please, a bit like a network hosepipe spraying the bytes freely until they stop coming:

for try await byte in url.resourceBytes {
    let string = UnicodeScalar(byte)
    print(string)
}

Of course, you can also go the other way and ask Swift to fetch all the data before handing it to you. This is best done using data(from:), like this:

let url = URL(string: "https://hws.one/slow-fetch")!
let (data, _) = try await URLSession.shared.data(from: url)
let string = String(decoding: data, as: UTF8.self)
lines = string.components(separatedBy: "\n")

Tip: As that waits for all the data to come back before continuing, our example app will appear to do nothing for 10 seconds.

Anyway, this was just a quick post to express my appreciation for URL.lines – it’s a small change in the grand scheme of things, but I just love its simplicity and look forward to it being used in real apps.

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.