Build a Movie Booking App in SwiftUI

Create an interactive tinder-like card swipe with custom design and animations

I’m here to introduce you to the movie booking app we will be developing. In this project, we’ll create an iOS app based on a beautiful UI design from Figma. We will incorporate interesting animations and interactions, such as the tinder-like card swipe with custom design using the drag gesture. Additionally, we will create a custom tab bar and use Navigation Views to build a whole flow. Corrected

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

The course consists of three parts. The first, which is the main focus of this tutorial, is the ticket interactions, then we’ll spend some time on the custom tab bar, and lastly, we’ll cover the app’s navigation flow.

Demo App

In this demo, you see that there are ticket interactions, followed by a custom tab bar. Then, the flow from the home view. Screen Recording 2022-08-21 at 4.07.27 PM

**Assets**

To follow this course, you will need to download the assets ****which include the Figma template, Xcode projects. It includes pre-made components, images, colors, and data files.

Figma Design

file cover - 2

The source files will include the project’s design in Figma, so you can inspect the colors, typography, assets, and animations. You can also duplicate it from the Figma community. Sourasith designed the entire UI and animations.

https://www.figma.com/community/file/1102953368834419129

Folder Structure

After all that, let’s look at the template. The template has multiple folders. It contains pre-made components or data files. The assets one contains all the colors and images we need.

  • UI
  • Data
  • Buttons
  • Views

Ticket View

To begin, let’s create a new SwiftUI file for TicketView. Start with a ZStack and add a background modifier to it; then place a VStack in it so that we can align the two texts vertically. Next, let’s add a series of modifiers to our texts.

ZStack {
  VStack(spacing: 30.0) {
    Text("Mobile Ticket")
      .font(.title3)
      .foregroundColor(.white)
      .fontWeight(.bold)

    Text("Once you buy a movie ticket simply scan the barcode to acces to your movie.")
      .frame(maxWidth: 248)
      .font(.body)
      .foregroundColor(.white)
      .multilineTextAlignment(.center)
  }
  .padding(.horizontal, 20)
  .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.background(
  LinearGradient(
    gradient: Gradient(colors: [Color("backgroundColor"), Color("backgroundColor2")]),
    startPoint: .top, endPoint: .bottom)
)

Background circle animations

For the background animations, we will use 2 circles with different colors and blur them. We will then move the circles around using a variable called “animate”. The actual component is already made.

Custom card design using specific corner radius

In this section, we will learn about MVVM and how to properly use data in our components. SwiftUI doesn’t come with specific corner radius so we will use a custom function.

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

TicketModel

Before starting on the tickets, we need to discuss the ticket model. It is a way of assigning a type to each data. Model-View-ViewModel (MVVM) is a software design pattern that separates program logic and user interface controls.

struct TicketModel: Identifiable {
    var id = UUID().uuidString
    var image: String
    var title: String
    var subtitle: String
    var top: String
    var bottom: String
}

Right below, we have the data for the tickets. Each model has an image, title, subtitle, top and bottom images.

var tickets: [TicketModel] = [
    TicketModel(image: "thor", title: "Thor", subtitle: "Love and Thunder", top: "thor-top", bottom: "thor-bottom"),
    TicketModel(image: "panther", title: "Black Panther", subtitle: "Wakanda Forever", top: "panther-top", bottom: "panther-bottom"),
    TicketModel(image: "scarlet", title: "Doctor Strange", subtitle: "in the Multiverse of Madness", top: "scarlet-top", bottom: "scarlet-bottom")
]

Ticket

Let’s create a new file for Ticket in cards folder. This part is the top part of the ticket. The first VStack keeps both parts together. To achieve the ticket shape, we will use the image as a mask on the VStack.

VStack(spacing: 0.0) {
  VStack(spacing: 4.0) {
    Text(title)
      .fontWeight(.bold)

    Text(subtitle)
  }
  .padding(EdgeInsets(top: 20, leading: 30, bottom: 0, trailing: 30))
  .frame(width: 250, height: 325, alignment: .top)
  .foregroundColor(.white)
  .background(
    Image(top)
      .resizable()
      .aspectRatio(contentMode: .fill)
  )
  .mask(
    Image(top)
      .resizable()
      .frame(width: 250, height: 325)
  )
  .overlay {
    RoundedRectangle(cornerRadius: 40)
      .stroke(
        LinearGradient(colors: gradient, startPoint: .topLeading, endPoint: .bottomTrailing),
        style: StrokeStyle(lineWidth: 2))
  }
}
.frame(height: 460)
.font(.footnote)
.shadow(radius: 10)

Don’t forget to add the variables as well as the gradient color.

@State var title: String = "Thor"
@State var subtitle: String = "Love and Thunder"
@State var top: String = "thor-top"

var gradient: [Color] = [Color("cyan"), Color("cyan").opacity(0), Color("cyan").opacity(0)]

Custom corner radius

Unfortunately, SwiftUI doesn’t come with specific corner radius so we have to use a custom function to only apply a corner radius to the top left and top right side of the ticket.

struct RoundedCorner: Shape {
  var radius: CGFloat = .infinity
  var corners: UIRectCorner = .allCorners

  func path(in rect: CGRect) -> Path {
    let path = UIBezierPath(
      roundedRect: rect, byRoundingCorners: corners,
      cornerRadii: CGSize(width: radius, height: radius))
    return Path(path.cgPath)
  }
}

extension View {
  func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
    clipShape(RoundedCorner(radius: radius, corners: corners))
  }
}

