Create SwiftUI App for Movie Listing using TMDB API

I have created a SwiftUI app for movie listing and movie details using TMDB API.

First of all, I would like to tell you that I have built this in only one day and there are a lot of things that can be added to this App to make it better.

I welcome you all to give any suggestions to make it better or fix issues (If you find one).

If you wish you can download this project in a structured way from here: https://www.codespeedy.com/products/tmdb-swiftui-ios-app-project/

Or if you need any help you can contact me from the contact button.

Here I will be focusing on the coding part instead of explaining each line. (As it will be a long tutorial if I start to explain each line).

To keep it simple and easy I will provide codes step by step to reach the final App.

In the end, this app will look like this:

SwiftUI TMDB App

This is the home view of the app.

The most important thing is – You will need TMDB API key. You can go to the TMDB API official website and register to get the API key.

The features are:

  • Listing of popular movies
  • Instant search option
  • Dark mode light mode toggle button
  • Movie detail View
  • Clicking on cast the cast image will be shown
  • The star rating system is available both in 5-star format and 10-star format

So let’s get started…

For dealing with the poster images in SwiftUI I will be using Kingfisher package here.

First step: Add Kingfisher package to your project to deal with poster images

There are multiple packages available to work with images. Here I am going to use Kingfisher package. (It is pretty famous)

Once you create your App project, you will be able to add package dependency in your project.

If you don’t know how to do that, check this: How to add a Package in Xcode project

Making our TMDB SwiftUI app home layout

Here I am sharing what I have done to list the popular movies in Grid style in the main ContentView.

To make it better, I have also added a dark mode enable disable button.

import SwiftUI
import Kingfisher

struct ContentView: View {
    @State private var movies: [Movie] = []
    @State private var isDarkMode = false  // State for dark mode toggle
    
    var body: some View {
        NavigationView {
            VStack {
                HStack {
                    Spacer()
                    Button(action: {
                        isDarkMode.toggle()
                        UIApplication.shared.windows.first?.rootViewController?.overrideUserInterfaceStyle = isDarkMode ? .dark : .light
                    }) {
                        Image(systemName: isDarkMode ? "moon.fill" : "sun.max.fill")
                            .font(.title)
                            .padding()
                    }
                }
                .background(Color.primary.opacity(0.2))
                
                ScrollView {
                    LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 10) {
                        ForEach(movies) { movie in
                            VStack {
                                RemoteImage(urlString: "https://image.tmdb.org/t/p/w500\(movie.posterPath)")
                                    .aspectRatio(contentMode: .fit)
                                    .frame(width: 150, height: 200)
                                Text(movie.title)
                                    .font(.caption)
                                    .foregroundColor(.primary)
                            }
                        }
                    }
                    .padding()
                }
                .navigationTitle("Movie List")
            }
            .onAppear {
                MovieFetcher().fetchMovies { fetchedMovies in
                    movies = fetchedMovies
                }
            }
        }
    }
}

struct RemoteImage: View {
    let urlString: String
    
    var body: some View {
        KFImage(URL(string: urlString))
            .resizable()
            .placeholder {
                Color.gray
            }
            .aspectRatio(contentMode: .fit)
    }
}

struct Movie: Codable, Identifiable {
    let id: Int
    let title: String
    let posterPath: String
    
    enum CodingKeys: String, CodingKey {
        case id, title
        case posterPath = "poster_path"
    }
}

class MovieFetcher {
    private let apiKey = "put_your_api_key_here"
    private let baseUrl = "https://api.themoviedb.org/3"
    
    func fetchMovies(completion: @escaping ([Movie]) -> Void) {
        let url = URL(string: "\(baseUrl)/movie/popular?api_key=\(apiKey)")!
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let data = data {
                do {
                    let response = try JSONDecoder().decode(TMDBResponse.self, from: data)
                    completion(response.results)
                } catch {
                    print("Error decoding JSON: \(error)")
                }
            }
        }.resume()
    }
}

struct TMDBResponse: Codable {
    let results: [Movie]
}

As you can see I have placed all the structs in a single ContentView.swift file.

This is not a good practice. For the sake of this tutorial, I am doing this. But while doing this project you can separate the files.

For getting requests from the API use a file like a network.swift

Separating the files makes your project easier for other developers to modify and work on.

A little bit of explanation on what I did here

I have added the movies grid list in a ScrollView so that users can easily scroll down to load more popular movies.

