DEV Community

Cover image for Almost Netflix: An iOS Netflix Clone built with Appwrite
Jake Barnby for Appwrite

Posted on

Almost Netflix: An iOS Netflix Clone built with Appwrite

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!

Cover Image

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:

  1. 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.
  2. Setup Appwrite project following our Almost Netflix server setup guide
  3. Access to XCode 12 or newer. Find more about Xcode here.
  4. 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.

Create Project

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.

Name the project

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.

Appwrite SDK search

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.

Add Package to Target

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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

            ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
                }

                ...
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

Title View Display

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()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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?
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
            }

        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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

Feed View

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)
    }
Enter fullscreen mode Exit fullscreen mode

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")
                        }
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
Enter fullscreen mode Exit fullscreen mode

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)
    }
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
Enter fullscreen mode Exit fullscreen mode

Our movie detail view will now look like the following:

Movie Detail View

βš™οΈ 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 });
                    }

                }
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

πŸ”– 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)
        }
    }
Enter fullscreen mode Exit fullscreen mode

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!

πŸ”— Learn more

Oldest comments (1)

Collapse
 
lukasw12v profile image
lukas

Can I also watch the series there? :D