.cornerRadius(40, corners: [.topLeft, .topRight])

Create an infinite stack of cards

In this section, we will use a function to make a dash line and as well as creating an infinite stack for the tickets. This implementation will allow us later, to make an infinite swipe.

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

Dash Line

Now, the bottom part of the ticket has a dash line. SwiftUI doesn’t have a specific view for that, so we will use a custom view.

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

Next, let’s add a line view, some texts and a blur material to the ticket.

@State var bottom: String = "thor-bottom"

VStack(spacing: 10.0) {
  Line()
    .stroke(style: StrokeStyle(lineWidth: 2, dash: [7]))
    .frame(width: 200, height: 1)
    .opacity(0.6)

  HStack(spacing: 20.0) {
    HStack(spacing: 4.0) {
      Text("Date:")
        .fontWeight(.medium)
        .foregroundColor(Color("lightPurple"))
      Text("April 23")
        .foregroundColor(.black)
    }
    HStack(spacing: 4.0) {
      Text("Time:")
        .fontWeight(.medium)
        .foregroundColor(Color("lightPurple"))
      Text("6 p.m.")
        .foregroundColor(.black)
    }
  }

  HStack(spacing: 20.0) {
    HStack(spacing: 4.0) {
      Text("Row:")
        .fontWeight(.medium)
        .foregroundColor(Color("lightPurple"))
      Text("2")
        .foregroundColor(.black)
    }
    HStack(spacing: 4.0) {
      Text("Seats:")
        .fontWeight(.medium)
        .foregroundColor(Color("lightPurple"))
      Text("9, 10")
        .foregroundColor(.black)
    }
  }

  Image("code")
}
.frame(width: 250, height: 135, alignment: .top)
.background(.ultraThinMaterial)
.background(
  Image(bottom)
    .resizable()
    .aspectRatio(contentMode: .fill)
)
.mask(
  Image(bottom)
    .resizable()
    .frame(width: 250, height: 135)
)

Binding Height

In the demo, when we swipe left, the ticket separates. The variable to be adjusted is the height.

@Binding var height: CGFloat

Spacer(minLength: height)

Tickets

Create a new file for the tickets component, which will contain the three tickets and their interactions and animations. Start by copying the data from the data template.

@State var tickets: [TicketModel] = [
        TicketModel(image: "thor", title: "Thor", subtitle: "Love and Thunder", top: "thor-top", bottom: "thor-bottom"),
        TicketModel(image: "panther", title: "Black Panther", subtitle: "Wakanda Forever", top: "panther-top", bottom: "panther-bottom"),
        TicketModel(image: "scarlet", title: "Doctor Strange", subtitle: "in the Multiverse of Madness", top: "scarlet-top", bottom: "scarlet-bottom")
    ]

Then, add a ZStack so the 3 views are stack on top of each others. Using the ForEach method, we will loop through the list and push all the data to another view.

ZStack {
  ForEach(tickets) { ticket in
    InfiniteStackView(tickets: $tickets, ticket: ticket)
  }
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)

Infinite Stack

At the bottom, declare a new view and name it InfiniteStackView. Then, declare the tickets and ticket variables. In the body, call the Ticket component.