RemoteImage(urlString: "https://image.tmdb.org/t/p/w500\(movie.posterPath)")

You can see that I am using RemoteImage here. And on line number 50, I have created a struct for it.

https://image.tmdb.org/t/p/w500\(movie.posterPath)This is the Movie poster URL.

KFImage(URL(string: urlString))This is the Kingfisher’s built in method to load images.

I have also created struct MovieFetcher and Movie. If you wish to show more data like casting, and production house then you can create more structs.

The TMDB documentation will be helpful to check what data they are providing.

If you run the above program it will give you a warning like this: 'windows' was deprecated in iOS 15.0: Use UIWindowScene.windows on a relevant window scene instead"

There is a way to fix this.

Here is the updated code and it will remove that warning:

import SwiftUI
import Kingfisher

struct ContentView: View {
    @State private var movies: [Movie] = []
    @State private var isDarkMode = false

    var body: some View {
        NavigationView {
            VStack {
                HStack {
                    Spacer()
                    Button(action: {
                        isDarkMode.toggle()
                    }) {
                        Image(systemName: isDarkMode ? "moon.fill" : "sun.max.fill")
                            .font(.title)
                            .padding()
                    }
                }
                .background(Color.primary.opacity(0.2))

                ScrollView {
                    LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 10) {
                        ForEach(movies) { movie in
                            VStack {
                                RemoteImage(urlString: "https://image.tmdb.org/t/p/w500\(movie.posterPath)")
                                    .aspectRatio(contentMode: .fit)
                                    .frame(width: 150, height: 200)
                                Text(movie.title)
                                    .font(.caption)
                                    .foregroundColor(.primary)
                            }
                        }
                    }
                    .padding()
                }
                .navigationTitle("Movie List")
            }
            .onAppear {
                MovieFetcher().fetchMovies { fetchedMovies in
                    movies = fetchedMovies
                }
            }
            .preferredColorScheme(isDarkMode ? .dark : .light)  // Apply dark/light mode
        }
    }
}

struct RemoteImage: View {
    let urlString: String
    
    var body: some View {
        KFImage(URL(string: urlString))
            .resizable()
            .placeholder {
                Color.gray
            }
            .aspectRatio(contentMode: .fit)
    }
}

struct Movie: Codable, Identifiable {
    let id: Int
    let title: String
    let posterPath: String
    
    enum CodingKeys: String, CodingKey {
        case id, title
        case posterPath = "poster_path"
    }
}

class MovieFetcher {
    private let apiKey = "tmdb_api_key_here"
    private let baseUrl = "https://api.themoviedb.org/3"
    
    func fetchMovies(completion: @escaping ([Movie]) -> Void) {
        let url = URL(string: "\(baseUrl)/movie/popular?api_key=\(apiKey)")!
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let data = data {
                do {
                    let response = try JSONDecoder().decode(TMDBResponse.self, from: data)
                    completion(response.results)
                } catch {
                    print("Error decoding JSON: \(error)")
                }
            }
        }.resume()
    }
}

struct TMDBResponse: Codable {
    let results: [Movie]
}

Adding more features: Movie detail page and a back button

Note: It’s not a good idea to place the API key again and again for new views. You can place the API key only once in info.plist

Check this: Add key in info.plist file in Xcode

In this step, I have added the option to click on any movie and get the details on another view.

Also a back button on the movie detail page to allow the users to go back to the main view.

import SwiftUI
import Kingfisher

struct ContentView: View {
    @State private var movies: [Movie] = []
    @State private var isDarkMode = false

    var body: some View {
        NavigationView {
            VStack {
                HStack {
                    Spacer()
                    Button(action: {
                        isDarkMode.toggle()
                    }) {
                        Image(systemName: isDarkMode ? "moon.fill" : "sun.max.fill")
                            .font(.title)
                            .padding()
                    }
                }
                .background(Color.primary.opacity(0.2))

                ScrollView {
                    LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 10) {
                        ForEach(movies) { movie in
                            NavigationLink(destination: MovieDetail(movie: movie)) {
                                VStack {
                                    KFImage(URL(string: "https://image.tmdb.org/t/p/w500\(movie.posterPath)"))
                                        .resizable()
                                        .aspectRatio(contentMode: .fit)
                                        .frame(width: 150, height: 200)
                                    Text(movie.title)
                                        .font(.caption)
                                        .foregroundColor(.primary)
                                        .lineLimit(2)
                                        .padding(.horizontal)
                                }
                            }
                        }
                    }
                    .padding()
                }
                .navigationTitle("Movie List")
            }
            .onAppear {
                MovieFetcher().fetchMovies { fetchedMovies in
                    movies = fetchedMovies
                }
            }
            .preferredColorScheme(isDarkMode ? .dark : .light)
        }
    }
}

