How to build a note-taking app with Firebase + SwiftUI

Peter Hrvola
Firebase Developers
13 min readMar 23, 2020

In this series, I’ll be sharing my take on developing multi-platform applications using SwiftUI and Google Firebase. I’ll go over the process of creating a note-taking app. The final result this series is building towards is available on Github.

Once while browsing wild waters of internet I stumbled upon an article about an innovative system of taking notes called the Zettelkasten Method. In short, Zettelkasten is a knowledge management method. Notes form a tree structure where every new note expands information from its parent, creating clusters of information with similar topic. This all seems like something that a person with a fish memory, like me, could benefit from. Unfortunately, there is only a handful of note-taking apps for this method — most of which have a rather outdated look or awkward user experience.

So I pulled out my CS degree, prepared to get my hands dirty and went on a quest to find the latest technologies to build native applications. In my certification for Google Cloud Architect, I learned about Firebase and implemented some sample projects with it but never anything more serious. Firebase offers free accounts with rather generous free tier limits. Where Firebase comes in especially handy is synchronizing data across multiple devices. One of the features of Firebase is Cloud Firestore, a document-based NoSQL cloud database with real time sychronization. Firestore can be used to synchronize notes on the phone with notes on the laptop without implementing a single line of code. When a device is not connected to the internet, updates are stored locally and synchronized once the device goes online again.

Another very recent technology is Apple SwiftUI. Together with Catalyst, it allows us to run iOS applications on macOS. SwiftUI offers a declarative style of programming UIs which massively simplifies the whole process. SwiftUI’s declarative style is very similar to React Native. It also supports live preview of your UI while editing, which allows for faster turn-around times.

Application requirements

Enough talk, let’s get to work. This is Zetten, the note-taking app. Notes are organized in a tree-like structure where a note can have a child note, but we are not limited to having a single tree, since your grocery shopping list doesn’t entirely relate with the latest stock info — or maybe it does? If you wonder where the name Zetten comes from, check out the back story.

Zetten

Another requirement is to be able to synchronize notes on a laptop and a phone — after all, we live in 2020, not in the stone age! This is where Firebase’s realtime sync comes in handy.

And last but not least it’s important to be able to search in your (hopefully) growing number of short notes in the Zettelkasten system. For this, we need to bring the heavy guns, full-text search with stemming and indices on the device. Luckily the latest version of GRDB has just what we need.

So how this will all look like? Well, I don’t have the best eye for design but let’s say something like this:

iOS List View
macOS List View

Setting up Firebase

At first sight, Firebase might seem difficult to set up, however, we are getting a lot in return so bear with me for a while. Firebase’s most popular features include authentication, an easy to use NoSQL cloud-basewd document database, crash reporting, analytics, machine learning and advertising integration. Zetten uses authentication, document storage, and crash reporting.

Let’s start by setting up authentication in Firebase. First, you’ll need to create a new Firebase project. You can use the free (Spark) tier which has more than enough resources for private or development projects. After accepting the terms and conditions we start at the welcome dashboard.

The next step is to add a new platform to your account. Pressing the iOS button will lead you to a series of steps to include your application. First, we need to enter the iOS bundle ID which has to be the same as bundle ID in Xcode when creating a new project. Then we download “GoogleService-Info.plist” — this is a configuration file that helps the Firebase SDK to connect to your Firebase project. We will need in a second.

Creating a new project

Let’s fire up Xcode and select create a new project. We want to create a single view project. In the next step, we choose the application name, your team (can be personal development team) and most importantly we want to use SwiftUI as a user interface engine. We don’t need to include any tests for now so we can uncheck those options.

Allow Mac as a target

Let’s finish creating the project by allowing the application to compile to both macOS and iOS platforms. This will use Apple Catalyst when compiling for the Mac target.

To feed your curiosity Xcode 11.3 is changing bundle identifier for Mac with prefix maccatalyst, from Xcode 11.4 Mac and iOS share the same identifier. Now you should be able to compile the application both to iOS and Mac.

Let’s get back to configuring Firebase. Remember that Firebase “GoogleService-Info.plist” file? Now it’s the time to copy the file to the root of your project. In this case it’s Zetten/Zetten since the Zetten directory can continue multiple projects e.g ZettenUI-test. The GoogleService-Info.plist file tells the Firebase SDK how to connect to the Firebase project we just created.

