Movie Booking App - SwiftUI 2.0 Update

MovieBookingApp Jul 17, 2020

Hey guys, swift UI 2.0 is out, if you didn't know now you know. So I wanted to update the movie booking app with the new changes. The main component that will be replaced is the UICollectionView as there's now a swiftUI alternative. With this change, we will loose the peekaboo effect, but that's nothing compared to what we will get . Let's jump right into it.

Requirements

  • Xcode 12 or greater
  • MacOS Catalina or greater
  • Swift 5.0 or greater
  • iOS 14 or greater

Starter and final project

Get started

I want to try a new format of writing where I will show you the code and the file that contains it, the preview and last the explanation, just create the file if you don't have it already.

To follow along, you need download the previous project source code (use part 5) , and when you finish, try comparing your work with the final project.

Initial cleanup

We will need to get rid of some unnecessary files that we don't need anymore... So, delete the entire Images folder, then in the Views/Cells folder add the following swiftUI files:

  • NowPlayingView.swift
  • PopularView.swift
  • UpcomingView.swift

Add the following in the Views folder:

  • DynamicContainer.swift
  • SectionView.swift

Now let's start filling them up, shall we?

Now Playing

NowPlayingView.swift

import SwiftUI
import KingfisherSwiftUI

struct NowPlayingView: View {
    
    var movie: MovieViewModel
    
    var body: some View {
        return
           KFImage(source: .network(movie.backdropUrl))
            .resizable()
            .aspectRatio(contentMode: .fit)
                .overlay(
                    Text(movie.title)
                        .font(.title2)
                        .bold()
                        .foregroundColor(Color.white)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding(.leading, 10)
                        .padding(.bottom, 10)
                        .background(
                            LinearGradient(gradient: Gradient(colors: [Color.black, Color.clear]), startPoint: .bottom, endPoint: .top)
                        ), alignment: .bottom
                ).cornerRadius(20)
    }
}

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

Explanation

The NowPlayingView will just be an image with a text overlay aligned to the bottom. We've also added a gradient background to the text to make it readable on light background.

Preview

https://res.cloudinary.com/liquidcoder/image/upload/v1594293248/movie%20store/jkydrory3brobgtffo9w.png
https://res.cloudinary.com/liquidcoder/image/upload/v1594293248/movie%20store/jkydrory3brobgtffo9w.png

The top rated and popular section will use the same view, I will call the file Popular.swift, but feel free to be creative.

Popular.swift

import SwiftUI
import KingfisherSwiftUI

struct PopularView: View {
    
    var movie: MovieViewModel
    
    var body: some View {
        VStack( alignment: .leading, spacing: 0) {
            
            KFImage(source: .network(movie.posterUrl)).resizable()
             .aspectRatio(contentMode: .fit)
             .frame(height: 225, alignment: .center)
                .cornerRadius(20)
                .clipped()
                .padding(.top, 5)
         
            
            Text(movie.title)
                .font(.headline)
                .multilineTextAlignment(.leading)
                .frame(maxWidth: .infinity,alignment: .leading)
                .padding(.all, 10)
        }.frame(width: 150)
            
    }
}

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

Explanation

As you can see, in this view we stack the image and the title vertically.

Preview

https://res.cloudinary.com/liquidcoder/image/upload/v1594293248/movie%20store/z1llbhmcwk38r4uwpz2q.png
https://res.cloudinary.com/liquidcoder/image/upload/v1594293248/movie%20store/z1llbhmcwk38r4uwpz2q.png

Upcoming

UpcomingView.swift

import SwiftUI
import KingfisherSwiftUI

struct UpcomingView: View {
    
    var movie: MovieViewModel
    
    var body: some View {
        HStack{
            KFImage(source: .network(movie.backdropUrl)).resizable()
                .aspectRatio(contentMode: .fit)
                .cornerRadius(10.0)
    
            VStack(alignment: .leading) {
                Text(movie.title).font(.title).bold()
                Text(movie.releaseDate)
									.foregroundColor(.gray)

            }
            
            Spacer()
        }
    }
}

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

Explanation

As you can see, in this view we stack the image and the title horizontally.

Preview

https://res.cloudinary.com/liquidcoder/image/upload/v1594293249/movie%20store/vycdw2zg79eyecdkwg1v.png
https://res.cloudinary.com/liquidcoder/image/upload/v1594293249/movie%20store/vycdw2zg79eyecdkwg1v.png

Dynamic Container

This will be a generic container that we will use to layout different types of views, in our case we will use only the newly introduced LazyGrid and LazyHStack , but sky is the limit, one might extend it to include other containers.

DynamicContainer.swift

import SwiftUI

struct DynamicContainer<Model, Content>: View where Content: View, Model: Identifiable {
    
    enum ContainerType {
        case stack, grid
    }
    
    
    var type: ContainerType
    var data: [Model]
    var content: (Model) -> Content
    
    
    private let gridLayout = [
        GridItem(.flexible(minimum: 100), spacing: 10, alignment: .center),
        GridItem(.flexible(minimum: 100), spacing: 10, alignment: .center)
    ]
    
    
    init( data: [Model]?, type: ContainerType = .stack, @ViewBuilder content: @escaping (Model) -> Content) {
        self.data = data ?? []
        self.type = type
        self.content = content
    }
    
