Movie Booking App - Part 5

iOS App Development Feb 02, 2020

Http requests using the combine framework

Last week, I got a ton of demands to replace the sample data we are currently using with real data fetched from a remote API, so I decided to do just that. I am going to use the movie database API because who does like using it? We will refactor a major portion of our current code, and delete several files as well. We will use the Combine framework to make our HTTP requests instead of using the old callback way, and I promise you the new way is much better.

Preparations

To get started, you will need the finished part 4 project. If you are not subscribed, get it here, otherwise, check your email inbox. I’d recommend you to follow along in order to understand what’s going on. If you haven’t read previous parts, I’d also recommend you read them here before reading this. However you can still read the first half of this tutorial if you are only interested in the combine framework.

The Movie Database

Let’s talk a little bit about the movie database. To perform http requests against the movie database API, you must have an api key, and you will get this key for absolutely free by creating a free account here. Login into your account, and click the profile picture located at you top right corner.

Then click setting in the pop up menu.

And last, click API located to your left. Copy the API Key (v3 auth) value. This is your private key, so make sure you keep it safe, and of course private.

You can also click the API link in the navigation bar to read the documentation.

Tip: Never keep API Keys or any sensitive value in plain text in your code, make sure to obfuscate them, and/or put them somewhere same.

With that out of the way, let us begin, shall we? We will start by creating a couple of files which in turn will cause lots of compiler errors, but fear not we will fix those as we go.

The Movie Model

The old movie model will change based on the definition of the json payload we will fetch from TMDB, it will be totally different from what we had before. In the Models folder, create a file named Movie.swift, and put the following code inside:


import SwiftUI


struct TMDBResult: Codable {
    let page:Int
    let totalPages:Int
    let results:[Movie]
}


struct Genre: Codable{
    let id:Int
    let name:String
}

struct ProductionCompany: Codable {
    let name: String
}


struct Movie: Codable {
 
    let id:Int?
    let title:String?
    let releaseDate:String?
    let overview:String?
    let popularity:CGFloat?
    let genres: [Genre]?
    let voteAverage:CGFloat?
    let originalLanguage: String?
    let posterPath:String?
    let backdropPath:String?
    let voteCount:Int?
    let status:String?
    let runtime,revenue:Int?
    let budget: Int?
    let productionCompanies: [ProductionCompany]?

    
    static var `default`: Movie {
        Movie(id: 0, title: "", releaseDate: "", overview: "", popularity: 0, genres: [], voteAverage: 0, originalLanguage: "", posterPath: "", backdropPath: "", voteCount: 0, status: "", runtime: 0, revenue: 0, budget: 0, productionCompanies: [])
    }
    
}

And yes, we have errors. The problem here is that we already have a Movie struct, so now I want you to completely remove/delete the MovieBundle.swift file. That fixed these errors, but created several others, I count over 40 errors. Woohoo!!! However, this was expected, right?. What you need to do right now is to act like there are no errors for now..

While you still in the Models folder, replace the Ticket with the following:

import Foundation

struct Ticket: Identifiable {
    var id: UUID
    var movie: MovieViewModel
    var date: TicketDate
    var hour: String
    
    static var `default`: Ticket{
        .init(id: UUID(), movie: MovieViewModel.default, date: TicketDate.default, hour: "")
    }
}

The only modification we made here is just using the new Movie struct and make the Ticket struct non-generic. However, I know, the errors count keeps increasing, fantastic!

The Movie Database URLs

For simplicity purposes, we will extend the built-in URL struct, and add our own variables and helper methods that we will use throughout the entire app.

In the Extensions folder, add a file named URLExt.swift containing the following code:


import SwiftUI


extension URL{
    static private let baseImageUrl = "https://image.tmdb.org/t/p/"
    static private let backdropSize = "w780"
    static private let posterSize = "w342"
    
