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:
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
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