Building a Star Wars App with SwiftUI + Combine (Part 3)

Applying Infinite Scrolling with SwiftUI & Combine

As a continuation of our Star Wars App we’ll be pulling more characters from the API.

We can do this in many ways, by pages, with buttons, and some more, but I believe a nice thing to apply is Infinite Scrolling.

Basically Infinite Scrolling will load pages as the user scrolls down.

In our case the API has a finite number of items so the infinite is not that real because when the API has no more items to load it will stop.

If we’re using an API that keeps querying a database of any sort, and the items keeps coming endlessly, well, then the Infinite Scrolling is going to be a real thing and this is a great solution to apply.


First let’s remove some unused code

In our ViewModel we have defined a peopleList array, that we’re instantiating but never using.

Let’s get rid of that!

Remove the property itself and the reference in the Init()

Now our CharactersViewModel looks like this:

@Observable
class CharactersViewModel {
    enum LoadingState {
        case loading
        case loaded(characters: [People])
        case error
    }
    
    var nextPageURL: String?
    var currentPageURL = "https://swapi.tech/api/people"
    var cancellable = Set<AnyCancellable>()
    let api: API?
    var state = LoadingState.loading
    var useMockData: Bool
    
    init(api: API? = nil, useMockData: Bool = false) {
        self.api = api
        self.useMockData = useMockData
        getPeople()
    }
    
    func getPeople() {
        if useMockData {
            state = .loaded(characters: People.mockData())
        } else {
            let url = nextPageURL ?? currentPageURL
            
            if let api {
                api.getPeople(from: url)
                    .sink(receiveCompletion: { completion in
                        switch completion {
                        case .finished:
                            print("finished")
                        case .failure(_):
                            self.state = .error
                        }
                    }, receiveValue: { [weak self] val in
                        guard let self else { return }
                        
                        self.state = .loaded(characters: val.results)
                    })
                    .store(in: &cancellable)
            }
        }
    }
}

As you can see we’re sending the data collection through our enum .loaded with the People associated value as a parameter.

Run the app to confirm that everything keeps working.

Good, commit and move on.

Remember to commit clear messages instead of just WIP.

If you look into the history of repo you’ll see this particular message for this operation:

Remove unused peopleList from CharactersViewModel -> The collection is being passed as an enum associated value

In this way we communicate to other readers what this commit does.

Remember that to be better coders we need to improve our communication, always.

Let’s move on.


Think conceptually how the Infinite Scroll is going to work

Definition of the Infinite Scroll

We have our tools to make whatever we want, but first we need to know for sure what it is.

We will introduce Infinite Scrolling, or at least until the API have nothing left to return in our case, because our API is finite.

The same procedure can be used in an API that returns an infinite number of items, like pulling data from a database or whatever, and the experience would be real infinite.

This means that we’ll make it seamless to the user, with no buttons to fire the next page whatsoever, no page indicators and nothing more than an endless scroll.


Define the steps to follow

Describe the steps

In our case we’re going to Load our first 10 characters as we’re doing now, then from the view we will listen when the scroll is approaching to the end of this first 10 characters, then we’ll ask for more characters to the viewModel. 

If the API have more items we’ll append this new items to the existing collection and load them into the view.

Define the steps

  1. Initial Loading (10 characters) (already working)
  2. When we approach the last character we ask for more characters
  3. If there are more items we append them to the collection
  4. We load the new items on screen

Start the implementation of Infinite Scrolling

Since our first step is already in place let’s continue with the second one.

Check when we approach the end of the current collection

In this second step we’ll listen from the view when we approach the end of the collection.

To do this we can compare the id of the appearing character in the screen with the id of the latest character in the collection ( if chad.uid == characters.last?.uid). We do this inside the ForEach where we have access to the individual items (character, or chad in the code 🙂)

If the ids are the same we have the place where to call for more items.

Let’s print something to probe our point.

