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