    static private  let apiKey = "YOUR_API_KEY"
    static private  let baseUrl = "https://api.themoviedb.org/3/movie"
    
    
    static func movies(for section: HomeSection, page: Int) -> URL{
        URL(string: "\(baseUrl)/\(section.rawValue.replacingOccurrences(of: " ", with: "_").lowercased() )?api_key=\(apiKey)&language=en-US&page=\(page)")!
    }
    
    
    static var topRated: URL{
        get{
            
            URL(string: "\(baseUrl)/top_rated?api_key=\(apiKey)&language=en-US&page=1")!
        }
    }
    
    
    static var upcoming: URL{
         get{
              URL(string: "\(baseUrl)/upcoming?api_key=\(apiKey)&language=en-US&page=1")!
         }
     }
     
     static var popular: URL{
         get{
            return  URL(string: "\(baseUrl)/popular?api_key=\(apiKey)&language=en-US&page=1")!
         }
     }
     
     static var nowPlaying: URL{
         get{
             URL(string: "\(baseUrl)/now_playing?api_key=\(apiKey)&language=en-US&page=1")!
         }
     }
     
     static func movie(with id: Int) -> URL{
         return URL(string: "\(baseUrl)/\(id)?api_key=\(apiKey)&language=en-US&page=1")!
     }
    
}

The above extension is meant to make our lives easier, but it’s not a good practice.

Tip: You should always keep things like URLs and other configs in a place where they can easily be configured for different types of environments naming development, staging, UAT and production. This way your app can use the right configs for each environment.

Web Service

Like I said earlier, we will use the Combine framework for our simple networking layer simply because it’s great. Once you get the hang of it, you will never go back to using the callback hell method again (the old way). The Combine framework is a declarative Swift API for processing values over time according to apple. Enough with the pep-talk, let’s write some code.

In your base folder, create a new folder named Networking, and inside it add a file called WebService.swift. Inside that file, add the following code:


import Foundation
import Combine


struct WebService {


}

Don’t forget to import Combine.

Configuration

Add the following code inside the struct:

  private var decoder: JSONDecoder = {
      let decoder = JSONDecoder()
       decoder.keyDecodingStrategy = .convertFromSnakeCase	        return decoder
  }()

The above code creates a decoder that we will use to parse the json payload from the movie database API, the payload keys will be camel-case that’s why we set the .keyDecodingStrategy to .convertFromSnakeCase .

Below that code , add this:

 private var session: URLSession = {
       let config = URLSessionConfiguration.default
        config.urlCache = URLCache.shared
        config.waitsForConnectivity = true
        config.requestCachePolicy = .returnCacheDataElseLoad
        return URLSession(configuration: config, delegate: nil, delegateQueue: nil)
    }()

Here we create our own URLSession , here is the explanation:

  • We are using the built-in shared cache, although you can totally configure it to meet your needs, this shared one is enough for this tutorial.
  • Setting waitForConnectivity to true does what the name says, it checks whether the device is connected to the internet , then loads the data, otherwise, it will wait until the device receive the connection.
  • Setting the cache policy to .returnCacheDataElseLoad means we always check if the data is cached for a particular url exists, otherwise, we fetch it and cache it, and it will never be fetched again as long as the cached version is still available. This behaviour is good if you know that the data from the web API will never change or you are in development mode and don’t want to fetch the data over and over. Even for this particular app, it’s not a good option as we are sure that movies are added, changed and/or removed on-a-regular-basis. Like I said you can configure this to meet your back-end behaviour.

The Combine framework

Below the above code, add the following:

     private func createPublisher<T: Codable>(for url: URL) -> AnyPublisher<T, Error>{
        return session.dataTaskPublisher(for: url)
                       .map({$0.data})
                       .decode(type: T.self, decoder: decoder)
                        .eraseToAnyPublisher()
    }

Let’s break it down:

  1. First of all, the method is generic, we will reuse that piece of code in a couple of places. The generic parameter must conform to the codable protocol.
  2. The method returns AnyPublisher. This AnyPublisher type is a special type that wraps another Publisher in order to hide its complex signature or definition. Usually, you will find that some Publishers have weird, hard-to-read signatures, so the above type just makes it simple for us mere mortals to understand.
  3. We kick -off the request with this line session.dataTaskPublisher(for: url). Before combine, and if you still don’t want to use combine, you can use the alternative session.dataTask which is callback based. However, we want to use publishers.
  4. The output of the session.dataTaskPublisher is a tuple of type (data: Data, response: URLResponse), so we map through it, and we just retrieve the info that we are interested in for this tutorial. The response is also very important as it would help you return appropriate errors based on the response code if the request failed. In that case using the .tryMap operator instead of .map is better. By the way .map, .decode,… are called operators in combine.
  5. The .decode just does what it says, decode the data fetched into TMDBResult type, we also use the decoder we defined earlier.
  6. .eraseToAnyPublisher() just wraps the returned publisher from .decode to a simple AnyPublisher<TMDBResult, Error>.