ForEach(characters) { chad in
    CharacterView(character: chad)
        .onTapGesture {
            selectedCharacter = chad
        }
        .padding(.vertical, 8)
        .onAppear {
            if chad.uid == characters.last?.uid {
                print("approaching the end")
            }
        }
}

Give it a try and scroll down.

Cool, approaching the end prints in the console every time you scroll and approach the bottom of the collection.

Now that we know we’re approaching the end of the collection we have a place to call for more characters.


Applying Infinite Scrolling requires some model changes

Modifying PeopleResponse model

Since the API provides us with the next attribute (a complete URL for each page) we can take advantage of it and set the value for the next URL with the next value after it is fetched the initial set of items.

We define next as Optional, so, if the value comes as nil, we don’t try to load any further items, avoiding unnecessary network calls.

In our PeopleResponse model add let next: String? just before results and add it to the CodingKeys enum to let the whole model be properly decoded, like this:

struct PeopleResponse: Codable {
    let message: String
    let records: Int
    let pages: Int
    let next: String?
    let results: [People]
    
    enum CodingKeys: String, CodingKey {
        case message
        case records = "total_records"
        case pages = "total_pages"
        case next
        case results
    }
}

Back in our viewModel

Change how the url is formed.

The guard statement inside getPeople()function should look like this now:

guard let url = nextPageURL else { return }

This way we can set a default value with the initial url and then set the new next value as new items appears.

var nextPageURL: String? = "https://swapi.tech/api/people"

Modify receiveValue

Now, change the .sink operator on the receivedValue to look like this:

receiveValue: { [weak self] response in // rename val to response please :)
    guard let self else { return }
    
    self.allCharacters.append(contentsOf: response.results)
    self.state = .loaded(characters: self.allCharacters)
    self.nextPageURL = response.next

Here we will store the current array of items locally and append new items as they appear.

Then set the state with the updated associated value allCharacters in it and set the nextPageURL with the next that comes from the response.

Don’t forget to declare private var allCharacters = [People]() at the top of the class to hold the collection (yes, we remove this at the beginning of the post but is not completely the same thing 🙂)


Back in the view

Now we can call the same function in our view instead of just print statement.

.onAppear {
    if chad.uid == characters.last?.uid {
        charactersViewModel.getPeople()
    }
}

The function is the same, which is cool, the ViewModel knows how to deal with the different pages as necessary.

Give it a try!

It works great!

Let’s commit!


Improving the User Experience

Now pages are appearing seamlessly at the bottom of the screen, but.. it wouldn’t be nice to let the user know what is going on? Of course it is.

Let’s add the same ProgressView indicator, as the one we’re using for initial loading, but at the bottom of the screen when a new page is loading.

When I say the same I say the same kind, but indeed is another ProgressView than the one at loading time.

This will be managed from the ViewModel as well so add a new value to it.

var isLoadingMoreItems: Bool = false

Now, in our get people function, toggle it to true, just before the api start to make a new call and toggle it back to false at the end of the receiveValue closure.

Now your View Model should look like this:

@Observable
class CharactersViewModel {
    enum LoadingState {
        case loading
        case loaded(characters: [People])
        case error
    }
    
    private var allCharacters = [People]()
    var nextPageURL: String? = "https://swapi.tech/api/people"
    var cancellable = Set<AnyCancellable>()
    let api: API?
    var state = LoadingState.loading
    var useMockData: Bool
    var showProgressView: Bool = false // 1) Set the new property
    
    init(api: API? = nil, useMockData: Bool = false) {
        self.api = api
        self.useMockData = useMockData
        getPeople()
    }
    
    func getPeople() {
        if useMockData {
            state = .loaded(characters: People.mockData())
        } else {
            guard let url = nextPageURL else { return }
            
            if let api {
                self.showProgressView = true // 2) Toggle it to true
                api.getPeople(from: url)
                    .sink(receiveCompletion: { completion in
                        switch completion {
                        case .finished:
                            print("finished")
                        case .failure(_):
                            self.state = .error
                        }
                    }, receiveValue: { [weak self] response in
                        guard let self else { return }
                        
                        self.allCharacters.append(contentsOf: response.results)
                        self.state = .loaded(characters: self.allCharacters)
                        self.nextPageURL = response.next
                        self.showProgressView = false // 2) Toggle it to false
                    })
                    .store(in: &cancellable)
            }
        }
    }
}

Test it on the screen.

When you scroll down a little ProgressView appears for a brief moment.

I like it, do you?

Let’s commit and move on.


Adding more images

As you can see the placeholder for the images is taking place in all subsequent images, that’s what we expected.. now let’s get those images from the web 🥱

(A few hours later..)

Ok, now the images were added, yes, all manual stuff.

Don’t forget to check out the repo here: https://github.com/FeRHeDio/Swapi-demo

I forgot to mention that I’ve added the id’s to be rendered on the UI, along with the name of the Character and the image.

This helped me to name the different images as I was grabbing them from the network.

We can keep it, let’s move on!


Adding Pull to Refresh mechanism

It’s almost an instinct for users to pull and refresh, doesn’t it?

Well, adding such functionality In SwiftUI it’s a breeze.

To know for sure what we’re doing let’s add some print statements before hand.

In the .finished case in our receiveCompletion inside the ViewModel let’s print the url that we’re loading.

Instead of just printing “finished” in the console let’s add something more useful and let’s include the url of the current page.

Changing it to print("finished loading page \(url)")

This also help us keep track of the current page and to know where we are at any given scroll movement (not any but at a page level)

Now let’s add a new function inside the View Model to be in charge of refreshing the data.

What to refresh?

The refresh mechanism should only refresh the first page, and nothing else.

So the reload function should do:

  1. Empty the current collection of characters
  2. Reset the url to be back on the first page
  3. Call getPeople() func in order to reuse the existing api call

The function looks like this:

func refreshData() {
      cancellable.removeAll()
      allCharacters.removeAll()
      nextPageURL = "https://swapi.tech/api/people"
      getPeople()
}

Now in our CharactersListView, inside the ScrollView add the .refreshable modifier and call our new refreshData() from the ViewModel.

.padding(.horizontal, 6)
.refreshable {
	 charactersViewModel.refreshData()
}

Now if we simply add .refreshable to the view like this, it will work, but, as you can experience the behavior is not something nice.

How .refreshable works

.refreshable is design to work with asynchronous operations, where the refresh indicator is tied to the lifecycle of these operations.

Here we’re using a synchronous function that returns data almost instantly, that’s why the indicator appears and disappears too quickly.

Let’s fix it by adding an artificial waiting time to our function on the View Model.

func refreshData() async {
    await sleepSafelyFor(1.0)
    cancellable.removeAll()
    allCharacters.removeAll()
    nextPageURL = "<https://swapi.tech/api/people>"
    getPeople()
}

// A helper function
func sleepSafelyFor(_ seconds: Double) async {
    try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
}

Now let’s change how we call it in our view:

.refreshable {
    await charactersViewModel.refreshData()                   
}

With this change in place the behavior of our Pull to refresh mechanics is smooth.

Well, I believe I wrote enough for this episode 😅


Conclusion: Modern List Interactions in SwiftUI

In this post, we’ve transformed our Star Wars app from a basic character list into a polished, production-ready feature with two essential mobile interaction patterns:

  1. Infinite Scrolling – We implemented a seamless data loading mechanism that anticipates the user’s needs by automatically fetching more characters as they approach the end of the list. This not only creates a smoother user experience but also optimizes network usage by loading data only when needed.
  2. Pull to Refresh – We added this intuitive gesture that users have come to expect in modern applications, complete with proper visual feedback and state management.

This approach demonstrates how Apple’s modern frameworks work together to simplify what would have been much more complicated in UIKit.

The same approach can be applied to virtually any paginated API, if it has a infinite number of items, the better, making this knowledge immediately applicable to your own projects.

See you in the next part.

Thanks for reading!

TOC