Simple iOS Photo Feed App

May 24, 2021

Creating a simple feed of photos.

A very common app in the mobile world is to get data, and put it into a table for a user to scroll through. Some examples are, Facebook, Instagram, Reddit, etc…

Here is one simple way to build it using SwiftUI! This is simply a MVP, and we will build on this project in future blog posts to explore different ways to improve this app.

The Plan

We’re going to be building this today:

We will be using Pexels as our API for getting images. For more information about their API, visit their documentation.

The plan is to do the following:

  1. Search photos via GET https://api.pexels.com/v1/search.
  2. Process and store the response.
  3. Download the images using the URLs provided by the search response.
  4. Display images including the author in a ScrollView.

First, we need to decide on the architecture we’re going to use. For the sake of making code easier to read, not overcomplicated, and modularized, we will use MVVM.

Models

Following the search endpoint, there will be 3 models needed:

Simply following their response structure, we can build Decodable structs.

// PhotoSource.swift

struct PhotoSource: Decodable {

  var original: String
  var large2x: String
  var large: String
  var medium: String
  var small: String
  var portrait: String
  var landscape: String
  var tiny: String

}
// Photo.swift

struct Photo: Decodable, Identifiable {

  private enum CodingKeys: String, CodingKey {
    case id
    case width
    case height
    case url
    case photographer
    case photographerUrl = "photographer_url"
    case photographerId = "photographer_id"
    case avgColor = "avg_color"
    case src
  }

  var id: Int
  var width: Int
  var height: Int
  var url: String
  var photographer: String
  var photographerUrl: String
  var photographerId: Int
  var avgColor: String
  var src: PhotoSource

  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.id = try container.decode(Int.self, forKey: .id)
    self.width = try container.decode(Int.self, forKey: .width)
    self.height = try container.decode(Int.self, forKey: .height)
    self.url = try container.decode(String.self, forKey: .url)
    self.photographer =
      try container.decode(String.self, forKey: .photographer)
    self.photographerUrl =
      try container.decode(String.self, forKey: .photographerUrl)
    self.photographerId =
      try container.decode(Int.self, forKey: .photographerId)
    self.avgColor = try container.decode(String.self, forKey: .avgColor)
    self.src = try container.decode(PhotoSource.self, forKey: .src)
  }

}
// SearchResponse.swift

struct SearchResponse: Decodable {

  private enum CodingKeys: String, CodingKey {
    case totalResults = "total_results"
    case page
    case perPage = "per_page"
    case photos
    case nextPage = "next_page"
    case prevPage = "prev_page"
  }

  var totalResults: Int
  var page: Int
  var perPage: Int
  var photos: [Photo]
  var nextPage: String?
  var prevPage: String?

  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.totalResults = try container.decode(Int.self, forKey: .totalResults)
    self.page = try container.decode(Int.self, forKey: .page)
    self.perPage = try container.decode(Int.self, forKey: .perPage)
    self.photos = try container.decode([Photo].self, forKey: .photos)
    self.nextPage = try? container.decode(String.self, forKey: .nextPage)
    self.prevPage = try? container.decode(String.self, forKey: .prevPage)
  }

}

Controllers

There will be 2 controllers, PexelsController and ImageController.
PexelsController will be used to make API calls to Pexels.
ImageController will be used to download image and transform them into UIImage.

PexelsController

We simply need an API key and we can start making requests! Let’s build the function that will fetch data and return

// PexelsController.swift

class PexelsController {

  // Not the best way to store an API key, but for this example, it's okay.
  private let apiKey = "API_KEY_HERE"
  private let host = "https://api.pexels.com"
  private let path = "/v1/search"

  /**
   Performs a search query with Pexels for cars using a default url.
   */
  func getSearchResponse() -> AnyPublisher<SearchResponse, Error> {
    var components = URLComponents(string: "\(host)\(path)")
    components?.queryItems = [
      URLQueryItem(name: "query", value: "cars")
    ]
    return getSearchResponse(components?.string ?? "")
  }