struct MovieDetail: View {
    let movie: Movie
    @State private var movieDetails: MovieDetails? = nil
    @Environment(\.presentationMode) var presentationMode
    
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                Text(movie.title)
                    .font(.title)
                KFImage(URL(string: "https://image.tmdb.org/t/p/w500\(movie.posterPath)"))
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(height: 300)
                Text(movieDetails?.overview ?? "")
                    .font(.body)
                Text("Rating: \(movieDetails?.vote_average ?? 0)/10")
                    .font(.subheadline)
                Text("Release Date: \(movieDetails?.release_date ?? "")")
                    .font(.subheadline)
                Text("Actors:")
                    .font(.subheadline)
                if let cast = movieDetails?.credits.cast {
                    ForEach(cast) { actor in
                        Text(actor.name)
                            .font(.caption)
                    }
                }
                Spacer()
            }
            .padding()
            .navigationBarBackButtonHidden(true)
            .navigationBarItems(leading: backButton)
            .onAppear {
                fetchMovieDetails()
            }
        }
    }
    
    private var backButton: some View {
        Button(action: {
            presentationMode.wrappedValue.dismiss()
        }) {
            Image(systemName: "arrow.left.circle.fill")
                .font(.title)
        }
    }
    
    private func fetchMovieDetails() {
        guard let apiKey = Bundle.main.object(forInfoDictionaryKey: "TMDB_API_KEY") as? String else {
            print("TMDB API Key missing")
            return
        }
        
        let url = URL(string: "https://api.themoviedb.org/3/movie/\(movie.id)?api_key=\(apiKey)&append_to_response=credits")!
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let data = data {
                do {
                    let movieDetails = try JSONDecoder().decode(MovieDetails.self, from: data)
                    DispatchQueue.main.async {
                        self.movieDetails = movieDetails
                    }
                } catch {
                    print("Error decoding JSON: \(error)")
                }
            }
        }.resume()
    }
}

struct Movie: Identifiable, Decodable {
    let id: Int
    let title: String
    let posterPath: String
    
    enum CodingKeys: String, CodingKey {
        case id, title
        case posterPath = "poster_path"
    }
}

struct MovieDetails: Decodable {
    let overview: String
    let vote_average: Double
    let release_date: String
    let credits: Credits
    // Add more properties as needed
    
    enum CodingKeys: String, CodingKey {
        case overview
        case vote_average
        case release_date
        case credits
    }
}

struct Credits: Decodable {
    let cast: [Cast]
}

struct Cast: Decodable, Identifiable {
    let id: Int
    let name: String
    
    enum CodingKeys: String, CodingKey {
        case id
        case name
    }
}


class MovieFetcher {
    private let apiKey = "b13d9c0174613300bc30647725cda7ea"
    private let baseUrl = "https://api.themoviedb.org/3"
    
    func fetchMovies(completion: @escaping ([Movie]) -> Void) {
        let url = URL(string: "\(baseUrl)/movie/popular?api_key=\(apiKey)")!
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let data = data {
                do {
                    let response = try JSONDecoder().decode(TMDBResponse.self, from: data)
                    completion(response.results)
                } catch {
                    print("Error decoding JSON: \(error)")
                }
            }
        }.resume()
    }
}

struct TMDBResponse: Decodable {
    let results: [Movie]
}

As you can see, due to the multiple structs the file is becoming more complicated. Thus I suggest separating the files for your ease of understanding.

Adding a star rating system for our project

This is the code for creating a star rating system for each movie.

Text("Rating: \(movieDetails?.vote_average ?? 0, specifier: "%.1f")/10")
                    .font(.subheadline)
                HStack(spacing: 2) {
                    ForEach(1...5, id: \.self) { index in
                        let ratingValue = movieDetails?.vote_average ?? 0
                        let starRating = (ratingValue / 2)
                        
                        if Double(index) <= starRating {
                            Image(systemName: "star.fill")
                                .foregroundColor(.yellow)
                        } else if Double(index - 1) < starRating && starRating < Double(index) {
                            Image(systemName: "star.leadinghalf.fill")
                                .foregroundColor(.yellow)
                        } else {
                            Image(systemName: "star")
                                .foregroundColor(.yellow)
                        }
                    }
                }