struct InfiniteStackView: View {
  @Binding var tickets: [TicketModel]
  var ticket: TicketModel

  @State var height: CGFloat = 0

  var body: some View {
    VStack {
      Ticket(
        title: ticket.title, subtitle: ticket.subtitle, top: ticket.top, bottom: ticket.bottom,
        height: $height)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
  }
}

Interactions and animations using drag gesture

In this section, we will learn about drag gestures and how to use it to create interactions. We will be able to make our app more interactive and enjoyable by doing this.

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

Get Index

As you can see, right after inputting the data, we see the last item of the array. The Doctor Strange ticket is on top of the Thor one. To fix this issue, we will create a getIndex() and using the value of the index, we are going to change the zIndex of the views.

func getIndex() -> CGFloat {
        let index = tickets.firstIndex { ticket in
            return self.ticket.id == ticket.id
        } ?? 0

        return CGFloat(index)
    }

.zIndex(Double(CGFloat(tickets.count) - getIndex()))

Drag Gesture

Let’s add the drag gesture modifiers to the ticket. First, declare two variables: isDragging contains a boolean value and offset is the translation of the item dragged. We will only allow dragging on the first ticket, and we will make sure that the rest are not draggable. Then, we will use offset’s x value to offset.

@GestureState var isDragging: Bool = false
@State var offset: CGFloat = .zero

  .gesture(
    DragGesture()
      .updating(
        $isDragging,
        body: { _, out, _ in
          out = true
        }
      )
      .onChanged({ value in
        var translation = value.translation.width
        translation = tickets.first?.id == ticket.id ? translation : 0
        translation = isDragging ? translation : 0

        withAnimation(.easeInOut(duration: 0.3)) {
          offset = translation
        }
      })
      .onEnded({ value in
        withAnimation(.easeInOut(duration: 0.5)) {
          offset = .zero
        }
      })
  )

  .offset(x: offset)

Tickets Modifiers

Here, let’s change the positioning of the second and third ticket. By using a condition, which is the index, we can target the specific ticket and change its position.

.rotationEffect(getIndex() == 1 ? .degrees(-6) : .degrees(0))
.rotationEffect(getIndex() == 2 ? .degrees(6) : .degrees(0))
.scaleEffect(getIndex() == 0 ? 1 : 0.9)
.offset(x: getIndex() == 1 ? -40 : 0)
.offset(x: getIndex() == 2 ? 40 : 0)

Get Rotation

To make the draggable ticket smoother, let’s add a rotation effect when dragged. We will create a function to take the offset and dividing it by the full width of the screen with paddings. Then, using that value times the angle.

func getRotation(angle: Double) -> Double {
        let width = UIScreen.main.bounds.width - 50
        let progress = offset / width

        return Double(progress * angle)
    }

.rotationEffect(.init(degrees: getRotation(angle: 10)))

Remove and add back

At present, swiping left or right has no effect. Let’s add the functionality of a right swipe. DispatchQueue basically execute the action inside it after a certain amount of time. First of all, we are updating the ticket to add it back to the array, then, we remove the first ticket.

func removeAndAdd() {
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    var updatedTicket = ticket
    updatedTicket.id = UUID().uuidString

    tickets.append(updatedTicket)

    withAnimation(.spring()) {
      tickets.removeFirst()
    }
  }
}

Then, let’s declare the width of the screen and if we swipe over half of that value to the right, we are setting the offset to right and calling the removeAndAdd().

let width = UIScreen.main.bounds.width
let swipedRight = offset > (width / 2)

if swipedRight {
  offset = width
  removeAndAdd()
} else {
  offset = .zero
}

Remove

The left swipe is very similar to the right one except the fact that we are not adding the ticket back to the array.

func removeTicket() {
  withAnimation(.spring()) {
    tickets.removeFirst()
  }
}

Swiping left will set the ticket’s width to the left edge and remove the ticket.

let swipedLeft = -offset > (width / 2)

if swipedLeft {
  offset = -width
  removeTicket()
} else {
  if swipedRight {
    offset = width
    removeAndAdd()
  } else {
    offset = .zero
  }
}

Change zIndex on drag

Let’s add some feedback to the interactions, when we swipe right, let’s change the zIndex of the dragged card under the second card.