  /**
   Performs a search query with Pexels for custom urls.

   - parameter urlString: String url for a GET request.
   */
  func getSearchResponse(
    _ urlString: String
  ) -> AnyPublisher<SearchResponse, Error> {
    guard let url = URL(string: urlString) else {
      return Fail(error: PexelsError.invalidURL)
        .eraseToAnyPublisher()
    }
    var request = URLRequest(url: url)
    request.addValue(apiKey, forHTTPHeaderField: "Authorization")

    return URLSession.shared.dataTaskPublisher(for: request)
      .tryMap(\.data)
      .decode(type: SearchResponse.self, decoder: JSONDecoder())
      .eraseToAnyPublisher()
  }

}

extension PexelsController {

  enum PexelsError: Error {
    case invalidURL
  }

}

Let’s run through what’s going on here. The real meat lies in getSearchResponse(_:). We’re making use of some of Publishers in Swift Combine.

guard let url = URL(string: urlString) else {
    return Fail(error: PexelsError.invalidURL)
      .eraseToAnyPublisher()
  }

The code above simply tells the Subscriber that it will get a failure because we have a malformed URL.

return URLSession.shared.dataTaskPublisher(for: request)
  .tryMap(\.data)
  .decode(type: SearchResponse.self, decoder: JSONDecoder())
  .eraseToAnyPublisher()

Next up, we are simply making use of URLSession dataTaskPublisher(for:) method. The result we get gives an object (data: Data, response: URLResponse). In this situation, we don’t really care too much about the response since the data will tell us enough information. Hence, we use tryMap(\.data) to filter only the data. Next, we take that data, and try to decode as the SearchResponse model we build earlier.

ImageController

// ImageController.swift

class ImageController {

  /**
   Downloads image given a valid URL.

   - parameter url: Image url.
   */
  func image(for url: String) -> AnyPublisher<UIImage?, Never> {
    guard let url = URL(string: url) else {
      return Just(nil).eraseToAnyPublisher()
    }

    return URLSession.shared.dataTaskPublisher(for: url)
      .map({ (data, _) -> UIImage? in
        guard let image = UIImage(data: data) else {
          return nil
        }
        return image
      })
      .replaceError(with: nil)
      .eraseToAnyPublisher()
  }

}

Similar to PexelsController, we will make use of URLSession again. Instead of returning an error upon failure, we’ll just let the Subscriber know it will receive a nil from the Publisher.

return URLSession.shared.dataTaskPublisher(for: url)
  .map({ (data, _) -> UIImage? in
    guard let image = UIImage(data: data) else {
      return nil
    }
    return image
  })
  .replaceError(with: nil)
  .eraseToAnyPublisher()

In this piece of code, we’re simply trying to transform (data: Data, response: URLResponse) into UIImage?.

View Models

Making use of these controllers, we can build view models for the models we created earlier.

PhotosViewModel

PhotosViewModel will be in charge of connecting the SearchResponse object with the view that will display all the photos. To do that, it will act as the Subscriber for PexelsController Publisher.

// PhotosViewModel.swift

class PhotosViewModel: ObservableObject {

  @Published var photos: [Photo] = []

  private let pexelsController: PexelsController

  private var cancellables: Set<AnyCancellable> = []
  private var currentSearchResponse: SearchResponse? = nil {
    didSet {
      photos = currentSearchResponse?.photos ?? []
    }
  }

  init(pexelsController: PexelsController = PexelsController()) {
    self.pexelsController = pexelsController
    loadPhotosIfNeeded()
  }

  /**
   Retrieves search response and saves it in the view model.
   */
  func loadPhotosIfNeeded() {
    pexelsController.getSearchResponse()
      .receive(on: RunLoop.main)
      .compactMap({ $0 })
      .catch({ error -> Just<SearchResponse?> in
        print(error.localizedDescription)
        return Just(nil)
      })
      .assign(to: \.currentSearchResponse, on: self)
      .store(in: &cancellables)
  }

}

