iOS Photo Feed Infinite Scrolling

June 07, 2021

Learn how to build an infinite scrolling feed

Scrolling Image

One important aspect about a feed app, is the seemingly never-ending amounts of data. When you are browsing your feed on Facebook, for example, more and more things keep popping up as you scroll.

Let’s attempt to build this infinite scrolling illusion together! In an older blog post, Simple iOS Photo Feed App, we learned how to build a very basic feed app that grabs photos from Pexels. Here, we will continue with this app and have it constantly load new content as the user scrolls.

Making Use Of Next Page

As you may have noticed, the SearchResponse model has a field we never used, nextPage. That will contain a URL string to grab the data for the next batch of content.

First, we need to be distinct about when we are loading the initial content vs when we are loading next page content. Starting off with some improved function naming to distinguish that in PexelsController, we renamed getSearchResponse() to getNewSearchResponse()

// PexelsController.swift

class PexelsController {
  ...

  /**
   Performs a search query with Pexels for cars using a default url.
   */
  func getNewSearchResponse() -> AnyPublisher<SearchResponse, Error> {
    ...
  }

  ...
}

Refactoring PhotosViewModel

The next step in getting ready for infinite content, is changing PhotosViewModel to update the list of photos instead of reassigning the photos variable upon a new SearchResponse.

// PhotosViewModel.swift
class PhotosViewModel {
  ...

  private var currentSearchResponse: SearchResponse? = nil {
    didSet {
      photos += currentSearchResponse?.photos ?? []
    }
  }

  ...
}

Continuing with the idea to distinguish between an initial content load and an additional content load, we need to divide up the functions better.

// PhotosViewModel.swift
class PhotosViewModel {
  ...

  /**
   The offset from the end of the `photos` list that we should start loading
   new data.
   (i.e. Start loading new data if we are 5 indexes away from the end of the
   `photos` list)
   */
  private let loadOffset = 5

  init(pexelsController: PexelsController = PexelsController()) {
    self.pexelsController = pexelsController
    loadPhotos(with: pexelsController.getNewSearchResponse())
  }

  /**
   Call `loadPhotos()` if we are running out of photos.

   - parameter index: Check if we need to load more photos at the given index.
   */
  func loadPhotosIfNeeded(index: Int) {
    // TODO
  }

  /**
   Retrieves search response and saves it in the view model.
   */
  func loadPhotos(with publisher: AnyPublisher<SearchResponse, Error>) {
    // TODO
  }

  ...
}

Let’s start with populating the loadPhotos(with:) method taking pieces of the old loadPhotosIfNeeded() method.

func loadPhotos(with publisher: AnyPublisher<SearchResponse, Error>) {
  publisher
    .receive(on: RunLoop.main)
    .compactMap({ $0 })
    .catch({ error -> Just<SearchResponse?> in
      print(error.localizedDescription)
      return Just(nil)
    })
    .sink(receiveValue: { [weak self] searchResponse in
      guard let searchResponse = searchResponse else {
        // Most likely caught an error, so we're getting a `nil` object.
        // Don't assign it to `self.currentSearchResponse` in the case that
        // `self.currentSearchResponse != nil` so we don't unset things.
        return
      }
      self?.currentSearchResponse = searchResponse
    })
    .store(in: &cancellables)
}

Now that we’re done copy and pasting some of our old code, let’s implement the loadPhotosIfNeeded(index:) function to start loading new content!

func loadPhotosIfNeeded(index: Int) {
  let needsMorePhotos = index >= photos.count - loadOffset

  if let urlString = currentSearchResponse?.nextPage,
     needsMorePhotos {
    loadPhotos(with: pexelsController.getSearchResponse(urlString))
  }
}

This function checks if we need to start loading new content given the index that the user is currently at. If the user is within the last 5 (our loadOffset value) indices before they reach the bottom of the ScrollView, then we need to start loading new photos.

Wait a Minute, Something Is Wrong!

If the user is within the last 5 indices, loadPhotos(with:) will get called 5 times because we will be calling loadPhotosIfNeeded(index:) on every index that the user lands on. Once that happens, we’ll start getting duplicated pages 😱!

Back to the drawing board

We could keep track of every page that has already been loaded. We can try converting currentSearchResponse into a dictionary, setting page as a key, and the SearchResponse for that page as value. Although it’s a good approach, this is quite memory intensive.

Why not just throttle the loading with a boolean variable? If a new page gets loaded, needsMorePhotos = index >= photos.count - loadOffset will implicitly prevent duplicates because the user will need to scroll even further down to meet the new needsMorePhotos criteria.

We could be even safer, and add some extra tracking variables like lastPageLoaded for example, but let’s try not to over complicate our code.

class PhotosViewModel {
  ...

  private var isLoading = false

  ...

  func loadPhotosIfNeeded(index: Int) {
    let needsMorePhotos = index >= photos.count - loadOffset

    if let urlString = currentSearchResponse?.nextPage,
       needsMorePhotos && !isLoading {
      loadPhotos(with: pexelsController.getSearchResponse(urlString))
    }
  }

  ...

  func loadPhotos(with publisher: AnyPublisher<SearchResponse, Error>) {
    if isLoading {
      return
    }
    isLoading = true
    publisher
      .receive(on: RunLoop.main)
      .compactMap({ $0 })
      .catch({ error -> Just<SearchResponse?> in
        print(error.localizedDescription)
        return Just(nil)
      })
      .sink(receiveValue: { [weak self] searchResponse in
        self?.isLoading = false
        guard let searchResponse = searchResponse else {
          // Most likely caught an error, so we're getting a `nil` object.
          // Don't assign it to `self.currentSearchResponse` in the case that
          // `self.currentSearchResponse != nil` so we don't unset things.
          return
        }
        print("Loaded page: \(searchResponse.page)")
        self?.currentSearchResponse = searchResponse
      })
      .store(in: &cancellables)
  }

  ...
}

By adding isLoading boolean, we limit our requests to only happen once at a time, which gives time everything to update properly without duplicates.

Update the View

Now that we’ve polished up our PhotosViewModel, we need to adjust PhotosView to make sure it works well with the changes.

We can make use of the onAppear method SwiftUI provides to call loadPhotosIfNeeded(index:).

// PhotosView.swift

struct PhotosView: View {
  ...

  var body: some View {
    ScrollView {
      LazyVStack {
        ForEach(
          Array(zip(photosViewModel.photos.indices, photosViewModel.photos)),
          id: \.0,
          content: { (index, photo) in
            PhotoView(photo: photo)
              .onAppear {
                photosViewModel.loadPhotosIfNeeded(index: index)
              }
          }
        )
      }
    }
  }

}

Since we want to pass the index to loadPhotosIfNeeded(index:), we need to update the ForEach a little to get the index value. We can do that by creating tuples of index and photo by using the zip method.

It Works!

Here is a glance of the infinite scrolling in action! Notice how the scroll bar shrinks because new content got loaded.

It looks a little janky right now because of the issues with image loading, but that’s another problem for another blog post!

Conclusion

By having pagination in your API, it’s possible to asynchronously load content page-by-page and create the illusion of infinite scrolling. For more information, see the code if full in GitHub

Let's work together

Find out how we can help you grow.