.zIndex(getIndex() == 0 && offset > 100 ? Double(CGFloat(tickets.count) - getIndex()) - 1 : Double(CGFloat(tickets.count) - getIndex()))

Remove ticket interaction

The ticket has a height state because we will increase the spacer’s height when swiping left.

height = -offset / 5

height = .zero

Finally, add the Tickets() component in TicketView

Tickets()
.padding(.top, 30)

Make a unique navigation in SwiftUI

In this section, we will use a custom tab bar instead of using the native one and add the functionality to switch between tabs.

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

Tab Bar Data

We’ll begin by declaring all the cases for the tab bar. Create a new file in the Data directory and name it TabData. The strings are the same as the names of icons in the Assets folder.

enum Tab: String, CaseIterable {
    case home = "Home"
    case location = "Location"
    case ticket = "Ticket"
    case category = "Category"
    case profile = "Profile"
}

Tab View

Then, in ContentView, declare the currentTab and set it to home. Right below, we will be using the native TabView and set the 5 views. The tags sets a unique value for each view.

@State var currentTab: Tab = .home

VStack(spacing: 0) {
  TabView(selection: $currentTab) {
    Text("Home")
      .tag(Tab.home)

    Text("Location")
      .tag(Tab.location)

    TicketView()
      .tag(Tab.ticket)

    Text("Category")
      .tag(Tab.category)

    Text("Profile")
      .tag(Tab.profile)
  }
}

In the preview, you can see that you can select each native tab and hide it, by setting the UITabBar’s appearance to hidden.

init() {
        UITabBar.appearance().isHidden = true
    }

Custom Tab Bar

Now, let us create a custom Tab Bar. Create a new file and name it CustomTabBar. In ContentView, add the new component. To preview the tab bar more easily, change the preview to ContentView.

CustomTabBar()

Selected tab

Declare a currentTab binding on the component and link it to the currentTab state in ContentView.

@Binding var currentTab: Tab

CustomTabBar(currentTab: $currentTab)

You can display images for each tab by using a HStack to loop through the tabs and add an image for each of them.

HStack(spacing: 0.0) {
  ForEach(Tab.allCases, id: \.rawValue) { tab in
    Button {

    } label: {
      Image(tab.rawValue)
        .renderingMode(.template)
        .frame(maxWidth: .infinity)
        .foregroundColor(.white)
    }
  }
}
.frame(maxWidth: .infinity)
.background(.red)

Then, set the currentTab to the tab.

withAnimation(.easeInOut) {
  currentTab = tab
}

Create a simple tab bar interaction

In this section, we will learn how to add animations to the tab bar. We will make a background circle follow the selected tab.

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

ab Bar Background

To create a blurred effect, declare a variable that stores all the colors for the gradient background. Use Apple’s ultraThinMaterial to accomplish this effect. Then remove the red background.

var backgroundColors: [Color] = [Color("purple"),Color("lightBlue"), Color("pink")]

VStack {

}
.frame(height: 24)
.padding(.top, 30)
.background(.ultraThinMaterial)
.background(LinearGradient(colors: backgroundColors, startPoint: .leading, endPoint: .trailing))

To know which tab we are currently in, let’s adjust the selected tab’s offset.

.offset(y: currentTab == tab ? -17 : 0)

Tab Bar background animation

We are going to add a background circle to the selected tab.

var gradientCircle: [Color] = [Color("cyan"), Color("cyan").opacity(0.1), Color("cyan")]

  .background(alignment: .leading) {
    Circle()
      .fill(.ultraThinMaterial)
      .frame(width: 80, height: 80)
      .shadow(color: .black.opacity(0.25), radius: 20, x: 0, y: 10)
      .overlay(
        Circle()
          .trim(from: 0, to: CGFloat(0.5))
          .stroke(
            LinearGradient(colors: gradientCircle, startPoint: .top, endPoint: .bottom),
            style: StrokeStyle(lineWidth: 2)
          )
          .rotationEffect(.degrees(135))
          .frame(width: 78, height: 78)
      )
  }

We will create a function to return the index. Then, using that value to calculate the offset of the circle.

func getIndex() -> Int {
  switch currentTab {
  case .home:
    return 0
  case .location:
    return 1
  case .ticket:
    return 2
  case .category:
    return 3
  case .profile:
    return 4
  }
}