Let’s now create the method that will return the movies for the home screen:

Below the above code , add the following:

      
    func getSectionsPublisher() -> AnyPublisher<(TMDBResult, TMDBResult, TMDBResult, TMDBResult), Error>{
        Publishers.Zip4(createPublisher(for: .topRated),
                        createPublisher(for: .upcoming),
                        createPublisher(for: .popular),
                        createPublisher(for: .nowPlaying)) // Zip all 4 publishers to run them in parallel
                        .eraseToAnyPublisher() // Erase the complex publisher to a simple AnyPublisher
    }
    

Yep, you read it right… there’s a zip4 operator in combine, what it does is it zips 4 publishers together into a single one. Zip4 will wait until all of the data from all 4 publishers are available before forwarding the 4 values. We also erase to AnyPublisher here.

After that, add these last 2 methods below:

     func getPaginatedPublisher(for section: HomeSection, page: Int) -> AnyPublisher<TMDBResult, Error> {
        let url =  URL.movies(for: section, page: page)
        return createPublisher(for: url)
    }
    
    func getMovieDetailPublisher(for id: Int) -> AnyPublisher<Movie, Error> {
        createPublisher(for: .movie(with: id))
    }

The method returns a paginated publisher, because we will also cover pagination and the last one return a movie detail publisher.

View Models

The ViewModel is what the UI will display. In fact, in the MVVM pattern, the UI should never know about the models. View models are links between models and views.

In your ViewModels folder, replace everything with the following code:

import SwiftUI

enum HomeSection: String, CaseIterable {
    case NowPlaying = "Now playing"
    case Popular
    case Upcoming
    case TopRated = "Top rated"
}

struct MovieViewModel: Identifiable {
    
     var id:Int
     var title:String
     var releaseDate:String
     var overview:String
     var popularity:CGFloat
     var genres:[String]
     var voteAverage:Double = 0
     var originalLanguage: String
     var posterUrl:URL
     var backdropUrl:URL
     var runtime: String
     var productionCompany: String
    
     private let baseImageUrl = "https://image.tmdb.org/t/p/"
     private let backdropSize = "w780"
     private let posterSize = "w342"
        
    
    static  var `default` : MovieViewModel {
        get{
            MovieViewModel(movie: Movie(id: 0, title: "", releaseDate: "", overview: "", popularity: 0, genres: [], voteAverage: 0, originalLanguage: "", posterPath: "", backdropPath: "", voteCount: 0, status: "", runtime: 0, revenue: 0, budget: 0, productionCompanies: []) )
        }
    }
    
    init(movie: Movie) {
        self.id = movie.id!
        self.title = movie.title ?? "N/A"
        self.releaseDate = movie.releaseDate ?? "No date"
        self.overview = movie.overview ?? "No overview"
        self.popularity = movie.popularity ?? 0
        self.genres = movie.genres?.map({$0.name}) ?? []
        self.originalLanguage = movie.originalLanguage ?? "N/A"
        self.backdropUrl = MovieViewModel.backdropImageUrl(with: movie.backdropPath ?? "", baseUrl: self.baseImageUrl, size: backdropSize)
        self.posterUrl = MovieViewModel.posterImageUrl(with: movie.posterPath ?? "", baseUrl: baseImageUrl, size: posterSize)
        self.runtime = MovieViewModel.formatTime(from: movie.runtime ?? 0)
        self.productionCompany = MovieViewModel.productionCompany(movie: movie)
        
        if let avarage = movie.voteAverage, avarage > 0 {
            voteAverage = Double(avarage) / 2
        }
    }
    
    
    static private func posterImageUrl(with path: String, baseUrl: String, size: String) -> URL {
        if let url = URL(string: "\(baseUrl)\(size)\(path)"){
            return url
        }
        
        return URL(string: "https://via.placeholder.com/150/0000FF/808080?Text=No&image&available")!
    }
    
