Movie Booking App - Part 4

MovieBookingApp Jan 26, 2020

Movie Tickets & Payment Form

This is the fourth and last part of this series. This series should’ve taken more than four parts had we used real data from a remote API and utilised a real payment process and proper validations. As you may have noticed, we used a bit of UIKit to achieve some functionalities that would’ve been a pain in the neck if we had stuck with swift UI.

Intro

Like the other tutorials, if you are subscribed, check your email inbox for the source, otherwise click here to download the source code. The folder will contain the starter project, part 1 to part 4. You can just pick up from part 3 and start following along. However, if you were following along from part 1, just continue from where you left off last week.

Basic Validation

In the previous tutorial, we finished the SeatsChoice screen. The next step is to show the Basket View after a user has chosen the seats, date and time. We also won’t allow going to the next screen if at least one of those requirements is not met. So let’s start with the inputs validations in the SeatsChoiceView. So add the following method below body:

    fileprivate func validateInputs() -> Bool {
        self.selectedSeats.count > 0
        && self.date != TicketDate.default
        && !self.hour.isEmpty
    }

Then add the following line of code in the Continue button action:

self.showBasket = self.validateInputs()

We perform some basic validations to check whether the requirements are met. We then present the basket view if everything is okay, otherwise, we show a popup View telling the user that he has missed something.

Now, replace the padding() modifier on the button with the following:

.sheet(isPresented: self.$showBasket) {
                       Text("To be implemented!")
                    }.padding()

We will replace the text shortly. First , let’s create the popup View

The main job of this pop-up view will be to display validation errors, and after that, we will move on to creating the Basket View. So add the following method below body:

    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: "Ok") {
                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))
    }

The above code creates the content that will be displayed in the popup when there are errors. Of course, in a production app, you should do proper validations. For simplicity, we won’t go deeper into validations and error handling. In brief, the above code does the following:

  1. Stacks 2 Text() and a Button() vertically
  2. Styles the view to create a Card.
  3. Applies a transition to animate (move) the pop-up from the bottom to the center of the parent view because that’s where the initial position is.

Then add this modifier to the navigationView:

.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)
        )

The most important thing to understand here is the overlay. It layers a secondary view in front of the view you add it to. According to apple, when you apply an overlay to a view, the original view continues to provide the layout characteristics for the resulting view naming the width, height etc...

Now add the following code in the continue button’s action closure:

withAnimation {
    self.showPopup = !self.validateInputs()
}

If you click the continue button without selecting some of the 3 requirements, you will see the validation view pops up.

Now let’s create the Basket View.

Basket View

Replace the Text("To be implemented!") in the .sheet closure with the following code:

 BasketView(ticket: Ticket(id: UUID(), movie: self.movie, date: self.date, hour: self.hour) , selectedSeats: self.selectedSeats)

BasketView does not exist yet, that’s why you are getting those errors. I will not explain the code that creates the horizontally scrollable list of tickets in this article because I have already written an amazing tutorial about it that you can read here. I will just paste the code here to guide you through creating corresponding files. If you want, you can drag and drop the files in the finished project and skip to the PaymentView.swift altogether.

Let’s create that now. In the Screens folder, add a BasketView.swift file with the following code inside:


import SwiftUI

struct BasketView<T: Movie>: View {
    var ticket: Ticket<T>
    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()})
                    .padding(.horizontal)
                    .padding(.bottom)
        }
    }
    
    func renderTicket(_ ticket: Ticket<T>, 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<Popular>.default, selectedSeats: [])
    }
}

Yes I know, bear with me, we will fix those errors shortly . In the Models folder, add a swift file named Ticket.swift with the following code inside:

import Foundation

struct Ticket<T: Movie>: Identifiable {
    var id: UUID
    var movie: T
    var date: TicketDate
    var hour: String
    
    static var `default`: Ticket<Popular>{
        .init(id: UUID(), movie: Popular.default, date: TicketDate.default, hour: "")
    }
}

Then in the Views folder, add a TicketView.swift file containing the following code inside:

import SwiftUI

struct TicketView<T: Movie>: View {

    var ticket: Ticket<T>
    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<Popular>(ticket: Ticket<Popular>.default)
    }
}

