Navigation

iOS Swift Tutorial

In this tutorial, you will create a task tracker app that allows users to:

  • Register themselves with email and password.
  • Sign in to their account with their email and password and sign out later.
  • View a list of projects they are a member of.
  • View, create, modify, and delete tasks in projects.
  • View a list of team members in their project.
  • Add and remove team members to their project.

This tutorial should take around 30 minutes.

Info With Circle IconCreated with Sketch.Note
Check Out the Quick Start

If you prefer to explore on your own rather than follow a guided tutorial, check out the iOS Quick Start. It includes copyable code examples and the essential information that you need to set up a MongoDB Realm application.

Using SwiftUI and Combine? Check out Quick Start with SwiftUI and Combine.

Before you begin, ensure you have:

1

We've already put together a task tracker iOS application that has most of the code you'll need. You can clone the client application repository directly from GitHub:

git clone https://github.com/mongodb-university/realm-tutorial-ios-swift.git
Important With Circle IconCreated with Sketch.Important

The realm-tutorial-ios-swift repository contains two branches: final and start. The final branch is a finished version of the app as it should look after you complete this tutorial. To walk through this tutorial, please check out the start branch:

git checkout start

In your terminal, run the following commands to navigate to the task tracker client application and install its dependencies with CocoaPods:

Important With Circle IconCreated with Sketch.Important

CocoaPods minimum version 1.10 is required to install Realm.

cd realm-tutorial-ios-swift
pod install --repo-update

Once the installation is complete, use Xcode to open the xcworkspace generated by CocoaPods, which has all of the dependencies configured. You can do this from the command line:

open "Task Tracker.xcworkspace"
2

In the Xcode Project Navigator, you can see the source files of the task tracker application in the Task Tracker folder. The relevant files are as follows:

FilePurpose
Models.swiftDefines the Realm object models used by this app.
SceneDelegate.swiftDeclares the global Realm app instance used by other parts of the app.
WelcomeViewController.swiftImplements the login and user registration functionality.
ProjectsViewController.swiftThe list of projects that the logged-in user is a member of.
TasksViewController.swiftThe list of tasks for a given project.
ManageTeamController.swiftThe list of team members for a project and the associated team management controls.
3

To get the app working with your backend, you first need to add your Realm App ID to the SceneDelegate.swift file. The SceneDelegate.swift file declares a global Realm App instance:

let app = App(id: "tasktracker-qczfq")

Change the value of id to your Realm app ID, which you can find in the Realm UI.

4

Navigate to the Models.swift file to implement the Realm Object Models used in this app. First, implement the User model, which contains information about the user's membership in different projects. Notice that the Realm object models derive from Object from the RealmSwift library, which allows Realm to store them in the Realm Database:

class User: Object {
@objc dynamic var _id: String = ""
@objc dynamic var _partition: String = ""
@objc dynamic var name: String = ""
let memberOf = RealmSwift.List<Project>()
override static func primaryKey() -> String? {
return "_id"
}
}
Info With Circle IconCreated with Sketch.Note

To learn more about how Realm Object models are used in iOS applications, see Realm Objects in our iOS client guide.

Next, implement the Project embedded object that represents a Project that a User is a member of:

class Project: EmbeddedObject {
@objc dynamic var name: String? = nil
@objc dynamic var partition: String? = nil
convenience init(partition: String, name: String) {
self.init()
self.partition = partition
self.name = name
}
}

Finally, let's turn the Task class into a Realm object model:

class Task: Object {
@objc dynamic var _id: ObjectId = ObjectId.generate()
@objc dynamic var _partition: String = ""
@objc dynamic var name: String = ""
@objc dynamic var owner: String? = nil
@objc dynamic var status: String = ""
override static func primaryKey() -> String? {
return "_id"
}
var statusEnum: TaskStatus {
get {
return TaskStatus(rawValue: status) ?? .Open
}
set {
status = newValue.rawValue
}
}
convenience init(partition: String, name: String) {
self.init()
self._partition = partition
self.name = name
}
}
5

Navigate to the WelcomeViewController.swift file, which is where we implement all login and user registration logic. This controller is set up with a text field for email and password, sign in and sign up buttons, and an activity indicator to show when the app is handling an authentication request.

First, let's implement the signUp() method to register a new user, which uses the email/password authentication provider of the Realm app to register a new user:

@objc func signUp() {
setLoading(true);
app.emailPasswordAuth.registerUser(email: email!, password: password!, completion: { [weak self](error) in
// Completion handlers are not necessarily called on the UI thread.
// This call to DispatchQueue.main.async ensures that any changes to the UI,
// namely disabling the loading indicator and navigating to the next page,
// are handled on the UI thread:
DispatchQueue.main.async {
self!.setLoading(false);
guard error == nil else {
print("Signup failed: \(error!)")
self!.errorLabel.text = "Signup failed: \(error!.localizedDescription)"
return
}
print("Signup successful!")
// Registering just registers. Now we need to sign in, but we can reuse the existing email and password.
self!.errorLabel.text = "Signup successful! Signing in..."
self!.signIn()
}
})
}

Now, implement the signIn() method to log in with an existing user using email/password credentials. Once logged in successfully, open the user realm and navigate to the ProjectsViewController:

@objc func signIn() {
print("Log in as user: \(email!)");
setLoading(true);
app.login(credentials: Credentials.emailPassword(email: email!, password: password!)) { [weak self](result) in
// Completion handlers are not necessarily called on the UI thread.
// This call to DispatchQueue.main.async ensures that any changes to the UI,
// namely disabling the loading indicator and navigating to the next page,
// are handled on the UI thread:
DispatchQueue.main.async {
self!.setLoading(false);
switch result {
case .failure(let error):
// Auth error: user already exists? Try logging in as that user.
print("Login failed: \(error)");
self!.errorLabel.text = "Login failed: \(error.localizedDescription)"
return
case .success(let user):
print("Login succeeded!");
// Load again while we open the realm.
self!.setLoading(true);
// Get a configuration to open the synced realm.
var configuration = user.configuration(partitionValue: "user=\(user.id)")
// Only allow User objects in this partition.
configuration.objectTypes = [User.self, Project.self]
// Open the realm asynchronously so that it downloads the remote copy before
// opening the local copy.
Realm.asyncOpen(configuration: configuration) { [weak self](result) in
DispatchQueue.main.async {
self!.setLoading(false);
switch result {
case .failure(let error):
fatalError("Failed to open realm: \(error)")
case .success(let userRealm):
// Go to the list of projects in the user object contained in the user realm.
self!.navigationController!.pushViewController(ProjectsViewController(userRealm: userRealm), animated: true);
}
}
}
}
}
};
}
6

Open the ProjectsViewController.swift file, which is where we present the user with a list of projects they are a member of. The WelcomeViewController has opened the user realm for us and passed it in to this controller. This is a good practice, as it allows the realm to fully download before transitioning to the next screen. If the realm failed to open for some reason, the error could be handled on the original screen and not have to undo a transition.

We have designed our app so that the user realm will only ever contain at most one object: that is, the user custom data object. Because an authentication trigger creates this user object, the realm may not contain the object immediately after creating an account. So, we should watch the realm itself for changes:

// There should only be one user in my realm - that is myself
let usersInRealm = userRealm.objects(User.self)
notificationToken = usersInRealm.observe { [weak self, usersInRealm] (changes) in
self?.userData = usersInRealm.first
guard let tableView = self?.tableView else { return }
tableView.reloadData()
}

Whenever we observe a realm, collection, or object, we receive a notification token that we must retain as long as we want to keep observing that object. Once we are done observing, for example because the user navigated to another screen, we should invalidate that token. One convenient place to do so is in the deinit method of the view controller:

deinit {
// Always invalidate any notification tokens when you are done with them.
notificationToken?.invalidate()
}

Let's provide a way for a user to log out and get back to the WelcomeViewController. The viewDidLoad() method hooks up the Log Out button at the top of the view to the logOutButtonDidClick() method. We can implement logOutButtonDidClick() as follows:

@objc func logOutButtonDidClick() {
let alertController = UIAlertController(title: "Log Out", message: "", preferredStyle: .alert);
alertController.addAction(UIAlertAction(title: "Yes, Log Out", style: .destructive, handler: {
alert -> Void in
print("Logging out...");
app.currentUser?.logOut() { (error) in
DispatchQueue.main.async {
print("Logged out!");
self.navigationController?.popViewController(animated: true)
}
}
}))
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
self.present(alertController, animated: true, completion: nil)
}

Since the ProjectsViewController implements the UITableViewDelegate protocol for its own list, let's implement these methods. First, implement the tableView(_:numberOfRowsInSection:) method to return the number of available projects to the current user. Since the user always has access to their own project, we always return at least 1 even though the user object may not be available yet:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// You always have at least one project (your own)
return userData?.memberOf.count ?? 1
}