    static private func backdropImageUrl(with path: String, baseUrl: String, size: String) -> URL {
        if let url = URL(string: "\(baseUrl)\(size)\(path)"){
            return url
        }
        
        return URL(string: "https://via.placeholder.com/150/0000FF/808080?Text=No&image&available")!
    }
       
       
    static private func productionCompany(movie: Movie) -> String {
        
        if let prodCompanies = movie.productionCompanies, !prodCompanies.isEmpty {
            return prodCompanies.first?.name ?? "N/A"
        }
        
        return "N/A"
    }
    
    
    static private func formatTime(from runtime: Int)->String{
        if runtime == 0 {
            return "00h:00min"
        }
           let hour = runtime / 60
           let min = runtime % 60
           return "\(hour)h : \(min)min"
       }
}


The above code does what a typical view model should do, it convert the Movie model into something that views will easily consume hence the formatting and helper methods.

In the same folder, create a swift file named MovieListingViewModel.swift.

Inside the file, add the following code:

import SwiftUI
import Combine

class MovieListViewModel: ObservableObject {
    private var webService = WebService()
    private var cancellableSet: Set<AnyCancellable> = []
    
    @Published var sectionMovies =  [HomeSection: [MovieViewModel]]()
    @Published var movie = MovieViewModel.default
    @Published var paginatedMovies = [MovieViewModel]()
}

In the above code, we:

  • create a new instance of WebService.
  • create a set of AnyCancellable to store all AnyCancellable objects returned by the .sink operator that you will learn about shortly. Failure to store Cancellable objects will result in publishers being cancelled prematurely.
  • then create published objects that views will utilise.
    func getSectionMovies()  {
        webService
            .getSectionsPublisher()
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { status in
                switch status {
                case .finished:
                    break
                case .failure(let error):
                    print(error)
                    break
                }
            }) { (topRated,upcoming, popular, nowPlaying) in
                self.sectionMovies[.TopRated] = topRated.results.map(MovieViewModel.init)
                self.sectionMovies[.Upcoming] = upcoming.results.map(MovieViewModel.init)
                self.sectionMovies[.Popular] = popular.results.map(MovieViewModel.init)
                self.sectionMovies[.NowPlaying] = nowPlaying.results.map(MovieViewModel.init)
        }.store(in: &self.cancellableSet)
    }

Here we do this:

  • we receive the publisher’s result on the main thread because we cannot set published objects on a background thread.
  • We then subscribe to the publisher using the sink operator. For simplicity, we don’t handle errors appropriately in this tutorial. In the second closure, we set the sectionMovies map above.
  • Last we store the cancellable objects in the set.

Add the following method below:

    func getPaginatedMovies(for section: HomeSection, page: Int)  {
        webService.getPaginatedPublisher(for: section, page: page)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { status in
                switch status {
                case .finished:
                    break
                case .failure(let error):
                    print(error)
                    break
                }
            }) { page in
                self.paginatedMovies.append(contentsOf:  page.results.map(MovieViewModel.init))
        }.store(in: &self.cancellableSet)
    }

This method does the same thing as the one above, but append the results to the paginatedMovies array.

And last, add this below:

    func getMovieDetail(id: Int)  {
        webService.getMovieDetailPublisher(for: id)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { status in
               switch status {
                case .finished:
                    break
                case .failure(let error):
                    print(error)
                    break
                }
            }) { movie in
                self.movie = MovieViewModel(movie: movie)
        }.store(in: &self.cancellableSet)
    }

This one set the single movie.

KingFisher with Swift Package Manager.

We will use the swift package manager to integrate the kingfisher package into the project. We will use kingfisher to download and cache images.

Follow this to install kingfisher using the swift package manager:

  1. Select File > Swift Packages > Add Package Dependency. Enter https://github.com/onevcat/Kingfisher.git in the "Choose Package Repository" dialog.
  2. In the next page, specify the version resolving rule as "Up to Next Major" with “#.#.#” as its earliest version.
  1. After Xcode has finished checking out the source and resolving the version, you can choose the "Kingfisher" and “KingfisherSwiftUI” libraries and add them to your app target.

