Welcome to the fourth post in our Almost Netflix series! We'll be building upon the project setup and build another frontend for our Netflix Clone! In this post, we will take a closer look at building the clone using iOS. In the next post of this series, we'll be building frontend for Android!
This one's all about iOS, so let's get started!
It would be impossible to write every piece of code in this article ๐ฌ you will read about all essential concepts, components, and communication with Appwrite. Still, if you want to check out every corner of our Almost Netflix for iOS, you can check out the GitHub Source Code that contains the whole app.
๐ค What is Appwrite?
Appwrite is an open source backend-as-a-service that abstracts all the complexity involved in building a modern application by providing you with a set of REST APIs for your core backend needs. Appwrite handles user authentication and authorization, databases, file storage, cloud functions, webhooks, and much more! If anything is missing, you can extend Appwrite using your favorite backend language.
๐ Requirements
In order to continue with this tutorial, you will need the following:
- Access to an Appwrite project or permission to create one. If you don't already have an Appwrite instance, you can install it following our official installation guide.
- Setup Appwrite project following our Almost Netflix server setup guide
- Access to XCode 12 or newer. Find more about Xcode here.
- Know the basics of iOS development and Swift UI
๐ ๏ธ Create iOS project
We will start by creating a new project. Open Xcode and select Start New Project. On the next screen select iOS -> App, then click Next.
On the next screen, give your project a name, enter an organization id and for the interface, select SwiftUI with language set to Swift, then click next.
On the next screen, select the folder where you want to save your new project and click create. This will create a new project and open it in Xcode. You should now see the following screen.
โช๏ธ Dependencies
We will start by adding dependencies. For this project we need two packages. First the Appwrite SDK
and then Kingfisher
image package for displaying images from URL. In order to add a package, go to File -> Add Packages
In the dialog box that appears, tap the top right search icon and type the following GitHub URL for the SDK https://github.com/appwrite/sdk-for-apple and hit Enter. You should see the sdk-for-apple package listed.
Now select the sdk-for-apple package and on the right side, select dependency rule as Up to major version and use version 0.3.0
for the latest SDK. Now click on the Add Package button. Xcode will download the Appwrite Apple SDK along with its dependencies and will add it to your project.
Make sure the proper target is selected in the Add to target in the dialog as shown above, then click Add Package button. The package should successfully be added to your project. Now following the same process, this time search for the Kingfisher
package using the GitHub URL as the following https://github.com/onevcat/Kingfisher.
Now that we have all our dependencies, we will create an AppwriteService
class and initialize the Appwrite client, database and authentication services. Let's start. First add a new Swift file and name it AppwriteService.swift
containing the following:
class AppwriteService {
let client: Client
let database: Database
let account: Account
let storage: Storage
let avatars: Avatars
static let shared = AppwriteService()
init() {
client = Client()
.setEndpoint("https://YOUR_ENDPOINT")
.setProject("PROJECT_ID")
.setSelfSigned() // Do not use in production
database = Database(client)
account = Account(client)
storage = Storage(client)
avatars = Avatars(client)
}
}
Using the above code, we are initializing our client and database, account, storage and avatars services. Replace YOUR_ENDPOINT
with your own endpoint and PROJECT_ID
with your own project ID. Now that we have setup our Appwrite client and services, we will start implementing authentication in our application.
๐ Authentication
We will start by creating basic views that are required to implement the authentication flow. Let's start with login view. First create a new SwiftUI View file and name it LoginView.swift
. The full view definition can be found here, for now we will focus on the button actions, where they defer to a view model we will create later:
@State private var email = ""
@State private var password = ""
@EnvironmentObject var authVM: AuthVM
var body: some View {
VStack {
...
Button("Login") {
authVM.login(email: email, password: password)
}
...
}
}
}
Create a similar file named SignupView.swift
and update with the full view from here. This view is largely the same but calls a different method in our AuthVM
when the Sign Up button is clicked.
We now need a view that we can use to switch between login and sign up. Create a new file called TitleView.swift
. Here we will focus on the navigation aspect:
import Foundation
import SwiftUI
struct TitleView: View {
@State private var selection: String? = nil
var body: some View {
NavigationView {
VStack {
...
NavigationLink(destination: LoginView(), tag: "Sign In", selection: $selection) {}
NavigationLink(destination: SignupView(), tag: "Sign Up", selection: $selection) {}
Button("Sign In") {
self.selection = "Sign In"
}
Button("Sign Up") {
self.selection = "Sign Up"
}
...
}
}
}
}
Here we use navigation links inside a navigation view to navigate to sign in or sign up. When a user taps one of the buttons, the selection
state variable is set. This way, if we were to add more buttons, we only need to add new tags, not a new state variable for each button.
We now have a nice title view that navigates to either login or sign up that looks like this:
Now we need a view to navigate to on successful sign in or sign up. Create another file named HomeView.swift
and update with the following code for now, to simply create a dummy home page to make sure the auth flow is complete and correct.
import SwiftUI
struct HomeView: View {
var body: some View {
NavigationView {
ZStack {
Color(.black).ignoresSafeArea()
}
}
}
}
We also need a view that will display either a splash UI, login UI or the home page based on authentication state. Let's create a file named MainView.swift
and update with the following code.
import SwiftUI
struct MainView: View {
@EnvironmentObject var authVM: AuthVM
var body: some View {
Group {
if !authVM.checkedForUser {
SplashView()
} else if authVM.user != nil {
HomeView().environmentObject(MoviesVM(userId: authVM.user!.id))
} else {
TitleView()
}
}
}
}
Now that we have all the views necessary for authentication flow, we will create a view model that will handle the authentication logic. Create a new swift file and name it AuthVM.swift
. Inside the file create the following class.
class AuthVM: ObservableObject {
@Published var checkedForUser = false
@Published var error: String?
@Published var user: User?
}
We also have defined three variables that will help us track and display the authentication state, error and user's details. Now let's add a function that will help us create an account. Add the following to the AuthVM
class.
func create(name: String, email: String, password: String) {
error = ""
AppwriteService.shared.account.create(userId: "unique()", email: email, password: password, name: name) { result in
switch result {
case .failure(let err):
DispatchQueue.main.async {
print(err.message)
self.error = err.message
}
case .success:
self.login(email: email, password: password)
}
}
}
Above, we have a create method that accepts name, email and password and creates a new account using Appwrite's account service. Upon successful creation of an account, we are also calling login method to create session for the user. Let us now add the following login
function to our AuthVM
.
public func login(email: String, password: String) {
error = ""
AppwriteService.shared.account.createSession(email: email, password: password) { result in
switch result {
case .failure(let err):
DispatchQueue.main.async {
self.error = err.message
}
case .success:
self.getAccount()
}
}
}
The login function accepts an email and password and creates a session using Appwrite's account service. Once a session is created, we call the getAccount
function to get the details of the current user. Next, let us add the getAccount
method that will get the user's account and help us check whether the user is logged in.
private func getAccount() {
error = ""
AppwriteService.shared.account.get() { result in
DispatchQueue.main.async {
self.checkedForUser = true
switch result {
case .failure(let err):
self.error = err.message
self.user = nil
case .success(let user):
self.user = user
}
}
}
}
To get the user account we are simply calling the get
function of the Appwrite's account service. Upon success we are setting the user property with the received user details. Finally let us add an initializer method to the AuthVM
class that will call getAccount
so that user's authentication state is checked on application start-up.
init() {
getAccount()
}
That's it for authentication! Our application will now show a splash screen until we have checked if the user is logged in, if they are, they will be shown the home screen, otherwise the login screen.
๐ฌ Movies Page
Let us head back to the HomeView.swift
file and start creating our movie carousel views. We need to create a horizontal scroll view within a vertical scroll view. The vertical scroller will iterate movie categories and the horizontal scrollers will iterate the movies within each category. We will first add the vertical scroll to our HomeView
as follows:
ScrollView(.vertical, showsIndicators: false) {
if(moviesVM.featured != nil) {
MovieItemFeaturedView(
movie: moviesVM.featured!,
isInWatchlist: moviesVM.watchList.contains(moviesVM.featured!.id),
onTapMyList: {
self.onTapMyList(moviesVM.featured!.id)
}
)
} else if(!((moviesVM.movies["movies"] ?? []).isEmpty)) {
let movie = (moviesVM.movies["movies"]!).first!
MovieItemFeaturedView(
movie: movie,
isInWatchlist: moviesVM.watchList.contains(movie.id),
onTapMyList: {
self.onTapMyList(movie.id)
}
)
}
VStack(alignment: .leading, spacing: 16) {
ForEach(appwriteCategories) { category in
MovieCollection(title: category.title, movies: moviesVM.movies[category.id] ?? [])
.frame(height: 180)
}
}.padding(.horizontal)
}
Let's break that down. Inside our scroll view, we first check if we have a featured movie. If we do, this is shown at the top of the page using a MovieItemFeaturedView
that we will add later.
Below the featured movie, we have a vertical stack that iterates some pre-defined categories and displays a MovieCollectionView
for each.
Now we can create our subviews. First, create a new file called MovieItemFeaturedView.swift
and add the following:
import SwiftUI
import Kingfisher
struct MovieItemFeaturedView: View {
@State private var isShowingDetailView = false
let movie: Movie
let isInWatchlist: Bool
let onTapMyList: () -> Void
var body: some View {
ZStack{
KFImage.url(URL(string: movie.imageUrl))
.resizable()
.scaledToFill()
.clipped()
VStack {
HStack {
...
Button {
onTapMyList()
} label: {
VStack {
Image(systemName: self.isInWatchlist ? "checkmark" :"plus")
Text("My List")
}
}
NavigationLink(destination: MovieDetailsView(movie: movie), isActive: $isShowingDetailView) { EmptyView() }
Button {
self.isShowingDetailView = true
} label: {
VStack {
Text("Info")
}
}
}
}
}
}
}
Our featured movie is now set-up, so let us move on to the MovieCollectionView
. Create a new filed called MovieCollectionView.swift
and update with the following code:
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(movies) { movie in
MovieItemThumbnailView(movie: movie)
.frame(width: itemWidth, height: itemHeight)
}
}
.frame(height: frameHeight)
}
Here we have a scroll view, with a horizontal stack inside, which iterates each movie within the given collection and displays a MovieItemThumbnailView
for each. Now we can create a new file, MovieItemThumbnailView.swift
and add the following:
NavigationLink (destination: MovieDetailsView(movie: movie)) {
KFImage.url(URL(string: movie.imageUrl))
.resizable()
.scaledToFit()
.cornerRadius(4)
}
Here our image is nested inside a navigation link, so when the image is clicked, our app will navigate to the movie detail view. That's it for our movie feed. We now have a nested scroll view displaying collections of movies by category. Now we need to create a detail view for individual movies.
๐ต๏ธ Detail Page
Now we can define our movie detail view. Create a new file called MovieDetailsView.swift
. Inside the details view we have a button that toggles adding the current movie to a users watchlist:
Button {
self.addToMyList()
} label: {
VStack {
Image(systemName: moviesVM.watchList.contains(movie.id) ? "checkmark" : "plus")
Text("My List")
}
.padding()
}
func addToMyList() -> Void {
moviesVM.addToMyList(movie.id)
}
}
Now we need to add the MovieGridView
, which shows similar movies to the one displayed on the detail page. Let's add that now as a new file called MovieGridView.swift
:
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], alignment: .leading, spacing: 4) {
ForEach(movies) { movie in
MovieItemThumbnailView(movie: movie)
}
}
Our movie detail view will now look like the following:
โ๏ธ Movies ViewModel
We need to add the View Model we are using to fetch movies and publish them to the UI. This class contains most of the logic for managing the movie feed as well as the currently logged in user's watchlist. Add a new file, MoviesVM.swift
with the following:
class MoviesVM: ObservableObject {
@Published var featured: Movie?
@Published var movies: [String:[Movie]] = [:]
@Published var watchList: [String] = []
let userId: String
init(userId: String) {
self.userId = userId
getMovies()
getFeatured()
}
public func getMyWatchlist() {
AppwriteService.shared.database.listDocuments(
collectionId: "movies",
queries: [
Query.equal("$id", value: watchList)
]
) { result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(let docs):
self.movies["watchlist"] = docs.convertTo(fromJson: Movie.from)
}
}
}
}
func addToMyList(_ movieId: String) {
if(self.watchList.contains(movieId)) {
removeFromMyList(movieId)
} else {
AppwriteService.shared.database.createDocument(collectionId: "watchlists", documentId: "unique()", data: ["userId": userId, "movieId": movieId, "createdAt": Int(NSDate.now.timeIntervalSince1970)], read: ["user:\(userId)"], write: ["user:\(userId)"]){ result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(_):
self.watchList.append(movieId)
self.getMyWatchlist()
print("successfully added to watchlist")
}
}
}
}
}
func removeFromMyList(_ movieId: String) {
AppwriteService.shared.database.listDocuments(collectionId: "watchlists", queries: [
Query.equal("userId", value: userId),
Query.equal("movieId", value: movieId)
], limit: 1) { result in
switch result {
case .failure(let err):
print(err.message)
case .success(let docList):
AppwriteService.shared.database.deleteDocument(collectionId: "watchlists", documentId: docList.documents.first!.id) {result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(_):
let index = self.watchList.firstIndex(of: movieId)
if(index != nil) {
self.watchList.remove(at: index!)
self.getMyWatchlist()
print("removed from watchlist")
}
}
}
}
}
}
}
func isInWatchlist(_ movieIds: [String]) {
AppwriteService.shared.database.listDocuments(
collectionId: "watchlists",
queries: [
Query.equal("userId", value: userId),
Query.equal("movieId", value: movieIds)
]
) { result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(let docList):
let docs = docList.convertTo(fromJson: Watchlist.from)
for doc in docs {
self.watchList.append(doc.movieId)
}
if(docs.count > 1) {
self.getMyWatchlist()
}
}
}
}
}
private func getFeatured() {
AppwriteService.shared.database.listDocuments(
collectionId: "movies",
limit: 1,
orderAttributes: ["trendingIndex"],
orderTypes: ["DESC"]
) { result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(let docs):
self.featured = docs.convertTo(fromJson: Movie.from).first
if(self.featured != nil) {
self.isInWatchlist([self.featured!.id])
}
}
}
}
}
private func getMovies() {
appwriteCategories.forEach {category in
AppwriteService.shared.database.listDocuments(collectionId: "movies", queries: category.queries, orderAttributes: category.orderAttributes, orderTypes: category.orderTypes) { result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(let docs):
self.movies[category.id] = docs.convertTo(fromJson: Movie.from)
self.isInWatchlist(docs.documents.map { $0.id });
}
}
}
}
}
}
๐ Watchlist Page
Bonus points: We have also implemented a Watchlist feature. To add this, we need to another new view, WatchlistView.swift
:
ScrollView(.vertical, showsIndicators: false) {
if(!(moviesVM.movies["watchlist"] ?? []).isEmpty) {
MovieGridView(movies: moviesVM.movies["watchlist"] ?? [])
} else {
Text("You have no items in your watchlist")
.foregroundColor(Color.white)
}
}
This view will get all the movies in the users watchlist and display them in our previously created MovieGridView
.
๐จโ๐ Conclusion
Ta-da! We have (almost) cloned Netflix, Swiftly and easily with Appwrite as our backend! To become part of the Appwrite community, you can join our Discord server. We look forward to seeing what you build and who you are (when you join our discord!)
With each Appwrite release amazing new features are being added and as they are, we will return to our Netflix clone to keep it growing!
Top comments (1)
Can I also watch the series there? :D