iOS Swift Tutorial¶
Overview¶
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.
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 Use Realm Database with SwiftUI and Combine.
Prerequisites¶
Before you begin, ensure you have:
- Xcode version 11.0 or higher, which requires macOS 10.14.4 or higher.
- Target of iOS 13.0.
- CocoaPods 1.10.0 or later.
- Set up the backend.
Set up the Mobile App¶
Clone the Client App Repository¶
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
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:
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"
Explore the App Structure¶
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:
File | Purpose |
---|---|
Models.swift | Defines the Realm object models used by this app. |
SceneDelegate.swift | Declares the global Realm app instance used by other parts of the app. |
WelcomeViewController.swift | Implements the login and user registration functionality. |
ProjectsViewController.swift | The list of projects that the logged-in user is a member of. |
TasksViewController.swift | The list of tasks for a given project. |
ManageTeamController.swift | The list of team members for a project and the associated team
management controls. |
Connect to Your MongoDB Realm App¶
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.
Define the Object Models¶
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 { dynamic var _id: String = "" dynamic var _partition: String = "" dynamic var name: String = "" let memberOf = RealmSwift.List<Project>() override static func primaryKey() -> String? { return "_id" } }
To learn more about how Realm Object models are used in iOS applications, see Read & Write Data - iOS SDK 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 { dynamic var name: String? = nil 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 { dynamic var _id: ObjectId = ObjectId.generate() dynamic var _partition: String = "" dynamic var name: String = "" dynamic var owner: String? = nil 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 } }
Enable Authentication¶
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:
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:
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); } } } } } }; }
Implement the Projects List¶
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:
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 ); } } }
Implement the Tasks List¶
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)" }
Implement the Manage Team View¶
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.
Run and Test¶
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
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.
What's Next?¶
- Read our iOS SDK documentation.
- Try the MongoDB Realm Backend tutorial.
- Find developer-oriented blog posts and integration tutorials on the MongoDB Developer Hub.
- Join the MongoDB Community forum to learn from other MongoDB developers and technical experts.
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 right side of the page.