Episode #611

Initial SwiftUI Layer with State-Driven UI

26 minutes
Published on August 7, 2026

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

Now we wire up the API client into SwiftUI by driving an episode list from an @Observable model with an explicit idle/loading/loaded/error state enum. We'll cover loading data in a .task modifier, handling cancellation with Task.checkCancellation, presenting failures with ContentUnavailableView and a retry action, and building an episode row with AsyncImage phases plus bundled sample JSON for fast previews.

Note: in this episode I utilized Development Assets, but forgot to add this to the Xcode project settings. The bundled source code has this fixed.

Links

Code

@MainActor
@Observable
final class EpisodeListModel {
    enum State: Hashable {
        case idle
        case loading
        case loaded([API.V2.Episode])
        case error(String)
    }

    private(set) var state: State = .idle
    private let api = APIClient()

    func load() async {
        guard state != .loading else { return }
        state = .loading
        do {
            let episodes = try await api.fetchEpisodePage()
            try Task.checkCancellation()
            state = .loaded(episodes.data)
        } catch is CancellationError {
            state = .idle
        } catch {
            state = .error(error.localizedDescription)
        }
    }

    func retry() async {
        state = .idle
        await load()
    }
}
struct EpisodeListView: View {
    @State private var model = EpisodeListModel()

    var body: some View {
        ZStack {
            switch model.state {
            case .idle, .loading:
                ProgressView {
                    Text("Loading episodes")
                }
            case .loaded(let episodes):
                EpisodeList(episodes: episodes)
            case .error(let message):
                ContentUnavailableView {
                    Label("Error loading episodes", systemImage: "wifi.exclamationmark")
                } description: {
                    Text(message)
                } actions: {
                    Button {
                        Task { await model.retry() }
                    } label: {
                        Text("Retry")
                    }
                }
            }
        }
        .navigationTitle("NSScreencast")
        .task { await model.load() }
    }
}
struct EpisodeRow: View {
    let episode: API.V2.Episode