You will get even more errors with this, so let’s create the missing files. In your views folder, create the following files : TopTicketView.swift , TicketDetailView.swift and BottomTicketView.swift, then in the Shapes folder create these —> DashedSeperator.swift and TicketShape.swift

Next, I will paste the code, and specify which file you will put it in:

TopTicketView.swift


import SwiftUI

struct TopTicketView<T: Movie>: View {
    
    var ticket: Ticket<T>
    var seat = Seat.default
    
    var body: some View {
        VStack{
            VStack(alignment: .leading) {
                Text(ticket.movie.studio ?? "Unknown")
                    .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)
        
            Image(uiImage: UIImage(named: "\(ticket.movie.image)_land.jpg")!)
                .resizable().frame(minWidth: 0.0, maxWidth: .infinity)
                .scaledToFit()
                
            HStack{
                DetailsView(detail1: "SCREEN", detail2: "18", detail3: "PRICE", detail4: "$5.68")
                DetailsView(detail1: "ROW", detail2: "\(seat.row)", detail3: "DATE", detail4: "\(ticket.date.day)/\(ticket.date.month)/\(ticket.date.year)")
                DetailsView(detail1: "SEAT", detail2: "\(seat.number)", detail3: "TIME", detail4: ticket.hour)
            }.padding(.vertical)
        }
    }
}

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

TicketDetailView.swift


import SwiftUI

struct TicketDetailView: View {
     var detail1  = "SEAT"
      var detail2 = "34"
      var detail3 = "TIME"
      var detail4 = "18:15"
      
      var body: some View {
          VStack(spacing: 10){
              VStack {
                  Text(detail1)
                      .font(.system(size: 15, weight: .bold))
                      .foregroundColor(Color.gray)
                  Text(detail2)
                  .font(.system(size: 30, weight: .black))

              }
                  
              VStack {
                  Text(detail3)
                      .font(.system(size: 15, weight: .bold))
                      .foregroundColor(Color.gray)
                  Text(detail4).font(.system(size: 15, weight: .bold))
              }
        }.frame(minWidth: 0.0, maxWidth: .infinity)
      }
}

struct TicketDetailView_Previews: PreviewProvider {
    static var previews: some View {
        TicketDetailView()
    }
}

BottomTicketView.swift

import SwiftUI

struct BottomTicketView: View {
    var body: some View {
         Image("Barcode")
                       .resizable()
                       .scaledToFit()
                       .padding(30)
                       .frame(minWidth: 0.0, maxWidth: .infinity)
            .foregroundColor(Color.gray)
    }
}

struct BottomTicketView_Previews: PreviewProvider {
    static var previews: some View {
        BottomTicketView()
    }
}

DashedSeperator.swift

import SwiftUI

struct DashedSeperator: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.origin.x, y: rect.origin.y))
        path.addLine(to: CGPoint(x: rect.size.width, y: rect.origin.y ))
        path.closeSubpath()
        return path
    }
}


TicketShape.swift

import SwiftUI

struct TicketShape: Shape {
    func path(in rect: CGRect) -> Path {
        let arcRadius: CGFloat = 20
        let smallArcRadius:CGFloat = 10
        
        var path = Path()
        path.move(to: CGPoint(x: rect.origin.x, y: rect.origin.y + arcRadius))
        path.addArc(center: CGPoint.zero, radius: arcRadius, startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 0) , clockwise: true)
        
        path.addArc(center: CGPoint(x: rect.midX, y: rect.origin.y) , radius: arcRadius, startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 0) , clockwise: true)
        
        path.addLine(to:  CGPoint(x: rect.size.width - arcRadius, y: rect.origin.y))
        path.addArc(center: CGPoint(x: rect.size.width , y: rect.origin.y), radius: arcRadius, startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 90) , clockwise: true)
        
        path.addLine(to:  CGPoint(x: rect.size.width, y: rect.size.height - smallArcRadius))
        path.addArc(center: CGPoint(x: rect.size.width , y: rect.size.height), radius: smallArcRadius, startAngle: Angle(degrees: 270), endAngle: Angle(degrees: 180) , clockwise: true)
            
        path.addLine(to:  CGPoint(x: rect.origin.x + smallArcRadius, y: rect.size.height))
        path.addArc(center: CGPoint(x: rect.origin.x , y: rect.size.height), radius: smallArcRadius, startAngle: Angle(degrees: 360), endAngle: Angle(degrees: 270) , clockwise: true)
             
        path.closeSubpath()
        return path
    }
}