Final step: No poster image, Corner radius and bug free search system

import SwiftUI
import Kingfisher
import Network

struct ContentView: View {
    @State private var movies: [Movie] = []
    @State private var isDarkMode = false
    @State private var searchText = ""
    @State private var searchResults: [Movie] = []

    var body: some View {
        NavigationView {
            VStack {
                HStack {
                    Spacer()
                    Button(action: {
                        isDarkMode.toggle()
                    }) {
                        Image(systemName: isDarkMode ? "moon.fill" : "sun.max.fill")
                            .font(.title)
                            .padding()
                    }
                }
                .background(Color.primary.opacity(0.2))

                Text("Movie List")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .padding(.top, 10)

                SearchBar(text: $searchText)
                    .padding(.horizontal)
                    .onChange(of: searchText) { newValue in
                        if !newValue.isEmpty {
                            searchMovies(query: newValue)
                        } else {
                            searchResults.removeAll()
                        }
                    }

                ScrollView {
                    LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 10) {
                        ForEach(searchResults.isEmpty ? movies : searchResults) { movie in
                                NavigationLink(destination: MovieDetail(movie: movie)) {
                                    VStack {
                                        if let posterPath = movie.posterPath {
                                            KFImage(URL(string: "https://image.tmdb.org/t/p/w500\(posterPath)"))
                                                .resizable()
                                                .aspectRatio(contentMode: .fit)
                                                .frame(width: 150, height: 200)
                                                .cornerRadius(15) // Add corner radius here
                                        } else {
                                            Rectangle()
                                                .fill(Color.gray)
                                                .frame(width: 150, height: 200)
                                                .cornerRadius(15) // Add corner radius here
                                                .overlay(
                                                    Image(systemName: "photo")
                                                        .font(.system(size: 60))
                                                        .foregroundColor(.white)
                                                        .cornerRadius(15)
                                                )
                                        }
                                        Text(movie.title)
                                            .font(.caption)
                                            .foregroundColor(.primary)
                                            .lineLimit(2)
                                            .padding(.horizontal)
                                    }
                                }
                            }
                        }
                        .padding()
                }

            }
            .onAppear {
                fetchMovies()

            }
            .preferredColorScheme(isDarkMode ? .dark : .light)
        }
    }

    private func fetchMovies() {
        MovieFetcher().fetchMovies { fetchedMovies in
            DispatchQueue.main.async {
                movies = fetchedMovies
            }
        }
    }

    private func searchMovies(query: String) {
        guard let apiKey = Bundle.main.object(forInfoDictionaryKey: "TMDB_API_KEY") as? String else {
            print("TMDB API Key missing")
            return
        }

        let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
        let url = URL(string: "https://api.themoviedb.org/3/search/movie?api_key=\(apiKey)&query=\(encodedQuery)")!

        URLSession.shared.dataTask(with: url) { data, _, error in
            if let data = data {
                do {
                    let response = try JSONDecoder().decode(TMDBResponse.self, from: data)
                    DispatchQueue.main.async {
                        searchResults = response.results
                    }
                } catch {
                    print("Error decoding JSON: \(error)")
                }
            }
        }.resume()
    }
}

struct SearchBar: View {
    @Binding var text: String

    var body: some View {
        HStack {
            Image(systemName: "magnifyingglass")
                .foregroundColor(.gray)
            TextField("Search movies...", text: $text)
                .foregroundColor(.primary)
        }
        .padding(10)
        .background(Color(.secondarySystemBackground))
        .cornerRadius(10)
    }
}


struct MovieDetail: View {
    let movie: Movie
    @State private var movieDetails: MovieDetails? = nil
    @Environment(\.presentationMode) var presentationMode
    @State private var actorDetails: ActorDetails? = nil
    
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                Text(movie.title)
                    .font(.title)
                if let posterPath = movie.posterPath {
                    KFImage(URL(string: "https://image.tmdb.org/t/p/w500\(posterPath)"))
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(height: 300)
                        .cornerRadius(20) // Add corner radius to the image
                } else {
                    Rectangle()
                        .fill(Color.gray) // Gray background
                        .frame(height: 300)
                        .overlay(
                            Image(systemName: "photo") // Placeholder icon
                                .font(.system(size: 120))
                                .foregroundColor(.white)
                        )
                        .cornerRadius(10) // Add corner radius to the placeholder
                }