Firebase recommends using CocoaPods as the package manager for the Firebase platform on iOS. If you are missing CocoaPods on your machine please refer to the installation guide. Once we are sure that CocoaPods is properly installed we can navigate to the root directory of our project using terminal and execute pod init to set up the pods for our project. To add the Firebase SDK to your project, open Podfile and add the following lines to the Zetten target:

Installing dependencies

This includes all dependencies we will be needing later including a small hack to compile GRDB with FTS5 support. FTS5 is a virtual table module for SQLite which allows us to build reverse indices for our notes and perform full-text searches on them.

The Firebase/Analytics pod is only needed for first time set-up to notify Firebase that everything is working. However, having the Analytics pod as a dependency will prevent you from compiling for macOS. When compiling for macOS you might want to remove it and run pod install again.

Once the pod installation is done the project will have a new zetten.xcworkspace file. From now on you’ll have to use the xcworkspace file to open your Xcode project otherwise project dependencies won’t be correctly linked. Let’s close Xcode now and open the project using the new xcworkspace file.

If you are an active terminal user you can use “xed .” in the root directory which first opens workspace if exists, otherwise, it falls back to opening a normal project.

If at any point you feel lost you’re welcome to open the finished application on Github — it contains all files we will be working towards to.

Lastly, Firebase Authentication needs permissions to access the keychain. Go in Xcode -> Zetten -> Targets -> Signing & Capabilities. Press + Capability and add Keychain Sharing capability. Adding keychain permissions allows to share account information across all apps that belong to the same access group. This is used for signing in once and be signed in across all apps. The alternative is to disable sharing credentials, more detail can be found in the documentation.

Next, add name of the shared keychain group. The default format {organisation}.zetten is enough for our purposes. This concludes setting up Firebase.

Authentication view

Ugh, that was quite a bit of work, but now we are ready to use all of the Firebase libraries.

Login UI

Let’s start by creating a very simple login page with email and password. SwiftUI uses a declarative approach very similar to React. The visual representation of the view is created by composing blocks of UI elements. UI elements are in fact functions which generate the respective view including its child views. These high level function take care of defining the layout and any constraints and provide additional methods to further modify them. This means the beautiful native views can be generated with very few lines of code without any repetitive boilerplate.

On the right side of the view we can see the Preview Canvas — it shows in real-time how the view is going to look like on a real device. The preview can be configured using the PreviewProvider at the end of the file, in our case in a struct named LoginView_Previews. Multiple previews can be generated at the same time. As of now, previews are supported only for iOS targets. When you try to request a preview for Mac you’ll be faced with a rather unhelpful message where in fact the problems is the chosen target (top left corner).

Login view implementation

Let create a new file in our project called LoginView.swift and copy the above code into it. Every SwiftUI view implements the View protocol. This protocol requires you to provide a variable called body which returns some View. The some keyword allows us to return multiple types of view e.g. a Text view or a Button view. However, only one type of view can be returned from body (more on this a bit later)

First we start with a VStack (vertical stack). VStack allows us to combine multiple views together. VStack {} is equivalent with VStack(content: {}), whereas content defines what’s going to be generated inside the stack.

Inside the VStack we first include a Text function which generates a View showing some simple text. The Text function returns a View, this fact can be used to further modify the view created by the Text function. Views can be modified using … , yes “modifiers”. In this case, .font is a view modifier which allows you to provide font formatting arguments. The .font modifier again returns a modified view with applied changes, this means we can make further changes to the Text view, for example change colour using the .background modifier.

TextField is similar to Text but allows the user to type some information into the box. We will use this to get the user’s email address. Part of TextField’s definition is providing where the result will be stored. In this case at the beginning of the LoginView we have declared two variables: email and password. The @State in the declaration and $ in TextField tell Swift that the value is dynamically updated. Meaning that changes done by user to the TextField will be saved in the $ variable and changes done by the programmer to the variable will be shown to the user using TextField. More about this this in the next part. SecureField is the same as TextField but user won’t be able to see the value — this is useful for password fields.

After filling all the information we need a way to perform some action. A Button is ideal for this use case. When defining buttons we need to provide a Swift String type instead of the SwiftUI Text element. In addition, we need to provide an action that is performed when the button is pressed. For now, we use an empty function {}, but later this is going to perform the login.

To make view a bit prettier we can use Divider and Spacer. Divider inserts a line to visually separate views. Spacer generates a space, but in a rather funny way: Spacer consumes all empty space and pushes content up or down. When we have multiple spacers e.g Spacer1, Text, Spacer2, Spacer3, the View will compute that 90% of the view is empty and then divide total empty space by the number of spacers and each spacer will get a proportional share of space, in this case 30%. Meaning that our Text view will be in approximately about 1/3 from the top.