struct TicketShape_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            TicketShape()
        }
    }
}

Again check out this article to learn how I built the Ticket View and the scrolling effect. You should be able to run the app, and everything should work as intended.

Payment View

When the user presses the buy button in the BasketView, we will show the Payment screen.

In the Screens folder, add a file named PaymentView.swift. At the top of the struct, add the following properties:

  private let paymentMethods = PaymentMethod.allCases
    @State private var selectedMethod  = PaymentMethod.MasterCard
    @State private var cardholderName  = ""
    @State private var cardNumber  = ""
    @State private var expireDate  = ""
    @State private var cvv  = ""
    @State private var formYOffset: CGFloat = 0
    @State private var isSendingPending = false
    @State private var paymentSent = false
    

You don’t have the PaymentMethod enum yet, so add a swift file inside the Models folder named PaymentMethod.swift, and put the following code inside:


enum PaymentMethod: String, CaseIterable{
    case MasterCard,Visa, Paypal
}

Go back in the PaymentView.swift file, and add the following method below body:

    fileprivate func createPaymentMethodSection() -> some View{
        return Section(header: Text("Select payment method").font(.system(size: 20, weight: Font.Weight.semibold)) ) {
            Picker("", selection: self.$selectedMethod) {
                ForEach(paymentMethods, id: \.self){ method in
                    Text(method.rawValue).tag(method)
                }
            }.pickerStyle(SegmentedPickerStyle())
                .padding()
        }
    }

The above code creates a section containing a picker that users will use to select which type of payment the want to utilise. Now, let’s add a Form that will contain the above section. In body, add the following code:

  fileprivate func createNavigationContent() -> some View{
        return NavigationView{
            Form{
                createPaymentMethodSection()
            }
        }
    }

Then replace everything in body with the following:

createNavigationContent()

Here is a preview:

Then below the method you’ve just created, add this:

    fileprivate func createDetailsSection() -> some View{
            let isPaypal = selectedMethod == PaymentMethod.Paypal
            
            return Section(header: Text("Credit card details").font(.system(size: 20, weight: Font.Weight.semibold)) , footer: LCButton(text: "Pay", action: {
            }) ) {
                LCTextfield(value: self.$cardholderName, placeholder: isPaypal ? "Username" : "Cardholder Name", leadingIcon: Image(systemName: "person"))
                HStack {
                    LCTextfield(value: self.$cardNumber, placeholder:  isPaypal ? "Password" : "Card Number", leadingIcon: Image(systemName: isPaypal ? "lock" : "number"), isSecure: isPaypal)
                    Image(self.selectedMethod.rawValue)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(height: 40)
                    
                }
                
                if !isPaypal{
                    LCTextfield(value: self.$expireDate, placeholder: "Expire Date", leadingIcon: Image(systemName: "calendar"))
                    LCTextfield(value: self.$cvv, placeholder: "CVV", leadingIcon: Image(systemName:  "lock"))
                }
            }
        }

This code just introduced a bunch of errors, so let’s fix them by creating a new swift ui view named LCTextfield.swift containing the following:

import SwiftUI

struct LCTextfield: View {
    
    @Binding var value: String
    var placeholder = "Placeholder"
    var leadingIcon = Image(systemName: "person.crop.circle")
    var isSecure = false
    var onEditingChanged: ((Bool)->()) = {_ in }
    
    var body: some View {
        HStack {
            leadingIcon.imageScale(.large)
                .padding(.vertical)
                        .foregroundColor(Color.gray)
            if isSecure{
                SecureField(placeholder, text: self.$value, onCommit: {
                    self.onEditingChanged(false)
                }).padding(.vertical)
            } else {
                TextField(placeholder, text: self.$value, onEditingChanged: { flag in
                    self.onEditingChanged(flag)
                }).padding(.vertical)
            }
        }
    }
}

struct LCTextfield_Previews: PreviewProvider {
    static var previews: some View {
        LCTextfield(value: .constant(""))
    }
}