Next, implement the tableView(_:cellForRowAt:) to fill out the project information for each cell. Again, if the user object has not loaded yet, we still know the user's own project is always in the list:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") ?? UITableViewCell(style: .default, reuseIdentifier: "Cell")
cell.selectionStyle = .none
// User data may not have loaded yet. You always have your own project.
let projectName = userData?.memberOf[indexPath.row].name ?? "My Project"
cell.textLabel?.text = projectName
return cell
}

Finally, implement the tableView(_:didSelectRowAt:) method to handle what happens when the user clicks a project in the list. As we opened the user realm in the WelcomeViewController before navigating to the ProjectsViewController, we'll open the project realm before navigating to the TasksViewController:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let user = app.currentUser!
let project = userData?.memberOf[indexPath.row] ?? Project(partition: "project=\(user.id)", name: "My Project")
Realm.asyncOpen(configuration: user.configuration(partitionValue: project.partition!)) { [weak self] (result) in
switch result {
case .failure(let error):
fatalError("Failed to open realm: \(error)")
case .success(let realm):
self?.navigationController?.pushViewController(
TasksViewController(realm: realm, title: "\(project.name!)'s Tasks"),
animated: true
);
}
}
}
7

Navigate to the TasksViewController.swift file, where we'll implement the list of Tasks in a Project. The TasksViewController class holds an array of Task objects. We already converted the Task class to a Realm object model. In order to hold a live collection of Realm objects contained in a realm, we need to use RealmResults instead of a standard Swift array. Let's convert that property now:

let tableView = UITableView()
let partitionValue: String
let realm: Realm
var notificationToken: NotificationToken?
let tasks: Results<Task>

We can initialize the tasks property with a query on the project realm passed in from the ProjectsViewController. Once we have the live tasks collection, we can observe that for changes:

required init(realm: Realm, title: String) {
// Ensure the realm was opened with sync.
guard let syncConfiguration = realm.configuration.syncConfiguration else {
fatalError("Sync configuration not found! Realm not opened with sync?");
}
self.realm = realm
// Partition value must be of string type.
partitionValue = syncConfiguration.partitionValue!.stringValue!
// Access all tasks in the realm, sorted by _id so that the ordering is defined.
tasks = realm.objects(Task.self).sorted(byKeyPath: "_id")
super.init(nibName: nil, bundle: nil)
self.title = title
// Observe the tasks for changes. Hang on to the returned notification token.
notificationToken = tasks.observe { [weak self] (changes) in
guard let tableView = self?.tableView else { return }
switch changes {
case .initial:
// Results are now populated and can be accessed without blocking the UI
tableView.reloadData()
case .update(_, let deletions, let insertions, let modifications):
// Query results have changed, so apply them to the UITableView.
tableView.performBatchUpdates({
// It's important to be sure to always update a table in this order:
// deletions, insertions, then updates. Otherwise, you could be unintentionally
// updating at the wrong index!
tableView.deleteRows(at: deletions.map({ IndexPath(row: $0, section: 0) }),
with: .automatic)
tableView.insertRows(at: insertions.map({ IndexPath(row: $0, section: 0) }),
with: .automatic)
tableView.reloadRows(at: modifications.map({ IndexPath(row: $0, section: 0) }),
with: .automatic)
})
case .error(let error):
// An error occurred while opening the Realm file on the background worker thread
fatalError("\(error)")
}
}
}

Calls to observe Realm objects return a notificationToken. Always retain the notificationToken as long as you want the observation to complete. When done observing -- for example, because the user navigated away from this view -- be sure to invalidate the token. The deinit method is a good place to do so:

deinit {
// Always invalidate any notification tokens when you are done with them.
notificationToken?.invalidate()
}

The TasksViewController already populates the UI using the Tasks in the list. Check out the tableView(_:numberOfRowsInSection:) and tableView(_:cellForRowAt:) methods to see how the Realm object model version of the Task class and the RealmResults object are drop-in replacements for the regular class and array respectively. No changes are required in these methods.

The TasksViewController also wires up the Add button at the top of the view to the addButtonDidClick() method. We can implement the Task creation in this method:

// Create a new Task with the text that the user entered.
let task = Task(partition: self.partitionValue, name: textField.text ?? "New Task")
// Any writes to the Realm must occur in a write block.
try! self.realm.write {
// Add the Task to the Realm. That's it!
self.realm.add(task)
}

When the user selects a Task in the list, we present them with an action sheet to allow them to update the Task's status. Complete the tableView(_:didSelectRowAt:) method implementation as follows:

// If the task is not in the Open state, we can set it to open. Otherwise, that action will not be available.
// We do this for the other two states -- InProgress and Complete.
if (task.statusEnum != .Open) {
actionSheet.addAction(UIAlertAction(title: "Open", style: .default) { _ in
// Any modifications to managed objects must occur in a write block.
// When we modify the Task's state, that change is automatically reflected in the realm.
try! self.realm.write {
task.statusEnum = .Open
}
})
}
if (task.statusEnum != .InProgress) {
actionSheet.addAction(UIAlertAction(title: "Start Progress", style: .default) { _ in
try! self.realm.write {
task.statusEnum = .InProgress
}
})
}
if (task.statusEnum != .Complete) {
actionSheet.addAction(UIAlertAction(title: "Complete", style: .default) { _ in
try! self.realm.write {
task.statusEnum = .Complete
}
})
}

To handle swipes to delete a Task, we implement the tableView(_:commit:forRowAt:) method:

func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else { return }
// User can swipe to delete items.
let task = tasks[indexPath.row]
// All modifications to a realm must happen in a write block.
try! realm.write {
// Delete the Task.
realm.delete(task)
}
}

Finally, a user can manage the team of their own project. When setting up the UI, the TasksViewController uses the isOwnTasks() method to decide whether to add the "Manage Team" action to the action bar. We can implement that as follows:

// Returns true if these are the user's own tasks.
func isOwnTasks() -> Bool {
return partitionValue == "project=\(app.currentUser!.id)"
}
8

A user can add and remove team members to their own Project using the Manage Team view. Since the client side cannot handle access management, we need to call out to our Realm functions we defined earlier.

Navigate to the ManageTeamViewController.swift file, which defines the view that pops up when a user clicks the "Manage Team" action on the TasksViewController. The ManageTeamViewController uses its fetchTeamMembers() to get the list of team members. Add the call to the getMyTeamMembers function that returns a list of team members and refresh the list upon successful return:

// Calls a Realm function to fetch the team members and adds them to the list
func fetchTeamMembers() {
// Start loading indicator
activityIndicator.startAnimating()
let user = app.currentUser!
user.functions.getMyTeamMembers([]) { [weak self](result, error) in
DispatchQueue.main.async {
guard self != nil else {
// This can happen if the view is dismissed
// before the operation completes
print("Team members list no longer needed.");
return
}
// Stop loading indicator
self!.activityIndicator.stopAnimating()
guard error == nil else {
print("Fetch team members failed: \(error!.localizedDescription)")
return
}
print("Fetch team members complete.")
// Convert documents to members array
self!.members = result!.arrayValue!.map({ (bson) in
return Member(document: bson!.documentValue!)
})
// Notify UI of changed data
self!.tableView.reloadData()
}
}
}

The ManageTeamViewController wires up the add button and swipe to delete functionality to the addTeamMember() and removeTeamMember() methods, respectively.

The addTeamMember() method calls the addTeamMember Realm function and can use the onTeamMemberOperationComplete() method as a completion handler:

func addTeamMember(email: String) {
print("Adding member: \(email)")
activityIndicator.startAnimating()
let user = app.currentUser!
user.functions.addTeamMember([AnyBSON(email)], self.onTeamMemberOperationComplete)
}

The removeTeamMember() method calls the removeTeamMember Realm function and also uses the onTeamMemberOperationComplete() method as a completion handler:

func removeTeamMember(email: String) {
print("Removing member: \(email)")
activityIndicator.startAnimating()
let user = app.currentUser!
user.functions.removeTeamMember([AnyBSON(email)], self.onTeamMemberOperationComplete)
}

The onTeamMemberOperationComplete() method presents any errors to the user and refreshes the member list.

9

Once you have completed the code, you can run the app and check functionality.

Click the Run button in Xcode. If the app builds successfully, here are some things you can try in the app:

  • Create a user with email first@example.com
  • Explore the app, then log out or launch a second instance of the app on another device or simulator
  • Create another user with email second@example.com
  • Navigate to second@example.com's project
  • Add, update, and remove some tasks
  • Click "Manage Team"
  • Add first@example.com to your team
  • Log out and log in as first@example.com
  • See two projects in the projects list
  • Navigate to second@example.com's project
  • Collaborate by adding, updating, and removing some new tasks
Bulb IconTip

If something isn't working for you, you can check out the final branch of this repo to compare your code with our finished solution.

Info With Circle IconCreated with Sketch.Note
Leave Feedback

How did it go? Please let us know if this tutorial was helpful or if you had any issues by using the feedback widget on the bottom right of the page.

Give Feedback