    @ViewBuilder
    var body: some View {
        
        switch type {
        case .stack:
            LazyHStack(spacing: 20) {
                ForEach(data) { value in
                    self.content(value)
                }
            }.padding(.horizontal, 20)
            
        case .grid:
            LazyHGrid(rows: gridLayout, spacing: 0) {
                ForEach(data) { value in
                    self.content(value)
                }
            }
        }
    }
}

Explanation

That's a big chunk of code, let's break it down. The struct is generic where the first generic parameter must conform to Identifiable protocol and the second one must also conform to the View protocol. We declare the ContainerType enum inside the DynamicContainer because it will only be used in DynamicContainer.

We then declare all the properties that we want to pass in via the init method. The first one is just the type of the container to use, the second one is an array of the Model type and the last one is the content ,which in this case, is a closure taking in a Model and spitting out a View.

We've also created the a private gridLayout property which is not ideal, but in this example we just need one layout, so it will not kill us to just declare it above.

In the body block, we switch on the type of container (because we can now use switch statement in here) and return the different type of container based on the enum cases. We are now able to annotate body with   @ViewBuilder to return different types of Views inside a condition.

Section View

The section view will be used to layout each section.

SectionView.swift

import SwiftUI

struct SectionView: View {
    
    var sectionType: HomeSection = .NowPlaying
    var allItems: [HomeSection: [MovieViewModel]]
    @Binding var seeSection: HomeSection?
    @Binding var selectedMovie: MovieViewModel?
    
    var data = [Int]()
    
    
    var body: some View {
        
        
        return VStack(spacing: 5) {
            HStack {
                Text(sectionType.rawValue)
                    .font(.title2)
                    .bold()

                Spacer()

                Button(action: {
                    seeSection = sectionType
                }, label: {
                    Text("See all")
                })
            }.padding(.horizontal, 20)
            
            
            ScrollView(.horizontal, showsIndicators: false) {
                
                switch sectionType {
                case .NowPlaying:
                    DynamicContainer(data: allItems[sectionType]) { value in
                        NowPlayingView(movie: value)
                            .frame(width: UIScreen.main.bounds.width - 40)
                            .onTapGesture {
                                self.selectedMovie = value
                            }
                    }.frame(height: 220)
                    
                case .Popular:
                    DynamicContainer(data: allItems[sectionType]) { value in
                        PopularView(movie: value)
                          
                            .onTapGesture {
                                self.selectedMovie = value
                            }
                        
                    }
                    
                case .Upcoming:
                    DynamicContainer(data: allItems[sectionType], type: .grid) { value in
                        UpcomingView(movie: value)
                        .padding(.horizontal, 20)
                        .frame(width: UIScreen.main.bounds.width)
                            .onTapGesture {
                                self.selectedMovie = value
                            }
                    }.padding(.top, 5)

                case .TopRated:
                    DynamicContainer(data: allItems[sectionType]) { value in
                        PopularView(movie: value)
                            .onTapGesture {
                                self.selectedMovie = value
                            }
                            
                    }
                }
            }
            
        }
    }
}

struct SectionView_Previews: PreviewProvider {
    
    static var previews: some View {
        SectionView(allItems: [:], seeSection: .constant(.NowPlaying), selectedMovie: .constant(MovieViewModel.default))
    }
}

Explanation

The above code might seem like a lot, but it's just a bunch of repetition in the switch statement with some minor differences.

The only thing to explain is the DynamicContainer call, we set the data from the allItems map then in the content closure, we create the view that will act as a cell.

Update the MovieStoreApp

Add the following declaration to the top:

@State private var selectedMovie: MovieViewModel?

Then add the following function below the createCollectionView

fileprivate func createSections() -> some View {
     
        ScrollView{
            VStack(spacing: 20) {
                SectionView(sectionType: .NowPlaying, allItems: model.sectionMovies, seeSection: $section, selectedMovie: $selectedMovie)
                SectionView(sectionType: .Popular, allItems: model.sectionMovies, seeSection: $section, selectedMovie: $selectedMovie)
                SectionView(sectionType: .Upcoming, allItems: model.sectionMovies, seeSection: $section, selectedMovie: $selectedMovie)
                SectionView(sectionType: .TopRated, allItems: model.sectionMovies, seeSection: $section, selectedMovie: $selectedMovie)
            }.padding(.top, 20)
        }.navigationTitle(Text("Movies"))
        
    }

Now replace the entire else block with the following

createSections()
		.sheet(isPresented: .constant(self.section != nil || selectedMovie != nil), onDismiss: {
		    selectedMovie = nil
			   section = nil
		}) {
    selectedMovie.map { SingleMovieView(movieId: $0.id) }
    section.map { MovieListView(section: $0) }
}

Now delete these lines:

@State private var showDetails = false
@State private var selectedIndexPath: IndexPath?
@State private var showSheet = false

And the createCollectionView function. You should also delete the UIKit folder as well as the files inside it.

Conclusion

That's it folks, the small update that I wanted to make... LazyVStack , LazyHStack, LazyVGrid and LazyHGrid work the same as UICollectionView and UITableView in such a way that they don't load all the data at the same time, they only load the one that are visible on the screen, this is better for performance . The next update will be the SinglePass app. I have a bunch of projects in the work, subscribe if you haven't done so already to not miss out and share this article. Happy coding!

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