Refactoring the old view code to use the new view model

This portion can be done by anybody. I won’t go into much explanation, it’s just replacing the old model with the new one. So let’s get started:

MovieCollectionView

Replace allItems with the following:

 var allItems: [HomeSection: [MovieViewModel]]

Then replace the content of cellForItemAt with the following:

     switch indexPath.section {
            case 0:
                if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TrendingCell.reuseId, for: indexPath) as? TrendingCell{
                    cell.movie = parent.allItems[.NowPlaying]?[indexPath.item]
                    return cell
                }
            case 1:
                if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PopularCell.reuseId, for: indexPath) as? PopularCell{
                    cell.movie = parent.allItems[.Popular]?[indexPath.item]
                    return cell
                }
                
            case 2:
                if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: UpcomingCell.reuseId, for: indexPath) as? UpcomingCell{
                    cell.movie = parent.allItems[.Upcoming]?[indexPath.item]
                    return cell
                }
            default:
                if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PopularCell.reuseId, for: indexPath) as? PopularCell{
                    cell.movie = parent.allItems[.TopRated]?[indexPath.item]
                    return cell
                }
            }
            return UICollectionViewCell()

TrendingCell

In TrendingCell replace the following code:

 var trending: Trending?{
        didSet{
            if let trending = self.trending {
                imageView.image = UIImage(named: "\(trending.image)_land.jpg")
                titleLabel.text = trending.title
            }
        }
    }

With this:

  var movie: MovieViewModel?{
        didSet{
            if let movie = self.movie {
				// This line use kingFisher to download, set the image to the image view and caching it at the same time
                imageView.kf.setImage(with: movie.backdropUrl)
                titleLabel.text = movie.title
            }
        }
    }
    

And add import KingfisherSwiftUI below UIKit import statement.

Do the same thing for PopularCell and UpcomingCell, but for these celll, use movie.posterUrl rather than movie.backdropUrl. Delete the ActorCell.swift file and the line that register it in the makeUIView method.

MovieStoreApp

Replace everything in the MovieStoreApp.swift with the following:


import SwiftUI


struct MovieStoreApp: View {
    
    @State private var showDetails = false
    @State private var selectedIndexPath: IndexPath?
    @State private var section: HomeSection = .Popular
    @State private var showSheet = false
    
    @ObservedObject var model = MovieListingViewModel()
    
    var body: some View {
        
        
        return NavigationView {
            
            if model.sectionMovies.isEmpty{
                LoadingView().frame(width: 50, height: 50)
            } else {
                createCollectionView()
                    .sheet(isPresented: self.$showSheet) {
                        if self.selectedIndexPath == nil{
                            MovieListView(section: self.section)
                        } else {
                            SingleMovieView(movieId: self.model.sectionMovies[self.section]?[self.selectedIndexPath!.item].id ?? 0 )
                        }
                }
            }
        }.onAppear {
            self.model.getSectionMovies()
        }
    }
    
    fileprivate func createCollectionView() -> some View {
       
        return MovieCollectionView(allItems: model.sectionMovies,
                                          didSelectItem: { indexPath in
                                           self.selectedIndexPath = indexPath
                                           self.section = HomeSection.allCases[indexPath.section]
                                           self.showSheet.toggle()
               },
                                          seeAllforSection: { section in
                                           self.section = section
                                                          self.showSheet.toggle()
                                                          self.selectedIndexPath = nil
               }).edgesIgnoringSafeArea(.all).navigationBarTitle("Movies")    }
}

struct MovieStoreApp_Previews: PreviewProvider {
    static var previews: some View {
        MovieStoreApp()
    }
}

Here is what’s changed:

  1. We replace the old model with the new one, MovieListingViewModel.
  2. We check whether the data has loaded then we show collectionView, otherwise we show the loading indicator.
  3. In the onAppear block, we fetch the movies. When movies are loaded, the view will reload because we are using an ObservableObject.

MovieListView

Replace the content in the MovieListView with the following:

import SwiftUI

struct MovieListView: View {
    