    var body: some View {
        HStack {
            thumbnail
            VStack(alignment: .leading) {
                Text("\(episode.episodeNumber)")
                    .font(.caption)
                    .bold()
                    .foregroundStyle(.secondary)
                Text(episode.title)
                    .font(.headline)
                    .lineLimit(2)
                Text(episode.publishedAt, format: .dateTime.month().day().year())
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
    }

    private var thumbnail: some View {
        AsyncImage(url: episode.artworkURLs.small) { phase in
            switch phase {
            case .empty:
                ProgressView()
            case .success(let image):
                image.resizable()
            case .failure:
                Image(systemName: "film")
            @unknown default:
                EmptyView()
            }
        }
        .aspectRatio(contentMode: .fill)
        .frame(width: 112, height: 68)
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }
}
extension API.V2.Episode {
    static var sample: Self {
        let url = Bundle.main.url(forResource: "sample-episode", withExtension: "json")!
        let data = try! Data(contentsOf: url)
        return try! APIClient.decoder.decode(API.V2.Episode.self, from: data)
    }
}

This episode uses Xcode 27.0-beta-4.

OK, so now that we have our API ready to go, let's tie it into some UI. I'm going to create some organization here. We're going to have things organized by feature. So I'm going to create a file folder for features and then a folder for episode list. And then inside of here, we're going to create a new file called episode list view. So this will import SwiftUI and be a struct episode list view. It'll be a view. It'll have a body. For now, we'll just say color.red so that we can set up a preview for this. And we'll say episode list view like that. And I like to set up the preview just right up front so that I can iterate with the UI showing me what we're going to have here. So I do know while that's loading, I'm going to need an episode row, which is also a view. That's going to take an episode. And for now, we're just going to bind to these API models. So I'm going to do API v2 episode. Then we'll have a body for that. And for now, we're just going to do text with the episode title.

OK, so we have our episode list view. I am going to want this in a navigation stack, but I don't think I want that inside of this view. I think the navigation stack will go outside of it. So for now, I'm just going to see what it's going to look like when I put it inside of a navigation stack. And then we're also going to want to have a navigation title on this, which I'm going to say NSScreencast, because this will be like the first screen in the application. OK, so obviously we don't want color.red. We need to have a list of episodes. I'm eventually going to have something like list with some episodes, but I don't have those yet. So for now, I'm just going to say list, and we'll just leave it empty. So we need to start talking about how do we load the data from the API into this view. So I'm going to start off by creating a final class episode list model. This is going to be main actor, and it's also going to be observable. So it'll be an observable model that we use to sort of track the state of what the view should be. I much prefer doing this than putting it inside of the view, but that is a source of a lot of consternation across the SwiftUI community of sort of what the architecture is. And Apple doesn't have like a one size fits all recommendation here. And this is the way that I prefer to do it because it allows me to test these things outside of a view if I want to. And it allows me to construct things in a way that just sort of fits the way I like to work. Okay, so this episode list model is going to need some state. So we're going to need a state here, which will need to be hashable. And the state is going to be idle or loading or loaded, in which case we have some data, which is going to be our API.v2 .episode. Or we're going to have an error, in which case we'll display a string. So that state inside of our episode list model is going to be held in a private setable variable called state. And we'll start off as idle.

Then we're going to have a func here called load. And this is going to be an async function. It's not going to throw us because this is going to end up handling the actual error. So we'll do what we need to do here. And then we're going to catch an error here. And if we get an error, then we can say, hey, the state now is error. And I can pass in that error .localizeDescription.

Okay, so this load function is going to need to have an API client. And that's kind of our first, I guess, major architectural decision that we need to make. Right now I could say that we have an API client. I'll just call it API, API client. And the defaults here are going to be fine for us, for now. This is going to construct a real API client that talks to the real API.

And here we're going to say try await API .fetchEpisodePage. For now I'm just going to pass in nil and nil for the limits. In fact, I think I can just omit those. No, I can't. We should probably do that. That should probably be optional. So we could say fetch episode page. This is going to give us our episodes. We do want to say something like try task.checkCancellation. This is a good habit to get into with Swift concurrency. Because we're doing some work. We've suspended here. And when we come back, we don't want to continue to do any more work or alter the UI or anything if this task has been canceled. Meaning the screen just went away. So if we say try check cancellation here, then we're going to end up getting a cancellation error. In which case, we can just set the state to idle. Or we could leave the state alone. That's another thing we could do. Just leave it the way it was. Not sure what to do here, but that's one option we could do. Okay, so a couple of other things I want to do here. When we are loading, I want to set the state to loading. And I want to make sure that the state of our UI is not already loading. So guard state not equals dot loading. Otherwise, we'll return. Because there's no reason to load twice. So then we go into, we have our episodes now. And now we can say state equals loaded. And then we have our episodes page that has data in it. Okay, so if we just go over to our list view here. I'm just going to output, let's see, we need our observable.

Sorry, we need our state var model. Which is an episode list model. And we can just construct one because it takes no arguments right now. So now we have our model here. And I can say that I want to output the model's state. So let's do a switch on the model's state. And for idle, for now I'm going to say what the state is. We can actually see it idle. In the case of loading, we'll say loading. In the case of loaded, we'll say loaded. Maybe we'll say let episodes. Episodes.count records. And then case.error.let message. Text error with the message. Okay. So this gives us the state that we have in our view. And right now it's idle. So what we need to do here is attach a task onto this view that is going to call await model .load. So what we'll see here is it went to loading quickly and then loaded 50 records. Because it was really fast, we didn't really see the loading state. We could simulate this by saying try await task.sleep. And then we'll say for two seconds before actually loading. So we can see that this actually does go into a loading state. I did actually forget to do that. So state equals loading. That's what we want to do here. Okay. Loading. And then loaded 50 records. Now we don't have any real way to force that an error happened unless I just throw some sort of error here. So let's just throw a decoding error or HTTP error 500.

And what we should see here is that the operation couldn't be completed. And that's what we could show in the UI. Okay. So these are the states that our screen can be in. So let's now enrich this switch statement to do what we want to do. So we're going to treat idle and loading the same and show a progress view. So we can say progress view here. And then that can take a label, in which case we can say that we want to say loading. And so now it'll say loading. We can say loading episodes like that. Okay. So that gives us our progress view. So that's what we want to do. I want to put all of this in a Z stack. So that it sort of expands to fill everything. And then we can move the task here.

Okay. So now we have our progress view. And it's going to say loading while it's loading. If it is loaded, this is where we need to have the episode list. So I'm just going to make that a separate view that I can pass those along. We can go down here and say struct episode list as a view. And a lot of times I'll like to make these things private so that it's a little bit more clear outside of the app how you use it. Because this is sort of an internal detail. So this one is now going to take episodes, which will be an array of API v2 episode. It'll have a body. And then now here we are going to have our list over those episodes. And then we can do episode row for that episode $0.

Fix the episodes label there. And now for the error case, let's get rid of this text. And we're going to use content unavailable view. And that can give us a title, image, and description. Actually, we want to use one that gives us actions as well. Label description and actions. So if we do this, then we'll have a label with a title and system image. The title can be error loading episodes. System image can be wifi dot exclamation mark. Then we have actions. We have a description. And then we also have actions that we can take. So for the description, we can just use a text with that message. And then for the actions, I want to have a retry button here. So we can say button.

We'll do action and label. This will be await model dot retry, which we don't have yet. And then the label will be text retry. For now, we will just make the await. No, we will just make this function retry, which is async. This will set the state to idle and then call await load like we did before. OK. So if I go back to throwing that error, then we should see the content unavailable view. This needs to be a task, actually, because we can't call async functions within this button handler, but we can spin up an unstructured task there.

Sorry, that needs to be throw, not try.

We can also get rid of this list here. Now we have loading episodes, and it says error loading episodes. It couldn't be completed. I can click retry, in which case it goes back to loading, and then we get back to the error. OK. So let's go remove this API error here. And now when we retry, we should see our episodes loaded. And now we have a list of all of our episodes. Let's go make this list style dot plane so that it is full width, and we can decide what to do with our episode rows. So that gives us all 50 of the records that we just downloaded. We will figure out in a future episode how to do paging. But for now, let's just design this episode row. So we're going to take everything and put it in an H stack. So we have the image on the left and some details on the right. So we can have a private bar image here is going to be some view. And we can, let's just call it thumbnail. So the thumbnail can go here. And then this is going to be an async image. And we have URL content and placeholder. I actually want to use the one that has the phase. OK. So we've got URL here, which is going to come from our episode. So we're going to use our artwork URLs. And I'm going to use small here. So the content for that is going to come in as an image phase. And we can then switch on that phase. And this is very similar to the state pattern that we did before. So now we have success, empty, or failure. So in the empty case, we can say that we want a progress view. In the failure case, I'm not sure if we want to change that. We want to log any errors or anything like this. But we do want something to show up. So maybe this is an image with a system name of, I think it's filmstrip. Let's find out.

It's just film. So we can just do film here. And then in the case of the fact that we have success, now we have an image. And here is where we can say, I want an image with, sorry, I want to take that image. And we need to constrain this image in some way. So if I just say resizable, we'll see what happens here. And then there's one other case that we need to consider, which is what if Apple adds a new case here? We can't have this crash. So for unknown default, we're just going to have an empty view, let's say. So that way we have something to fill in if the operating system ever adds new phases here. I'm missing a colon here.

And you can see that I have this H stack with a thumbnail and a text, and it's actually creating like arbitrary extra rows. And that's because my episode row, this is a view builder and it's returning two views. I really want it to be like this.

And we can already see kind of that iterating while we're doing this API call is taking a long time. We can get rid of the sleep, of course, which I would like to do. But the point is here is that while we're iterating, this data isn't really changing. I just want to start, you know, iterating quickly on the UI. And so what I would typically do for that, I'll add another preview. This time will be called episode row. And episode row, I just want to have a single row with a single episode. Now, the question is what episode to put. And this is where I like to have some development assets, which I'm going to create a new folder. And let's call this development assets. This development assets folder, we can add a new empty file. And this is going to call sample episode dot JSON. And then I'm going to paste in some JSON from our API for one of our episodes. Okay, so now I want to have an easy way to get at this episode. So I want to say episode plus sample data here. Then we will create an extension on API.v2 .episode. We'll have a static func or static var sample, which will be an instance of self. And then here I want to create path for resource of type. So we'll say sample episode of type JSON. That's actually just going to be a path. Then we can get the data, which is going to be data contents of. That takes a URL, not a path. So maybe we'll do URL for resource instead. And for some reason it's with extension on this API. I'm going to need foundation at the top for this to work.

Okay, so our path at this point is optional, but I just kind of want it to crash hard if it is not found. And then the same thing for data contents of path here. This is also going to be force unwrap. And actually, no, this needs to be tri-bang so that we can actually get at the data. And now I want to create, get an API decoder, a JSON decoder rather. But I don't want to copy the configuration of this. So if we look up here, I've got a JSON decoder. I kind of want to use this in more places. So what I'm going to do is make a static var decoder. It's a JSON decoder. We're going to move all of this into here.

And then this decoder is going to be the static decoder. So we'll build one for this API client, but then others can grab it as well. So I'm going to go over here and say, instead of just constructing my own, I'm going to say API client dot decoder. And now I can return try decoder dot decode API dot v2 dot episode dot self from that data. So now we have a sample data loader. We can go over here and say sample. We'll switch over to the episode row preview. Okay. Obviously, this isn't the exact size of our row that we want. So we might want to constrain it by saying frame height is equal to, I don't know, 80 points or something. And our image is being stretched. We're going to fix that in a minute. But now we have, without actually hitting the network, we can just start iterating on this episode row. So let's first start by making this thumbnail the constrained aspect ratio. I want to say fill so that it will fill the available space. And note that at this point now, it is respecting its aspect ratio, and then filling the available space in it. I want to constrain the frame width here. And I want to say that maybe this is 88 pixels wide. I'm not quite sure what the number should be.

Okay. So this brings up another kind of fun layout thing about SwiftUI. You can see the text is overlapping this. And the reason is, if I add a border here, I will say color dot red. We'll see that the border is covering the view that I specified, right? 88 points. And then there's some spacing due to the H stack up here. But this image is being clipped outside of that. So this 88 really isn't wide enough for one. But two, we're not actually clipping it. So we do want to have it clipped, which will truncate out the stuff that we can't see. And let's make this width maybe 112 to make it a little bit wider, because these are wide images. I do want the clip shape to be a rounded rectangle with a corner radius of 8.

And then get rid of the border there. And if I take a look at this at a bigger size, this becomes a little hard to see. I think there's a way for me to say preview layout is fixed. Preview layout was deprecated. Okay. Use preview traits size that fits layout. Okay. So the new way is to say traits size that fits layout. But it's still doing our device frame. So not quite sure if I'm missing something. So I'm just going to go back to size that fits layout. Continue. Okay. So this gives us a wide -ish image. I might want this to be a little bit wider, but I'm not quite sure at the moment. We can also set the height of this. If we wanted to say that the height is like, say, 60, or whatever the height ends up being, it's still going to constrain the aspect ratio, but we're going to be able to see more of the image. And it looks like at this point it's clipped top and bottom. So we may want to play with these numbers so that it fits the layout. But we can see that our image is not being stretched within there. Okay. So we have a few other things that I want to do here. For the text, I want to have the episode number, which needs to be converted to a string. So episode dot episode number. And I also want episode dot published at. And for the format here, I want to do date time with month, day, and year. And that should format it in the user's locale however they want. It shows us in a nice way. Let's make the VStack alignment here reading.

We're going to set the font for this to caption as well as the published at date. And then the font here will be headline.

And then we can do line limit 2 here to make it so that it can constrain to smaller width devices. Interesting. Okay. So this lets me get a resizable preview. So that can help me figure out what it looks like if I resize to different sizes, how the truncation works, et cetera. So we might say like, oh, if you're on a really compact device that maybe this image needs to be smaller or hidden or something like that. Yeah. I think this is good enough for now. I want the, I actually want this to be bold. And I want it to be foreground color or foreground style secondary. And I want the secondary also here. Okay. Okay. So, and with that, if we go back to our episode list view, let me switch to just using it on device. We have a few other things that I would like to clean up here. The separator, I think doesn't add any, anything helpful here. So I'm going to go over to here and I'm going to say separator type is going to be hidden. And I think this is probably pretty good. This is close to what we have in, in the live app today. I could probably get a little bit more creative on how to, how to do this. Also, I think that the height can be expanded a bit. Let's try 68 because we have some room because the text often wraps. And so we end up having some room to work with. And I want to make sure that we don't like clip too much of like the text and you know, whatever else is in this artwork.