This function will return the position of the selected tab by dividing the width of the view by the amount of tabs.

func indicatorOffset(width: CGFloat) -> CGFloat {
  let index = CGFloat(getIndex())
  if index == 0 { return 0 }

  let buttonWidth = width / CGFloat(Tab.allCases.count)

  return index * buttonWidth
}

Geometry reader

We will use Geometry reader to get the width of the tab bar and use its value in the function.

GeometryReader { geometry in
  let width = geometry.size.width
}

.offset(x: indicatorOffset(width: width), y: -17)

Use TextField to make an editable field

In this section, we will cover the home view. We will add a background animation and learn how to make a custom search bar.

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

Home View layout

Similar to the TicketView, we will start with the basic layout.

ZStack {
  VStack(spacing: 0.0) {
    Text("Choose Movie")
      .fontWeight(.bold)
      .font(.title3)
      .foregroundColor(.white)
  }
  .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.background(
  LinearGradient(
    gradient: Gradient(colors: [Color("backgroundColor"), Color("backgroundColor2")]),
    startPoint: .top, endPoint: .bottom)
)

Then, add the background animations

@State var animate: Bool = false

CircleBackground(color: Color("greenCircle"))
  .blur(radius: animate ? 30 : 100)
  .offset(x: animate ? -50 : -130, y: animate ? -30 : -100)
  .task {
    withAnimation(.easeInOut(duration: 7).repeatForever()) {
      animate.toggle()
    }
  }

CircleBackground(color: Color("pinkCircle"))
  .blur(radius: animate ? 30 : 100)
  .offset(x: animate ? 100 : 130, y: animate ? 150 : 100)

Custom Search Bar

To create the custom search bar, first create a new component. A HStack will be used with two images and a TextField. The TextField is an editable text interface provided by SwiftUI.

struct CustomSearchBar: View {
  @State var searchText = ""

  var body: some View {
    HStack {
      Image(systemName: "magnifyingglass")

      TextField("Search", text: $searchText)

      Image(systemName: "mic.fill")
    }
    .padding(EdgeInsets(top: 7, leading: 8, bottom: 7, trailing: 8))
    .font(.headline)
    .background(.ultraThinMaterial)
    .foregroundColor(.white.opacity(0.6))
    .cornerRadius(10)
  }
}

Add the view in HomeView and adjust the paddings.

CustomSearchBar()
	.padding(EdgeInsets(top: 30, leading: 20, bottom: 20, trailing: 20))

Be sure to add this modifier in ContentView, otherwise the keyboard from the search bar will push some content up.

.ignoresSafeArea(.keyboard)

Enable the scroll gesture in your app

In this section, we will add the scroll view to the home view. By doing that, it allows us to see the content that is hidden below. It also enables the scroll gesture.## Scroll View

The ScrollView is a container that displays views vertically and horizontally. It contains four horizontal ScrollViews.

ScrollView(.vertical, showsIndicators: false) {
  VStack {

  }
}

Scroll Section

Create a new file for the Scroll View, which has a title and a horizontal scroll view that contains multiple cards.

@State var title: String = "Now Playing"

@State var posters: [String] = ["poster1", "poster2", "poster3", "poster4", "poster5", "poster6"]

var body: some View {
  VStack(alignment: .leading) {
    Text(title)
      .font(.headline)
      .foregroundColor(.white)
      .padding(.horizontal, 20)

    ScrollView(.horizontal, showsIndicators: false) {
      HStack(spacing: 20.0) {
        ForEach(posters.indices, id: \.self) { index in
          Image(posters[index])
            .resizable()
            .frame(width: 100, height: 130)
            .cornerRadius(20)
        }
      }
      .offset(x: 20)
      .padding(.trailing, 40)
    }
  }
}

In HomeView, declare the 3 arrays. Then, use the ScrollSection views 4 times.

@State var posters1: [String] = ["poster1", "poster2", "poster3", "poster4", "poster5", "poster6"]

@State var posters2: [String] = ["poster7", "poster8", "poster9", "poster10", "poster11", "poster12"]

@State var posters3: [String] = ["poster13", "poster14", "poster15", "poster16", "poster17", "poster18"]

VStack(spacing: 20.0) {
  ScrollSection(title: "Now Playing", posters: posters1)

  ScrollSection(title: "Coming Soon", posters: posters2)

  ScrollSection(title: "Top Movies", posters: posters3)

  ScrollSection(title: "Favorite", posters: posters1)
}
.padding(.bottom, 90)

In ContentView, we can replace the text by the view.

HomeView()
	.tag(Tab.home)

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

Scroll View

The ScrollView is a container that displays views vertically and horizontally. It contains four horizontal ScrollViews.

ScrollView(.vertical, showsIndicators: false) {
  VStack {

  }
}

Scroll Section

Create a new file for the Scroll View, which has a title and a horizontal scroll view that contains multiple cards.

@State var title: String = "Now Playing"

@State var posters: [String] = ["poster1", "poster2", "poster3", "poster4", "poster5", "poster6"]

var body: some View {
  VStack(alignment: .leading) {
    Text(title)
      .font(.headline)
      .foregroundColor(.white)
      .padding(.horizontal, 20)

    ScrollView(.horizontal, showsIndicators: false) {
      HStack(spacing: 20.0) {
        ForEach(posters.indices, id: \.self) { index in
          Image(posters[index])
            .resizable()
            .frame(width: 100, height: 130)
            .cornerRadius(20)
        }
      }
      .offset(x: 20)
      .padding(.trailing, 40)
    }
  }
}

In HomeView, declare the 3 arrays. Then, use the ScrollSection views 4 times.

@State var posters1: [String] = ["poster1", "poster2", "poster3", "poster4", "poster5", "poster6"]

@State var posters2: [String] = ["poster7", "poster8", "poster9", "poster10", "poster11", "poster12"]

@State var posters3: [String] = ["poster13", "poster14", "poster15", "poster16", "poster17", "poster18"]

VStack(spacing: 20.0) {
  ScrollSection(title: "Now Playing", posters: posters1)

  ScrollSection(title: "Coming Soon", posters: posters2)

  ScrollSection(title: "Top Movies", posters: posters3)

  ScrollSection(title: "Favorite", posters: posters1)
}
.padding(.bottom, 90)

In ContentView, we can replace the text by the view.

HomeView()
	.tag(Tab.home)

Create a flow between multiple screens in SwiftUI

In any app, navigation between screens is very important. In this section, we will learn about navigation link and navigation views.

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

Navigation link

We have now created multiple screens and a custom tab bar to navigate between them. The last part of this course is the flow. I will teach you how to create a flow from the Home View.

For the purposes of this guide, we will define a navigation link as any link in a view that takes the user to another view or section within your app. A navigation view is just what it sounds like: any view within your app that contains at least one navigation link.

The navigation link takes two arguments. The first one is the destination, and the second one is the label.

NavigationLink {
  Text("Booking View")
} label: {
  Image(posters[index])
    .resizable()
    .frame(width: 100, height: 130)
    .cornerRadius(20)
}

In the preview, the card view does not show another view when you click on it because we need to wrap the parent inside a navigation view.

NavigationView {
  VStack(spacing: 0) {
    TabView(selection: $currentTab) {
      HomeView()
        .tag(Tab.home)

      Text("Location")
        .tag(Tab.location)

      TicketView()
        .tag(Tab.ticket)

      Text("Category")
        .tag(Tab.category)

      Text("Profile")
        .tag(Tab.profile)
    }

    CustomTabBar(currentTab: $currentTab)
  }
}

Booking View

Create a new file for BookingView and let’s do the basic layout.

ZStack {
  Image("booking")
    .resizable()
    .aspectRatio(contentMode: .fit)
    .frame(maxHeight: .infinity, alignment: .top)
}
.background(Color("backgroundColor2"))
.ignoresSafeArea()

Then, we are going to declare a variable for the background gradient.

@State var gradient: [Color] = [Color("backgroundColor2").opacity(0), Color("backgroundColor2"), Color("backgroundColor2"), Color("backgroundColor2")]

VStack {
  LinearGradient(gradient: Gradient(colors: gradient), startPoint: .top, endPoint: .bottom)
    .frame(height: 600)
}
.frame(maxHeight: .infinity, alignment: .bottom)

Custom navigation bar

Instead of using the default Navigation buttons, we are going to use a custom one.

VStack(spacing: 0.0) {
  HStack {
    CircleButton(action: {}, image: "arrow.left")

    Spacer()

    CircleButton(action: {}, image: "ellipsis")
  }
  .padding(EdgeInsets(top: 46, leading: 20, bottom: 0, trailing: 20))
}
.frame(maxHeight: .infinity, alignment: .top)

Then, let’s add some text.

Text("Doctor Strange")
  .font(.title3)
  .fontWeight(.bold)
  .foregroundColor(.white)
  .padding(.top, 200)

Text("in the Multiverse of Madness")
  .font(.title3)
  .foregroundColor(.white)

Text(
  "Dr. Stephen Strange casts a forbidden spell that opens the doorway to the multiverse, including alternate versions of... "
)
.font(.subheadline)
.foregroundColor(.white)
.padding(30)

Text("Select date and time")
  .font(.title3)
  .fontWeight(.semibold)
  .foregroundColor(.white)

Learn how to make an unique button layout

In this section, we will create the custom layout for the buttons on the Booking view.

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

Selected Buttons

In this part, we will skip a lot of the logics because we don’t have a lot of time. Let’s start with declaring 2 variables. We will only use the selectedDate one and not the bindingSelection.

@State var selectedDate: Bool = false
@State var bindingSelection: Bool = false

Date Button

The date button is a component that is already made, because it has a very simple layout. We will use a HStack to position all the buttons horizontally, then, we will add specific padding for each view. On the middle one, let’s add an action, we will toggle the selectedDate boolean.

HStack(alignment: .top, spacing: 20.0) {
  DateButton(weekDay: "Thu", numDay: "21", isSelected: $bindingSelection)
    .padding(.top, 90)

  DateButton(weekDay: "Fri", numDay: "22", isSelected: $bindingSelection)
    .padding(.top, 70)

  DateButton(
    weekDay: "Sat", numDay: "23", width: 70, height: 100, isSelected: $selectedDate,
    action: {
      withAnimation(.spring()) {
        selectedDate.toggle()
      }
    }
  )
  .padding(.top, 30)

  DateButton(weekDay: "Sun", numDay: "24", isSelected: $bindingSelection)
    .padding(.top, 70)

  DateButton(weekDay: "Mon", numDay: "25", isSelected: $bindingSelection)
    .padding(.top, 90)
}

Time button

Similar to the date button, the time button is a component that is already made, because it has a very simple layout. We will use a HStack to position all the buttons horizontally, then, we will add specific padding for each view. On the middle one, let’s add an action, we will toggle the selectedHour boolean.

@State var selectedHour: Bool = false

HStack(alignment: .top, spacing: 20.0) {
  TimeButton(hour: "16:00", isSelected: $bindingSelection)
    .padding(.top, 20)

  TimeButton(hour: "17:00", isSelected: $bindingSelection)

  TimeButton(
    hour: "18:00", width: 70, height: 40, isSelected: $selectedHour,
    action: {
      withAnimation(.spring()) {
        selectedHour.toggle()
      }
    }
  )
  .padding(.top, -20)

  TimeButton(hour: "19:00", isSelected: $bindingSelection)

  TimeButton(hour: "20:00", isSelected: $bindingSelection)
    .padding(.top, 20)
}

Reservation button

Finally, let’s add a large button for the reservation. To animate it, we’ll set the offset of that button to 200 and make it appear from below once both variables are true.

LargeButton()
.padding(20)

.offset(y: selectedDate && selectedHour ? 0 : 200)

Then, wrap it in a navigation link to go to the next view.

NavigationLink {
  Text("Seats view")
} label: {
  LargeButton()
    .padding(20)
    .offset(y: selectedDate && selectedHour ? 0 : 200)
}

Don’t forget to embed the ZStack( BookingView ) in a NavigationView to make it work properly.

NavigationView {
}

Remove native back button

In the preview, you will see that we have the navigationBarBackButton showing. To remove it, use this modifier.

.navigationBarBackButtonHidden(true)

Learn how to dismiss your current view

In this section, we will learn about the dismiss method from SwiftUI and apply it to our back button. Then, we will tackle the Seats view design.

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

Dismiss

The custom back button doesn’t have an action and luckily for us, Apple has a dismiss method that we can use.

@Environment(\.dismiss) var dismiss

Seats View

This is the last screen that we will be working on. Let’s start with the basic layout.

@Environment(\.dismiss) var dismiss

VStack(spacing: 0.0) {
            HStack {
                CircleButton(action: {
                    dismiss()
                }, image: "arrow-left")

                Spacer()

                Text("Choose Seats")
                    .font(.title3)
                    .foregroundColor(.white)
                    .fontWeight(.bold)

                Spacer()

                CircleButton(action: {}, image: "calendar")
            }
            .padding(.top, 46)
            .padding(.horizontal, 20)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        .background(Color("backgroundColor"))
        .ignoresSafeArea()
        .navigationBarBackButtonHidden(true)
Image("frontSeat")
                .padding(.top, 55)
                .glow(color: Color("pink"), radius: 20)

Glow

In SwiftUI, there’s no way to natively add a glow effect. Here’s a function that does the job.

func glow(color: Color = .red, radius: CGFloat = 20) -> some View {
  self
    .shadow(color: color, radius: radius / 3)
    .shadow(color: color, radius: radius / 3)
    .shadow(color: color, radius: radius / 3)
}

Seats

Unfortunately, instead of coding this part, I will only use an image because it wasn’t the focus of this course.

Image("seats")
  .frame(height: 240)
  .padding(.top, 60)
  .padding(.horizontal, 20)

Status UI

This is a pre-made component because the layout is very simple.

HStack(spacing: 20.0) {
  StatusUI()

  StatusUI(color: Color("majenta"), text: "Reserved")

  StatusUI(color: Color("cyan"), text: "Selected")
}
.padding(.top, 60)

Using PopToRoot to navigate to the root view

SwiftUI allows embedded views inside other embedded views, and SwiftUI does not have a native way to return to the root view, so we will use a custom function.

Downloads

To follow this course, you can download the source file, which will help you compare your progress.

Bottom sheet

Let’s work the bottom layout.

ZStack(alignment: .topLeading) {
  VStack(alignment: .leading, spacing: 30.0) {
    HStack(spacing: 10.0) {
      Image(systemName: "calendar")
      Text("April 28 , 2022")
      Circle()
        .frame(width: 6, height: 6)
      Text("6 p.m.")
    }

    HStack(spacing: 10.0) {
      Image(systemName: "ticket.fill")
      Text("VIP Section")
      Circle()
        .frame(width: 6, height: 6)
      Text("Seat 9 ,10")
    }

    HStack(spacing: 10.0) {
      Image(systemName: "cart.fill")
      Text("Total: $30")
    }
  }
  .padding(42)
  .font(.subheadline)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.clipped()
.foregroundColor(.white)
.background(.ultraThinMaterial)
.padding(.top, 50)

Circle animation

This animation is very similar to the background one.

@State var animate: Bool = false
Circle()
  .frame(width: 200, height: 230)
  .foregroundColor(Color("purple"))
  .blur(radius: animate ? 70 : 100)
  .offset(x: animate ? -100 : 20, y: animate ? -20 : 20)
  .task {
    withAnimation(.easeInOut(duration: 7).repeatForever()) {
      animate.toggle()
    }
  }

Circle()
  .frame(width: 200, height: 230)
  .foregroundColor(Color("lightBlue"))
  .blur(radius: animate ? 50 : 100)
  .offset(x: animate ? 50 : 70, y: animate ? 70 : 30)
  .task {
    withAnimation(.easeInOut(duration: 4).repeatForever()) {
      animate.toggle()
    }
  }

Circle()
  .frame(width: 200, height: 230)
  .foregroundColor(Color("pink"))
  .blur(radius: animate ? 70 : 100)
  .offset(x: animate ? 150 : 170, y: animate ? 90 : 100)
  .task {
    withAnimation(.easeInOut(duration: 2).repeatForever()) {
      animate.toggle()
    }
  }

Show bottom sheet

Similar to the reservation button, we will hide the whole section until we click on the seats.

@State var showButton: Bool = false

.onTapGesture {
  withAnimation(.spring()) {
    showButton = true
  }
}

.offset(y: showButton ? 0 : 250)

Pop to root

In SwiftUI, it is very common to have an embedded navigation link inside another navigation link. This is the easiest way to go back to the root view. This function is inside the utils file.

HStack {
  Spacer()

  RoundButton(action: {
    NavigationUtil.popToRootView()
  })
}
.frame(maxHeight: .infinity)

Make sure to replace the text by this view.

SeatsView()
```1

About the Author

Leave a Reply

Your email address will not be published. Required fields are marked *

You may also like these