    var section: HomeSection
    @ObservedObject var model = MovieListingViewModel()
    @State private var page: Int = 1
    
    var body: some View {
        NavigationView {
            List{
                ForEach(0..<model.paginatedMovies.count, id: \.self) { i in
                    MovieListRow(movie: self.model.paginatedMovies[i]).onAppear {
                        if i == self.model.paginatedMovies.count - 1{
                            self.page += 1
                            self.model.getPaginatedMovies(for: self.section, page: self.page)
                        }

                    }
                }
            }.navigationBarTitle(section.rawValue)
        }.onAppear {
            self.model.getPaginatedMovies(for: self.section, page: self.page)
        }
    }
}

struct MovieListView_Previews: PreviewProvider {
    static var previews: some View {
        MovieListView(section: .TopRated)
    }
}

The code does the following:

  1. First the struct is not generic anymore. We once again get an instance of the MovieListingViewModel.
  2. We implement the pagination in the ForEach block.
    MovieListRow(movie: self.model.paginatedMovies[i]).onAppear {
                        if i == self.model.paginatedMovies.count - 1{
                            self.page += 1
                            self.model.getPaginatedMovies(for: self.section, page: self.page)
                        }

                    }
  3. We check if the loaded MovieListRow item is the last in the paginatedMovies array, then increase the page count before performing another request for that page. The result will be appended to paginatedMovies array which will in turn update List thanks to the ObservableObject protocol.

All remaining code do not need any explanation at all as there’s no major changes. You can just compare the old with new code and you will understand quite easily.

MovieListRow

Replace the content of this file with the following:

import SwiftUI
import KingfisherSwiftUI

struct MovieListRow: View {
    var movie: MovieViewModel
    
    fileprivate func createImage() -> some View {
        return KFImage(source: .network(movie.posterUrl))
        .resizable()
        .aspectRatio(contentMode: .fit).cornerRadius(20)
    }
        
    fileprivate func createTitle() -> some View {
        return Text(movie.title)
        .font(.system(size: 25, weight: .black, design: .rounded))
        .foregroundColor(Color.white)
    }
    
    var body: some View {    
        return ZStack(alignment: .bottom) {
            createImage()
            
            VStack(alignment: .leading) {
                createTitle()
                LineRatingView(value: movie.voteAverage)
            }.frame(maxWidth: .infinity, alignment: .leading)
                .padding()
                .background(LinearGradient(gradient: Gradient(colors: [Color.black, Color.clear]) , startPoint: .bottom , endPoint: .top)).cornerRadius(20)
                .shadow(radius: 10)
        
        }.padding(.vertical)
    }
}

SingleMovieView

Replace the content of that file with the following:

import SwiftUI
import KingfisherSwiftUI

struct SingleMovieView: View {
    
    var movieId: Int = -1
    
    @ObservedObject var model = MovieListViewModel()
    
    var body: some View {
        ScrollView(showsIndicators: false) {
                    VStack(alignment: .leading) {
                        createPosterImage()
                        MovieDetailView(movie: self.model.movie)
                    }
                }.edgesIgnoringSafeArea(.top)
                .onAppear {
                    self.model.getMovieDetail(id: self.movieId)
                }
        }
    
    
    
       fileprivate func createPosterImage() -> some View {
        return KFImage(source: .network(model.movie.posterUrl)).resizable()
           .aspectRatio(contentMode: .fit)
       }
}

SingleMovieView

Replace the content of that file with the following:


import SwiftUI

struct MovieDetailView: View {
     var movie: MovieViewModel
        
   @State private var showSeats: Bool = false
        
        var body: some View {            
            
           return VStack(alignment: .leading) {
                createTitle()
            LineRatingView(value: movie.voteAverage)
                createGenreList()
                HStack {
                    Text(self.movie.releaseDate).foregroundColor(Color.gray)
                    Spacer()
                    Text(self.movie.runtime ).foregroundColor(Color.gray)
                }.padding(.vertical)
                createDescription()
                createChooseSeatButton()                
            }.padding(.horizontal).padding(.bottom, 20)
        }
    
