SwiftUI: Movie Booking App (Part 3)

Movie Theatre Seats View

Liquidcoder
11 min readJan 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 users 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 3. You can just pick up from part 2, and start following along.

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 fix 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 SwiftUIstruct 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 add a gradient that will mimic some sort of screen light reflection. With that in place, all of the errors will be fixed, just build the project if it doesn’t happen automagically.

Here is the preview

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 SwiftUIstruct ChairView: View {

var width: CGFloat = 50
var accentColor: Color = .blue
var seat = Seat.default
@State private 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()
}
}

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 will become 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 SwiftUIstruct 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 theSeatChoice 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:

import Foundationstruct TicketDate: Equatable {
var day: String
var month: String
var year: String

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

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:

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

}
}

Here is a brief breakdown of what the code does:

  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:

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

}
}

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:

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

}
}

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 part (part 4).

Originally published at https://www.liquidcoder.com on January 17, 2020.

--

--