Movie Booking App - Part 3: Movie Theatre Seats View

MovieBookingApp Jan 17, 2020

Last week, we built the detail views for both movies and actors, if you haven’t read it, I’d suggest that you check it out here before reading this. In this tutorial, we will create the movie theatre where one will be able to choose a seat, date and time. Like all the other articles from this series, you will receive an email containing the source code if you are subscribed, and if you are not, you can get the code here. Without further ado, let’s jump right into it.

Here is what we are going to build in this part:

Intro

Get the source code HERE if you are not subscribed, otherwise check your email, I sent the code to you.  The source code folder will contain the starter project, part 1, part 2 and part.  You can just pick up from part 2, and start following along. However, if you were following along from part 1, just continue from where you we left off last week.

TheatreView

We will create this layout from top to bottom hence starting with the TheatreView. In your Views folder, add a file named TheatreView.swift, and inside it replace everything with the following code:

struct TheatreView: View {

    @Binding var selectedSeats:[Seat]
    
    var body: some View {
        ZStack {
            Rectangle()
                .fill(LinearGradient(gradient: Gradient(colors: [Color.darkPurple.opacity(0.3), .clear]) , startPoint: .init(x: 0.5, y: 0.0), endPoint: .init(x: 0.5, y: 0.5)) )
                .frame(height: 420)
                .clipShape(ScreenShape(isClip: true))
                .cornerRadius(20)
            
            ScreenShape()
                .stroke(style:  StrokeStyle(lineWidth: 5,  lineCap: .square ))
                .frame(height: 420)
                .foregroundColor(Color.accent)

        }
    }
}

struct TheatreView_Previews: PreviewProvider {
    static var previews: some View {
        TheatreView(selectedSeats: .constant([]))
    }
}

You will get a bunch of errors because you are missing a couple of items. Let’s start fixing them now.

Seat Model

In the Model folder, add the following code inside a swift file called Seat.swift.

struct Seat: Identifiable {
    var id: UUID
    var row: Int
    var number: Int
    
    static var `default`: Seat { Seat(id: UUID(), row: 0, number: 0) }
}

That fixes one error, let’s move to the next one.

Screen shape

In order to create a curved line like the one you saw in the video, we will need to make our own custom shape, and fear not, swift ui makes the process real easy, you will just need to understand some basic geometry, and by basic I mean really basic. So create a folder named Shapes, and inside it add a swift file named ScreenShape containing the following code:


import SwiftUI


struct ScreenShape: Shape {

    var screenCurveture: CGFloat = 30
    var isClip = false
    
    func path(in rect: CGRect) -> Path {
        
        return Path{ path in
            path.move(to: CGPoint(x: rect.origin.x + screenCurveture, y: rect.origin.y +  screenCurveture))
            path.addQuadCurve(to: CGPoint(x: rect.width - screenCurveture, y: rect.origin.y + screenCurveture), control: CGPoint(x: rect.midX, y: rect.origin.y) )
            if isClip{
                path.addLine(to: CGPoint(x: rect.width, y: rect.height))
                path.addLine(to: CGPoint(x: rect.origin.x, y: rect.height))
                path.closeSubpath()
            }
        }
    }
}

The above code draws a curved line from left to right, and depending on the isClip flag, we will draw the bottom shape which looks like a reversed bucket to which we will be adding the gradient that will mimic a screen light reflection. With that in place, all of the errors will be fixed, just build the project if it doesn’t happen automagically.

Let’s move on to creating the SeatView now, but first let’s create the chairView.

ChairView

In your Views folder, add a swift file named ChairView.swift containing the following code:

import SwiftUI


struct ChairView: View {
    
    var width: CGFloat = 50
    var accentColor: Color = .blue
    var seat = Seat.default
    @State var isSelected = false
    var isSelectable = true
    var onSelect: ((Seat)->()) = {_ in }
    var onDeselect: ((Seat)->()) = {_ in }
    
    
    var body: some View {
       Text("Hello world!")
    }
}

struct ChairView_Previews: PreviewProvider {
    static var previews: some View {
        ChairView()
    }
}

c

The properties inside the struct are self-explanatory. If you don’t understand some of them, don’t worry you will understand after using them shortly.