    fileprivate func createTitle() -> some View {
        return Text(self.movie.title)
        .font(.system(size: 35, weight : .black, design: .rounded))
        .layoutPriority(1)
        .multilineTextAlignment(.leading)
        .lineLimit(nil)
    }
    
    fileprivate func createGenreList() -> some View {
        
        return ScrollView(.horizontal, showsIndicators: false) {
            HStack{
                ForEach(self.movie.genres, id: \.self){ genre in
                    Text(genre)
                        .bold()
                        .padding(5)
                        .background(Color.lightGray)
                        .cornerRadius(10)
                        .foregroundColor(Color.gray)

                }
            }
        }
    }
    
    fileprivate func createDescription() -> some View {
        return Text(self.movie.overview).lineLimit(nil).font(.body)
    }
    
    fileprivate func createChooseSeatButton() -> some View {
        return LCButton(text: "Choose seats") {
            self.showSeats.toggle()
        }.sheet(isPresented: self.$showSeats) {
            SeatsChoiceView(movie: self.movie)
        }.padding(.vertical)
    }
}

SeatsChoiceView

Replace the content of the file with the following:


import SwiftUI

struct SeatsChoiceView: View {
    var movie: MovieViewModel
    
    @State private var selectedSeats: [Seat] = []
    @State private var showBasket: Bool = false
    @State private var date: TicketDate = TicketDate.default
    @State private var hour: String = ""
    @State private var showPopup = false
    
    var body: some View {
        NavigationView {
            ScrollView(showsIndicators: false ) {
                VStack(spacing: 0) {
                    TheatreView(selectedSeats: self.$selectedSeats).padding(.top, 20)
                    DateTimeView(date: self.$date, hour: self.$hour)
                    LCButton(text: "Continue", action: {
                        self.showBasket = self.validateInputs()
                        withAnimation {
                            self.showPopup = !self.validateInputs()
                        }
                    }).sheet(isPresented: self.$showBasket) {
                        BasketView(ticket: Ticket(id: UUID(), movie: self.movie, date: self.date, hour: self.hour) , selectedSeats: self.selectedSeats
                        )
                    }.padding()
                }.navigationBarTitle("Choose seats", displayMode: .large)
                    .frame(maxHeight: .infinity)
                    .accentColor(Color.accent)
            }
        }.blur(radius: self.showPopup ? 10 : 0).overlay(
            VStack{
                if self.showPopup {
                    self.createPopupContent()
                } else {
                    EmptyView()
                }
            }.frame(maxWidth: .infinity, maxHeight: .infinity)
                .background( self.showPopup ? Color.background.opacity(0.3) : .clear)
        )
    }
    
    fileprivate func createPopupContent() -> some View {
        VStack {
            Text("Not allowed").font(.system(size: 20, weight: Font.Weight.semibold))
            Text("You need to select at least one seat, a date and hour in order to continue.")
                .multilineTextAlignment(.center).frame(maxHeight: .infinity)
            LCButton(text: "Let's do that") {
                withAnimation {
                    self.showPopup.toggle()
                }
            }
        }.frame(width: UIScreen.main.bounds.width * 0.8, height: 200, alignment: .bottom)
            .padding()
            .background(Color.background.opacity(0.7))
            .cornerRadius(20)
            .shadow(color: Color.textColor.opacity(0.3), radius: 20, x: 0, y: 10)
            .transition(.move(edge: .bottom))
    }
    
    fileprivate func validateInputs() -> Bool {
        self.selectedSeats.count > 0
        && self.date != TicketDate.default
        && !self.hour.isEmpty
    }
    
}

struct ChooseSeatsView_Previews: PreviewProvider {
    static var previews: some View {
        SeatsChoiceView(movie: MovieViewModel.default)
    }
}

Basket View

Replace the content of the file with the following:


import SwiftUI