We’ve just made a reusable textfield that we reused in our form. Just (COMMAND + B) to build the project if the error does not go away automatically.

Then call the createDetailsSection method below the payment method in createNavigationContent . Its content should now look like this:

NavigationView{
            Form{
                createPaymentMethodSection()
                createDetailsSection()
            }
        }

And here is what the preview renders now:

Now add the following modifier directly to the form:

.navigationBarTitle("Payment", displayMode: self.formYOffset == 0 ? .large : .inline)
 .offset(y: self.formYOffset)

The “formYOffset” will change when the keyboard is shown, and will set to zero when the keyboard is hidden. Add the following code after the .offset modifier:

  .blur(radius: self.isSendingPending ? 7 : 0).overlay(
                               VStack{
                                   if self.isSendingPending {
                                       createPopupContent()
                                   } else {
                                       EmptyView()
                                   }
                               }.frame(maxWidth: .infinity, maxHeight: .infinity)
                                   .background( self.isSendingPending ? Color.background.opacity(0.3) : .clear).onAppear(perform: {
                                       print("Runs")
                                   })
                       )

The above code creates a popup, like we did earlier. We will need to add the function that creates the content, so add the following function below body:

     fileprivate func createPopupContent() -> some View {
        return VStack {
            if paymentSent{
                self.okView.transition(.hearbeat)
                Text( "Success!").foregroundColor(.gray).padding()
            } else {
                self.loadingView.frame(width: 50, height: 50).transition(.scale)
                Text( "Validating...").foregroundColor(.gray).padding()
            }
        }.frame(width: UIScreen.main.bounds.width * 0.4, height: UIScreen.main.bounds.width * 0.4)
            .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))
    }
    

This code does the following:

  1. Stacks the okView and the Text() vertically. We will create the okView shortly
  2. Sets the frame of the pop up card to be 40% the width and height of the entire screen
  3. Applies the same transition we applied earlier.

Now, put these 2 properties above body:

  private let okView = OkView(width: 30, lineWidth: 7)
  private let loadingView = LoadingView(lineWidth: 7)

To fix those errors, you will need to add a couple of Views in you Views folder. Create 2 swift files named OkView.swift and LoadingView.swift.

Add the following code in OkView.swift:

import SwiftUI

struct OkView: View {
    var width: CGFloat = 30
    var lineWidth: CGFloat = 7
    var body: some View {
          OkShape()
            .stroke(style: StrokeStyle(lineWidth: self.lineWidth, lineCap: .round,lineJoin: .round))
              .fill(LinearGradient(gradient: Gradient(colors: [Color.accent, .darkPurple]) , startPoint: .leading, endPoint: .trailing))
            .frame(width: self.width, height: self.width * 2).rotationEffect(Angle(degrees: 45) )
          
    }
}

struct OkView_Previews: PreviewProvider {
    static var previews: some View {
        OkView()
    }
}

The okShape is custom shape that we will create later. We use that to create the view that we used on the pop-up:

Create another swift file named OkShape.swift in the Shapes folder containing the following code:

struct OkShape: Shape {
         
    func path(in rect: CGRect) -> Path {
        
        return Path{ path in
            path.move(to: CGPoint(x: rect.origin.x, y: rect.size.height))
            path.addLine(to: CGPoint(x: rect.size.width, y: rect.size.height))
            path.addLine(to: CGPoint(x: rect.size.width, y: rect.origin.y))
        }
    }
}

This code draws an okShape. Just take a minute to read and understand the code. It’s pretty simple.

That’s it for the OkView. Let’s implement the LoadingView.swift. So add the following code inside it:

import SwiftUI

struct LoadingView: View {
    
    var lineWidth: CGFloat = 3
    
    @State private var flag = false
    
    var body: some View {
        
        return VStack {
            Circle()
                .trim(from: 0, to: 1)
                .stroke(style: StrokeStyle(lineWidth: self.lineWidth, lineCap: .round, lineJoin: .round, dash: [10,10], dashPhase: flag ? 0 : 40))
                .fill(LinearGradient(gradient: Gradient(colors: [Color.accent, .darkPurple]) , startPoint: .leading, endPoint: .trailing))
                .animation(Animation.linear(duration: 0.5).repeatForever(autoreverses: false))
                .onAppear {
                    self.flag = true
                    
            }.onDisappear {
                self.flag = false
            }
        }
    }
    