Then replace the Text inside body with the following:

 VStack(spacing: 2) {
            Rectangle()
                .frame(width: self.width, height: self.width * 2/3)
                .foregroundColor(isSelectable ? isSelected ? accentColor : Color.gray.opacity(0.5) : accentColor)
            .cornerRadius(width / 5)
            
            Rectangle()
                .frame(width: width - 10, height: width / 5)
                .foregroundColor(isSelectable ? isSelected ? accentColor :  Color.gray.opacity(0.5) : accentColor)
                .cornerRadius(width / 5)
        }

This is just 2 vertically stacked rectangles, nothing fancy. And as you may have noticed, we used some of the properties we declared above. Let’s now add a tap gesture to handle selection and deselection. Add the following modifier to the above VStack container:

.onTapGesture {
            if self.isSelectable{
                self.isSelected.toggle()
                if self.isSelected{
                    self.onSelect(self.seat)
                } else {
                    self.onDeselect(self.seat)
                }
            }
        }

As you can see, we call the onSelect closure when isSelected is true passing in the current seat, otherwise we call onDeselect.

Let’s now put everything together inside the TheatreView view.

Now open the Theatre struct, and below body, add the following method:

    fileprivate func createFrontRows() -> some View {
        
        let rows: Int = 2
        let numbersPerRow: Int = 7
        
        return
            
            VStack {
                ForEach(0..<rows, id: \.self) { row in
                    HStack{
                        ForEach(0..<numbersPerRow, id: \.self){ number in
                            ChairView(width: 30, accentColor: .accent, seat: Seat(id: UUID(), row: row + 1, number: number + 1) , onSelect: { seat in
                                self.selectedSeats.append(seat)
                            }, onDeselect: { seat in
                                self.selectedSeats.removeAll(where: {$0.id == seat.id})
                            })
                        }
                    }
                }
        }
    }
    

Here is what the above code does:

  1. The 2 properties above mean we want the first 2 rows to have 7 seats
  2. To create a grid in swift ui, we need nest an HStack inside a VStack or vice-versa, which is becomes very inefficient for a larger amount of data, so I wouldn’t use this as a UICollectionView. However, for our use-case, it won’t be an issue, we are good.
  3. We implement the onSelect and onDeselect by adding and removing the tapped chair respectively.

Next, let’s create the remaining chairs. So add the following method below the one you’ve just added:

    fileprivate func createBackRows() -> some View {
        
        
        let rows: Int = 5
        let numbersPerRow: Int = 9
        
        return
            
            VStack {
                ForEach(0..<rows, id: \.self) { row in
                    HStack{
                        ForEach(0..<numbersPerRow, id: \.self){ number in
                            ChairView(width: 30, accentColor: .accent, seat: Seat(id: UUID(), row: row + 3, number: number + 15) , onSelect: { seat in
                                self.selectedSeats.append(seat)
                            }, onDeselect: { seat in
                                self.selectedSeats.removeAll(where: {$0.number == seat.number})
                            })
                        }
                    }
                }
        }
    }

This code is exactly the same as the one I have just explained above. The only exception is that we add 3 to rows and 15 to columns each time we create a new Seat to account for the front rows that have already been created.

Next, let’s create the seat legend. Add the following code below the “createBackRows”:

    fileprivate func createSeatsLegend() -> some View{
        HStack{
            ChairLegend(text: "Selected", color: .accent)
            ChairLegend(text: "Reserved", color: .clearPurple)
            ChairLegend(text: "Available", color: .gray)
        }.padding(.horizontal, 20).padding(.top)
    }

The above code will not compile, so create a swift ui file in Views named ChairLegend.swift, and replace the content of the file with the following code:


import SwiftUI

struct ChairLegend: View {
    var text = "Selected"
    var color: Color = .gray
    
    var body: some View {
        HStack{
             ChairView(width: 20,accentColor: color, isSelectable: false)
            Text(text).font(.subheadline).foregroundColor(color)
        }.frame(maxWidth: .infinity)
    }
}


struct ChairLegend_Previews: PreviewProvider {
    static var previews: some View {
        ChairLegend()
    }
}

Errors should be gone by now, if not, just build your project (CMD + B).

Then add the following code below ScreenShape() inside the TheaterView :

       
                 VStack {
                     createFrontRows()
                     createBackRows()
                     createSeatsLegend()
                 }

Here is a preview:

The next step will be to create a SeatChoice Screen. This is the main view that will put everything together.

Seats Choice Screen

In the Screens folder, add a swift ui file named SeatsChoiceView.swift. And inside it replace the content with the following:

struct SeatsChoiceView<T: Movie>: View {
    var movie: T
        