struct BasketView: View {
    var ticket: Ticket
    var selectedSeats: [Seat]
    @State private var showPaymentScreen = false
    
    
    var body: some View {
        
        return
            VStack(spacing: 0) {
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack {
                        ForEach(selectedSeats) { seat in
                            GeometryReader { gr in
                                self.renderTicket(Ticket(id: self.ticket.id, movie:  self.ticket.movie, date:  self.ticket.date, hour:  self.ticket.hour), seat: seat , angle: gr.frame(in: .global).minX / -10)
                            }.frame(width: UIScreen.main.bounds.width)
                        }
                    }
                }
                LCButton(text: "Buy", action: {self.showPaymentScreen.toggle()})
                    .sheet(isPresented: self.$showPaymentScreen, content: {
                        PaymentView()
                    })
                    .padding(.horizontal)
                    .padding(.bottom)
        }
    }
    
    func renderTicket(_ ticket: Ticket, seat: Seat, angle: CGFloat) -> some View {
        
        return TicketView(ticket: ticket, seat: seat)
            .rotation3DEffect(Angle(degrees: Double(angle)) , axis: (x: 0, y: 10.0, z: 0))
    }
}

struct BasketView_Previews: PreviewProvider {
    static var previews: some View {
        BasketView(ticket: Ticket.default, selectedSeats: [])
    }
}

TicketView

import SwiftUI

struct TicketView: View {

    var ticket: Ticket
    var seat = Seat.default
    
    var body: some View {
      VStack(spacing: 0) {
        TopTicketView(ticket: ticket, seat: seat)
                      .background(Color.background)
                      .clipShape(TicketShape())
                  .shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 10)

                  DashedSeperator()
                      .stroke(Color.gray, style: StrokeStyle(lineWidth: 1,dash: [4,8], dashPhase: 4))
                      .frame(height: 0.4)
                      .padding(.horizontal)

                  BottomTicketView()
                      .background(Color("barcodeBG"))
                   .clipShape(TicketShape().rotation(Angle(degrees: 180)))
                  .shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 10)

      }.padding()
    }
}

struct TicketView_Previews: PreviewProvider {
    static var previews: some View {
        TicketView(ticket: Ticket.default)
    }
}

TopTicketView

Replace the content of the file with the following:


import SwiftUI
import KingfisherSwiftUI


struct TopTicketView: View {
    
    var ticket: Ticket
    var seat = Seat.default
    
    var body: some View {
        VStack{
            VStack(alignment: .leading) {
                Text(ticket.movie.productionCompany) // Change here
                    .font(.system(size: 20, weight: .bold))
                    .foregroundColor(Color.gray)
                Text(ticket.movie.title)
                    .font(.system(size: 30, weight: .black))
            }.frame(minWidth: 0.0, maxWidth:.infinity, alignment: .leading)
                .padding(.top, 30)
                .padding(.horizontal)
        
            KFImage(source: .network(ticket.movie.backdropUrl) )
                .resizable().frame(minWidth: 0.0, maxWidth: .infinity)
                .scaledToFit()
                
            HStack{
                TicketDetailView(detail1: "SCREEN", detail2: "18", detail3: "PRICE", detail4: "$5.68")
                TicketDetailView(detail1: "ROW", detail2: "\(seat.row)", detail3: "DATE", detail4: "\(ticket.date.day)/\(ticket.date.month)/\(ticket.date.year)")
                TicketDetailView(detail1: "SEAT", detail2: "\(seat.number)", detail3: "TIME", detail4: ticket.hour)
            }.padding(.vertical)
        }
    }
}

struct TopTicketView_Previews: PreviewProvider {
    static var previews: some View {
        TopTicketView(ticket: Ticket.default)
    }
}



The last thing is to completely delete the ActorListView.swift and ActorDetailView.swift located in the Screens folder and ActorListRow.swift located in the Views folder.

Run the app and hopefully everything should work fine. Try out the pagination as well in the see all list.

And that’s it, folks. Like I said combine is amazing, it really changes the way we do things in swift/iOS mobile development. And pairing it with swift UI feels like paradise on earth. There’s so much already we can do with the 2 frameworks still brand new, I can’t imagine what we will accomplish once they mature. Stay tuned for an amazing project I am preparing for you and many more, please share the article and subscribe for more. HAPPY CODING!!!

Follow me on twitter https://twitter.com/liquidcoder

Subscribe to my newsletter if you haven’t done so already

John K

I am a software developer and code enthusiast. Do you want to work with me, have a suggestion or a request? Feel free to contact me at [email protected] or https://twitter.com/liquidcoder