Let’s open ContentView.swift and replace Text(“Hello, World!”) with a call to our login view LoginView(). This will show us our newly created view when running our simple application. To see this in action, run the application and verify you can see login page. If facing any problems refer to the finished application on Github.

Let’s create a new file CreateAccountView.swift. The CreateAccount view is very similar to the previous LoginView with a slight modification in the title, description and a different action when pressing create button.

You can copy the provided code from below.

We want to be able to show CreateAccountView after pressing the Create Account button in LoginView. Let’s go back to LoginView and make the following modifications:
1. Wrap VStack into NavigationView {}
2. Before closing the VStack use NavigationLink with destination CreateAccountView:

Wrapping VStack in a NavigationView allows us to use NavigationLink inside and adds a top bar for navigation. NavigationLink creates a clickable view, in this case just Text, but in general can be any view e.g. Image. After pressing the content of a NavigationLink, a new view will open sliding over the current view and adding a back button. NavigationLink cannot be used inside another NavigationLink. However, there are ways to get around this constraint — we cove these in the second part.

Integrating the Authentication view with Firebase

We’ve created a nice authentication view, however for now it’s unusable as it’s not connected to any authentication code. Let’s integrate our views with Firebase.

Let’s start by initialising Firebase after application launch. This is done in AppDelegate.swift in the application:didFinishLaunchingWithOptions function. First add import Firebase in the imports for AppDelegate. Then make sure to add FirebaseApp.configure() into the application:didFinishLaunchingWithOptions method. The result should look like this:

The application:didFinishLaunchingWithOptions method in AppDelegate is often used for libraries that require to start after application launch and need to do some set up work to function properly.

Let’s create AuthenticationService.swift. This service is mostly wrapper around Firebase functions.

Authentication service

AuthenticationService must be defined as a class since it has to implement the ObservableObject protocol. ObservableObject allows a view to monitor a class for changes. We will use this in some of our views to check if a user has logged in.

First we define a variable for our user and mark it with @Published which allows views to react to changes. Next we define a constructor. In the constructor, we create a listener for changes in authentication status. Whenever authentication in Firebase changes, our function will be invoked allowing us to update our info about the current user. We store the listener in a variable so we can stop listening for changes when we want to.

In many cases listeners won’t work without storing the handle in a variable. This is caused by Swift automatically cleaning unused resources. Since Swift thinks that if you didn’t store the handler you don’t need to listen for changes. This can be tricky and easy to forget. Whenever you are expecting changes but they never happen, check first if you’ve stored the handle.

Next we have a trio of signIn, signUp, signOut functions which just forward calls to the Firebase authentication service.

The next step is to register our AuthenticationService. For this purpose we can use dependency injection. Dependency injection (DI) is a technique where you put all your available resource into a box and when some part of code needs something it can go to a box and get it.

Let’s create a new file AppDelegate+Injection.swift. This will be our box and we want to make AuthenticationService available for others to use.

Let’s go back to our AuthenticationView and let’s use our new shiny service. First add import to the top of the file import Resolver. On top of CreateAccountView we will add dependency on AuthenticationService. The result will be this:

Now the authenticationService is available for use and we can modify our create account button. Change action to:

This will create new account in Firebase. Let’s do something similar with LoginView. First, let’s inject the authentication service to our view:

And let’s change login button to:

To know that authentication is working let’s change our ContentView to show the AuthenticationView only when user is not authenticated, otherwise show them a warm welcome message.

Again, we declare a dependency on AuthenticationService and check if the user is signed in or not. The syntax is slightly different because we want to be able to observe changes on user and not just call methods.

The final step in implementing authentication is to enable authentication in Firebase. Go to Firebase > Authentication > Sign-in method > Email/Password authentication > Enable. Now we can compile our application and verify that authentication works. It doesn’t have the best experience but it works and on top of everything is completely safe and secure since all the important parts are controlled and managed by Google.

If you have problems with getting authentication working you can provide better handler for actions in buttons showing you what has happened for example:

Or if you can’t resolve the problems yourself please check finished example at Github.

Conclusion

In the first part of the series we’ve set up Firebase and Firebase Authentication, created authentication views and connected them to Firebase.

We’ve also covered the basics of composing views in declarative style in SwiftUI.

In the second part, we will create a list view showing all notes. We will connect Zetten to Cloud Firestore and synchronize notes across devices.

Thanks for reading!

--

--