    @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 { 
	Text("Hello world!")
}

}

As you can see, the struct is generic to be used for any type of Movie. You will get an error caused by the missing TicketDate model, so in the Models folder, add a swift file named TicketDate.swift, and put the following inside:

import Foundation

struct TicketDate: Equatable {
    var day: String
    var month: String
    var year: String
    
    static var `default`: TicketDate { TicketDate(day: "", month: "", year: "") }
}

Build (CMD + B) the project if the error does not go away automatically. Go back to the SeatsChoice file, and inside the struct’s body, replace the Text view with the following:

 NavigationView {
            ScrollView(showsIndicators: false ) {
                VStack(spacing: 0) {
                    TheatreView(selectedSeats: self.$selectedSeats).padding(.top, 20)

                }.navigationBarTitle("Choose seats", displayMode: .large)
                    .frame(maxHeight: .infinity)
                    .accentColor(Color.accent)
            }
        }

Right now, the preview is showing the exact screen as the one shown in the TheatreView . Let’s now create the DateTimeView.

In the Views folder, create a DateTimeView.swift file, and at the top, add the following code :

    @State private var selectedDate: TicketDate = TicketDate.default
    @State private var selectedHourndex: Int = -1
    private let dates = Date.getFollowingThirtyDays()
    
    @Binding var date: TicketDate
    @Binding var hour: String

You will need to create a Date extension to silence the error you have just got, so in the Extensions folder, create a DateExt file containing the following code lines of code:

import Foundation


extension Date{
    
     static var thisYear: Int {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy"
        let component = formatter.string(from: Date())
        
        if let value = Int(component) {
            return value
        }
        return 0
    }
    
    private static func getComponent(date: Date, format: String) -> String {
           let formatter = DateFormatter()
           formatter.dateFormat = format
        formatter.locale = Locale.autoupdatingCurrent
           let component = formatter.string(from: date)
           return component
    }
    
    static func getFollowingThirtyDays(for month: Int = 1) -> [TicketDate]{
        var dates = [TicketDate]()
        let dateComponents = DateComponents(year: thisYear , month: month)
        let calendar = Calendar.current
        let date = calendar.date(from: dateComponents)!

        let range = calendar.range(of: .day, in: .month, for: date)!
        
        for i in range{
            guard let fullDate = calendar.date(byAdding: DateComponents(day: i) , to: Date()) else { continue }
            let d = getComponent(date: fullDate, format: "dd")
            let m = getComponent(date: fullDate, format: "MM")
            let y = getComponent(date: fullDate, format: "yy")
            let ticketDate = TicketDate(day: d, month: m, year: y)
            dates.append(ticketDate)
        }
        
        return dates
        
    }
}

That is a big chunk of code, so let’s break it down.

  1. thisYear: is a property that returns the current year converted to an integer.
  2. getComponent : Accept a date and a format then returns a formatted date based on the passed in format.
  3. getFollowingThirtyDays : As its name implies, this method generates the next 30, 31 or 28 days depending on the month, and returns an array of TicketDate created from the generated dates.

Before we continue implementing the DateTimeView, we will need 2 more views, Date and Time Views.

In your Views folder, add the a DateView.swift file with the following code in it:

import SwiftUI

struct DateView: View {
    var date: TicketDate = TicketDate(day: "03", month: "11", year: "20")
    var isSelected: Bool
    var onSelect: ((TicketDate)->()) = {_ in }
    
    
    var body: some View {
        VStack {
            Text("\(date.day)")
                .font(.title)
                .bold()
                .foregroundColor(isSelected ? .white : .textColor)
            
            Text("\(date.month)/\(date.year)")
                .foregroundColor(isSelected ? .white : .textColor)
                .font(.callout)
                .padding(.top, 10)
            
        }.padding()
            .background( isSelected ? Color.accent: Color.gray.opacity(0.3))
            .clipShape(DateShape())
            .cornerRadius(10)
            .onTapGesture {
                self.onSelect(self.date)
        }
    }
}

struct DateView_Previews: PreviewProvider {
    static var previews: some View {
        DateView(isSelected: false )
    }
}

c

The above code is relatively simple, it just stacks 2 texts vertically. Your code will not compile caused by the missing DateShape() which you are about to create.

In the Shapes folder, add a DateShape.swift file with the following code inside:

import SwiftUI

struct DateShape: Shape {

    var cutRadius: CGFloat = 5
    