One interesting note is that you cannot use .assign(to: \.currentSearchResponse, on: self) if the Publisher returns AnyPublisher<SomeObject, Error>. As long as there is a chance for an error, we cannot assign the result to a variable. Thus, we have to either erase the error, or handle the error in some way.

PhotoSourceViewModel

PhotoSourceViewModel is in charge of taking a PhotoSource and loading the actual image.

// PhotoSourceViewModel.swift

class PhotoSourceViewModel: ObservableObject {

  @Published var image: UIImage? = nil {
    didSet {
      loading = false
    }
  }
  @Published var loading: Bool = true

  private let imageController: ImageController
  private let photoSource: PhotoSource

  private var cancellables: Set<AnyCancellable> = []

  init(
    photoSource: PhotoSource,
    imageController: ImageController = ImageController()
  ) {
    self.photoSource = photoSource
    self.imageController = imageController

    loadImage()
  }

  /**
   Download the image and store in this view model.
   */
  private func loadImage() {
    loading = true
    imageController.image(for: photoSource.medium)
      .receive(on: RunLoop.main)
      .assign(to: \.image, on: self)
      .store(in: &cancellables)
  }

}

Views

Lastly, let’s take all the data we got and all the processed values and display them!

PhotoSourceView

Starting bottom up in the view component hierarchy, we need a view that displays photos.

// PhotoSourceView.swift

struct PhotoSourceView: View {

  @ObservedObject private var photoSourceViewModel: PhotoSourceViewModel

  private let loadingColor: Color

  var body: some View {
    if photoSourceViewModel.loading {
      Rectangle()
        .fill(loadingColor)
    } else if let image = photoSourceViewModel.image {
      Image(uiImage: image)
        .resizable()
    } else {
      Image("applit")
        .resizable()
    }
  }

  init(photoSource: PhotoSource, loadingColor: Color) {
    self.loadingColor = loadingColor
    self.photoSourceViewModel = PhotoSourceViewModel(photoSource: photoSource)
  }

}

This has some added extras to make the loading UX nicer, since the API provides it anyway 😁.

PhotoView

The PhotoView will be the card-like design that incorporates a PhotoSourceView and the author’s name, all wrapped in a border.

// PhotoView.swift

struct PhotoView: View {

  private var photo: Photo

  var body: some View {
    VStack(alignment: .trailing, content: {
      PhotoSourceView(
        photoSource: photo.src,
        loadingColor: Color(hex: photo.avgColor)
      )
        .padding()
        .scaledToFit()
      Text(photo.photographer)
        .padding()
    })
    .overlay(
      RoundedRectangle(cornerRadius: 8.0)
        .stroke(Color.secondary)
    )
    .padding()
  }

  init(photo: Photo) {
    self.photo = photo
  }

}

PhotosView

PhotosView will tie all of this together and display multiple PhotoViews.

// PhotosView.swift

struct PhotosView: View {

  @ObservedObject private var photosViewModel = PhotosViewModel()

  var body: some View {
    ScrollView {
      LazyVStack {
        ForEach(photosViewModel.photos, content: { photo in
          PhotoView(photo: photo)
        })
      }
    }
  }

}

struct PhotosView_Previews: PreviewProvider {

  static var previews: some View {
    PhotosView()
  }

}

We’re playing around with iOS 14 LazyVStack instead of using a List since there’s more room for customizability and it’s more lightweight.

Conclusion

There you have it! A quick way to build a photo feed! There are different ways to build this and this is just one of the methods. If you have any suggestions, feel free to shoot us an email at hello@applit.io! Thanks for taking the time to read this!

Check out the GitHub repo for the full implementation!

Let's work together

Find out how we can help you grow.