    func stop() {
        self.flag = false
    }
}

struct LoadingView_Previews: PreviewProvider {
    static var previews: some View {
        LoadingView()
    }
}

The only thing to note here is how we animate dashes. We do so by setting the dashPhase to 40 and animating it indefinitely. Once the view appears, we will initiate the animation by setting the flag to true in the onAppear closure.

We will need to create that custom transition to make all of the errors go away. Let’s create a new file in the Extensions folder named AnyTransitionExt.swift, and inside it, add the following code:


extension AnyTransition {
    static var hearbeat: AnyTransition {
        return AnyTransition.scale(scale: 1.7).combined(with: .scale(scale: 1))
    }
}

As you can see we combine 2 scale transition to create an heartbeat effect. Now you can build the project, and hopefully everything should be okay.

The next and last step is to manage the keyboard properly, but first go back to the BasketView.swift, and add the following modifier directly to the buy button:

.sheet(isPresented: self.$showPaymentScreen, content: {
                    PaymentView()
                })

To test, run the app, and navigate to the PaymentView.

Payment Simulation

Add the following method below body:

  fileprivate func simulatePayment() {
        
        if !cardNumber.isEmpty
        && !expireDate.isEmpty
        && !cardholderName.isEmpty
        && !cvv.isEmpty{
            self.isSendingPending = true
            
            Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
                withAnimation {
                    self.paymentSent = true
                }
                Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
                    withAnimation {
                        self.isSendingPending = false
                    }
                }
            }
        }
    }

The above code just checks if inputs are valid. We don’t do advanced validations, and only check whether the textfields are empty.

Now call that method in the Pay button action:

LCButton(text: "Pay", action: {
		// Call this in here!
        self.simulatePayment() 
}

Keyboard management in swift UI

There are lots of tricks that one might try to manage the keyboard in swift ui. One might use the onEditingChanged callback to know when the keyboard shows and move the content to accommodate it. This callback will be called when the user begins editing text and after the user finishes editing text, passing a Bool indicating whether self (the textfield) is currently being edited or not. The problem with this trick is that theSecureField does not have the onEditingChanged callback.

Another way to handle the keyboard is to use NotificationCenter events. We will combine publishers with NotificationCenter to send events when the keyboard shows and hides. There is view in swift ui that was made just to handle published event, and it’s called SubscriptionView.

In the Extensions folder, add a file named NotificationCenterExt.swift, and put the following code:


import UIKit

extension NotificationCenter {
    private static let keyboardWillShow = NotificationCenter.Publisher(center: .default, name: UIResponder.keyboardWillShowNotification)
    private static let keyboardWillHide = NotificationCenter.Publisher(center: .default, name: UIResponder.keyboardWillHideNotification)
    
    static var keyboardEvent = NotificationCenter.keyboardWillShow.merge(with: NotificationCenter.keyboardWillHide.map { Notification(name: $0.name, object: $0.object, userInfo: nil) })
        .map { ($0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect ?? .zero)}
}

In the above code, we create 2 publishers, then merge them together to create the keyboardEvent. We publish the keyboard’s frame in the event .

Now to subscribe to those events, we will need to use the SubscriptionView. Here is its construction:

init(content: Content, publisher: PublisherType, action: @escaping (PublisherType.Output) -> Void)

The view takes the content to be rendered, a publisher to subscribe to and an action that will handle the event.

So replace the content in body with the following:

  SubscriptionView(content:  createNavigationContent(), publisher: NotificationCenter.keyboardEvent ) { rect in
            withAnimation {
                self.formYOffset = rect.height > 0 ? -230 : 0
            }
      }

In the above code, we receive the keyboard’s frame from the publisher, and animate the form by setting the formYOffset state.

Here is the final result:

Conclusion

This is the end of this series. Stay tuned for many more. People have asked me how one can use real movie data fetched from a remote api. I might add a bonus tutorial explaining how to do just that. I will replace the sample data with a real one. Happy coding!!!

Get the source code here

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