    func path(in rect: CGRect) -> Path {
        
        return Path{ path in
            path.move(to: CGPoint(x: rect.origin.x, y: rect.origin.y ))
            path.addLine(to: CGPoint(x: rect.width, y: rect.origin.y))
            path.addLine(to: CGPoint(x: rect.width, y: rect.height - rect.height / 4))
            path.addArc(center: CGPoint(x: rect.width, y: rect.height - rect.height / 4 + cutRadius), radius: cutRadius, startAngle: Angle(degrees: -90) , endAngle: Angle(degrees: 90) , clockwise: true)
            path.addLine(to: CGPoint(x: rect.width, y: rect.height))
            path.addLine(to: CGPoint(x: rect.origin.x, y: rect.height))
            path.addLine(to: CGPoint(x: rect.origin.x, y: rect.height - rect.height / 4 + cutRadius * 2))
            path.addArc(center: CGPoint(x: rect.origin.x, y: rect.height - rect.height / 4 + cutRadius), radius: cutRadius, startAngle: Angle(degrees: 90) , endAngle: Angle(degrees: -90) , clockwise: true)
            path.closeSubpath()
            
        }
    }
}

c

The above code creates a shapes with 2 cut-outs on each side. Build (Command + B) your project, and the errors should now leave you alone.

Here is the preview:

Next, let’s create the TimeView which will be way simpler than the DateView. So in the Views folder , create a TimeView.swift file, and paste the following code inside:

struct TimeView: View {
    var index: Int
    var isSelected: Bool
    var onSelect: ((Int)->()) = {_ in }


    var body: some View {
        Text("\(index):00")
            .foregroundColor(isSelected ? .white : .textColor)
            .padding()
            .background( isSelected ? Color.accent : Color.gray.opacity(0.3))
            .cornerRadius(10).onTapGesture {
                self.onSelect(self.index)
        }
    }
}

And voilà, that’s our TimeView. Pretty simple!

We are now ready to finish the DateTimeView. Add the following 2 methods below the body block in the DateTimeView struct:

fileprivate func createDateView() -> some View{
        VStack(alignment: .leading) {
            Text("Date")
            .font(.headline).padding(.leading)
             ScrollView(.horizontal, showsIndicators: false) {
                HStack{
                    ForEach(dates, id: \.day){ date in
                        DateView(date: date, isSelected: self.selectedDate.day == date.day, onSelect: { selectedDate in
                            self.selectedDate = selectedDate
                            self.date = selectedDate
                        })
                    }
                }.padding(.horizontal)
            }
        }
    }
    
    fileprivate func createTimeView() -> some View {
        VStack(alignment: .leading) {
            Text("Time").font(.headline).padding(.leading)

            ScrollView(.horizontal, showsIndicators: false) {
                HStack{
                    ForEach(0..<24, id: \.self){ i in
                        TimeView(index: i, isSelected: self.selectedHourndex == i, onSelect: { selectedIndex in
                            self.selectedHourndex = selectedIndex
                            self.hour = "\(selectedIndex):00"
                        })
                    }
                }.padding(.horizontal)
            }
        }
    }
    

Those 2 methods create a scrollable list of dates and times respectively, and handle the selection of each item using the onSelect closure. Next replace the Text(“Hello world!”) with the following:

VStack(alignment: .leading, spacing: 30) {
            createDateView()
            createTimeView()
        }

Here what the finished DateTimeView looks like:

Now, open the SeatsChoiceScreen, and add the following lines of code below TheatreView:

DateTimeView(date: self.$date, hour: self.$hour)
LCButton(text: "Continue", action: {}).padding()

And the SeatsChoice view now looks like this:

Let’s now link the SingleMovieView with the SeatsChoiceView. Open the DetailView.swift file, add this at the top of the struct:

    @State private var showSeats: Bool = false

Then find the createChooseSeatButton method, add this inside the button closure:

            self.showSeats.toggle()

And last, add the following modifier to the same button:

 .sheet(isPresented: self.$showSeats) {
            SeatsChoiceView(movie: self.movie)
        }.padding(.vertical)

Now run the app, and navigate to the screen we’ve just created. Try switching to dark mode. If your app does not work as expected, get the finished project, and compare with your code.

That’s it for this part folks, stay tuned for the next.

QUICK SIDE-NOTE : Based on this series’ reception, I will definitely make more of these, so buckle up because it’s going to be a bumpy, but exciting ride. Make sure you’re subscribed if you’re not already, and please share this article everywhere. 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