                Text(movieDetails?.overview ?? "")
                    .font(.body)
                Text("Rating: \(movieDetails?.vote_average ?? 0, specifier: "%.1f")/10")
                    .font(.subheadline)
                HStack(spacing: 2) {
                    ForEach(1...5, id: \.self) { index in
                        let ratingValue = movieDetails?.vote_average ?? 0
                        let starRating = (ratingValue / 2)
                        
                        if Double(index) <= starRating {
                            Image(systemName: "star.fill")
                                .foregroundColor(.yellow)
                        } else if Double(index - 1) < starRating && starRating < Double(index) {
                            Image(systemName: "star.leadinghalf.fill")
                                .foregroundColor(.yellow)
                        } else {
                            Image(systemName: "star")
                                .foregroundColor(.yellow)
                        }
                    }
                }
                .padding(.top, 4) // Add spacing between rating and other information
                
                HStack {
                    Image(systemName: "calendar.circle") // SF Symbol for calendar
                        .foregroundColor(.blue) // Set the color of the SF Symbol
                    
                    Text("Release Date: \(movieDetails?.release_date ?? "")")
                        .font(.subheadline)
                }
                Text("Actors:")
                                    .font(.subheadline)
                                LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 10) {
                                    ForEach(movieDetails?.credits.cast ?? []) { actor in
                                        
                                        Text(actor.name)
                                            .font(.caption)
                                            .onTapGesture {
                                                fetchActorDetails(actorID: actor.id)
                                            }
                                    }
                                }
                                
                                if let profilePath = actorDetails?.profilePath,
                                   let imageURL = constructImageURL(profilePath: profilePath) {
                                    KFImage(imageURL)
                                        .resizable()
                                        .frame(width: 50, height: 50)
                                        .cornerRadius(25)
                                } else {
                                    Text("No actor details or image available.")
                                }
                                
                                Spacer()


            }
            .padding()
            .navigationBarBackButtonHidden(true)
            .navigationBarItems(leading: backButton)
            .onAppear {
                fetchMovieDetails()
                // Usage

            }
        }
    }
    
    
    private var backButton: some View {
        Button(action: {
            presentationMode.wrappedValue.dismiss()
        }) {
            Image(systemName: "arrow.left.circle.fill")
                .font(.title)
        }
    }
    // Fetch actor details
    
    func fetchActorDetails(actorID: Int) {
        guard let apiKey = Bundle.main.object(forInfoDictionaryKey: "TMDB_API_KEY") as? String else {
            print("TMDB API Key missing")
            return
        }
        
        let urlString = "https://api.themoviedb.org/3/person/\(actorID)?api_key=\(apiKey)"
        
        if let url = URL(string: urlString) {
            URLSession.shared.dataTask(with: url) { data, response, error in
                if let data = data {
                    do {
                        let actorDetails = try JSONDecoder().decode(ActorDetails.self, from: data)
                        DispatchQueue.main.async {
                            self.actorDetails = actorDetails
                        }
                    } catch {
                        print("Error decoding actor details: \(error)")
                    }
                }
            }.resume()
        }
    }

    // Construct full image URL
    func constructImageURL(profilePath: String) -> URL? {
        let baseURL = "https://image.tmdb.org/t/p/w185"
        let fullURLString = baseURL + profilePath
        return URL(string: fullURLString)
    }
    
    private func fetchMovieDetails() {
        guard let apiKey = Bundle.main.object(forInfoDictionaryKey: "TMDB_API_KEY") as? String else {
            print("TMDB API Key missing")
            return
        }
        
        let url = URL(string: "https://api.themoviedb.org/3/movie/\(movie.id)?api_key=\(apiKey)&append_to_response=credits")!
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let data = data {
                do {
                    let movieDetails = try JSONDecoder().decode(MovieDetails.self, from: data)
                    DispatchQueue.main.async {
                        self.movieDetails = movieDetails
                    }
                } catch {
                    print("Error decoding JSON: \(error)")
                }
            }
        }.resume()
    }
}
struct TMDBResponse: Decodable {
    let results: [Movie]
}

In the search system, I faced a lot of problems in handling no poster available movies.

Somehow I finally managed to fix those bugs.

Leave a Reply

Your email address will not be published. Required fields are marked *