In my last post, I walked through how to integrate Realm into a mobile chat app in Building a Mobile Chat App Using Realm – Integrating Realm into Your App. Since then, the Realm engineering team has been busy, and Realm-Cocoa 10.6 introduced new features that make the SDK way more "SwiftUI-native." For developers, that makes integrating Realm into SwiftUI views much simpler and more robust. This article steps through building the same chat app using these new features. Everything in Building a Mobile Chat App Using Realm – Integrating Realm into Your App still works, and it's the best starting point if you're building an app with UIKit rather than SwiftUI.
Both of these articles follow-up on Building a Mobile Chat App Using Realm – Data Architecture. Read that post first if you want to understand the Realm data/partitioning architecture and the decisions behind it.
This article targets developers looking to build the Realm mobile database into their SwiftUI mobile apps and use MongoDB Realm Sync.
If you've already read Building a Mobile Chat App Using Realm – Integrating Realm into Your App, then you'll find some parts unchanged here. As an example, there are no changes to the backend Realm application. I'll label those sections with "Unchanged" so that you know it's safe to skip over them.
RChat is a chat application. Members of a chat room share messages, photos, location, and presence information with each other. This version is an iOS (Swift and SwiftUI) app, but we will use the same data model and backend Realm application to build an Android version in the future.
If you're looking to add a chat feature to your mobile app, you can repurpose the article's code and the associated repo. If not, treat it as a case study that explains the reasoning behind the data model and partitioning/syncing decisions taken. You'll likely need to make similar design choices in your apps.
Watch this demo of the app in action.
#Prerequisites
If you want to build and run the app for yourself, this is what you'll need:
- iOS14.2+
- XCode 12.3+
- Realm-Cocoa 10.6+ (recommended to use the Swift Package Manager (SPM) rather than Cocoa Pods)
- MongoDB Atlas account and a (free) Atlas cluster
#Walkthrough
The iOS app uses MongoDB Realm Sync to share data between instances of the app (e.g., the messages sent between users). This walkthrough covers both the iOS code and the backend Realm app needed to make it work. Remember that all of the code for the final app is available in the GitHub repo.
#Create a Backend Realm App (Unchanged)
From the Atlas UI, select the "Realm" tab. Select the options to indicate that you're creating a new iOS mobile app and then click "Start a New Realm App":

Name the app "RChat" and click "Create Realm Application":

Copy the "App ID." You'll need to use this in your iOS app code:

#Connect iOS App to Your Realm App (Unchanged)
The SwiftUI entry point for the app is RChatApp.swift. This is where you define your link to your Realm application (named app
) using the App ID from your new backend Realm app:
1 import SwiftUI 2 import RealmSwift 3 let app = RealmSwift.App(id: "rchat-xxxxx") // TODO: Set the Realm application ID 4 @main 5 struct RChatApp: SwiftUI.App { 6 var state = AppState() 7 8 var body: some Scene { 9 WindowGroup { 10 ContentView() 11 .environmentObject(state) 12 } 13 } 14 }
Note that we created an instance of AppState and pass it into our top-level view (ContentView) as an environmentObject
. This is a common SwiftUI pattern for making state information available to every view without the need to explicitly pass it down every level of the view hierarchy:
1 import SwiftUI 2 import RealmSwift 3 let app = RealmSwift.App(id: "rchat-xxxxx") // TODO: Set the Realm application ID 4 @main 5 struct RChatApp: SwiftUI.App { 6 var state = AppState() 7 var body: some Scene { 8 WindowGroup { 9 ContentView() 10 .environmentObject(state) 11 } 12 } 13 }
#Realm Model Objects
These are largely as described in Building a Mobile Chat App Using Realm – Data Architecture. I'll highlight some of the key changes using the User Object class as an example:
1 import Foundation 2 import RealmSwift 3 4 @objcMembers class User: Object, ObjectKeyIdentifiable { 5 dynamic var _id = UUID().uuidString 6 dynamic var partition = "" // "user=_id" 7 dynamic var userName = "" 8 dynamic var userPreferences: UserPreferences? 9 dynamic var lastSeenAt: Date? 10 var conversations = List<Conversation>() 11 dynamic var presence = "Off-Line" 12 13 override static func primaryKey() -> String? { 14 return "_id" 15 } 16 }
User
now conforms to Realm-Cocoa's ObjectKeyIdentifiable
protocol, automatically adding identifiers to each instance that are used by SwiftUI (e.g., when iterating over results in a ForEach
loop). It's like Identifiable
but integrated into Realm to handle events such as Realm Sync adding a new object to a result set or list.
conversations
is now a var
rather than a let
, allowing us to append new items to the list.
#Application-Wide State: AppState
The AppState
class is so much simpler now. Wherever possible, the opening of a Realm is now handled when opening the view that needs it.
Views can pass state up and down the hierarchy. However, it can simplify state management by making some state available application-wide. In this app, we centralize this app-wide state data storage and control in an instance of the AppState class.
A lot is going on in AppState.swift
, and you can view the full file in the repo.
As part of adopting the latest Realm-Cocoa SDK feature, I no longer need to store open Realms in AppState
(as Realms are now opened as part of loading the view that needs them). AppState
contains the user
attribute to represent the user currently logged into the app (and Realm). If user
is set to nil
, then no user is logged in:
1 class AppState: ObservableObject { 2 ... 3 var user: User? 4 ... 5 }
The app uses the Realm SDK to interact with the back end Realm application to perform actions such as logging into Realm. Those operations can take some time as they involve accessing resources over the internet, and so we don't want the app to sit busy-waiting for a response. Instead, we use Combine publishers and subscribers to handle these events. loginPublisher
, logoutPublisher
, and userRealmPublisher
are publishers to handle logging in, logging out, and opening Realms for a user:
1 class AppState: ObservableObject { 2 ... 3 let loginPublisher = PassthroughSubject<RealmSwift.User, Error>() 4 let logoutPublisher = PassthroughSubject<Void, Error>() 5 let userRealmPublisher = PassthroughSubject<Realm, Error>() 6 ... 7 }
When an AppState
class is instantiated, the actions are assigned to each of the Combine publishers:
1 init() { 2 _ = app.currentUser?.logOut() 3 initLoginPublisher() 4 initUserRealmPublisher() 5 initLogoutPublisher() 6 }
We'll later see that an event is sent to loginPublisher
when a user has successfully logged into Realm. In AppState
, we define what should be done when those events are received. Events received on loginPublisher
trigger the opening of a realm with the partition set to user=<id of the user>
, which in turn sends an event to userRealmPublisher
:
1 func initLoginPublisher() { 2 loginPublisher 3 .receive(on: DispatchQueue.main) 4 .flatMap { user -> RealmPublishers.AsyncOpenPublisher in 5 self.shouldIndicateActivity = true 6 let realmConfig = user.configuration(partitionValue: "user=\(user.id)") 7 return Realm.asyncOpen(configuration: realmConfig) 8 } 9 .receive(on: DispatchQueue.main) 10 .map { 11 return $0 12 } 13 .subscribe(userRealmPublisher) 14 .store(in: &self.cancellables) 15 }
When the Realm has been opened and the Realm sent to userRealmPublisher
, user
is initialized with the User
object retrieved from the Realm. The user's presence is set to onLine
:
1 func initUserRealmPublisher() { 2 userRealmPublisher 3 .sink(receiveCompletion: { result in 4 if case let .failure(error) = result { 5 self.error = "Failed to log in and open user realm: \(error.localizedDescription)" 6 } 7 }, receiveValue: { realm in 8 print("User Realm User file location: \(realm.configuration.fileURL!.path)") 9 self.userRealm = realm 10 self.user = realm.objects(User.self).first 11 do { 12 try realm.write { 13 self.user?.presenceState = .onLine 14 } 15 } catch { 16 self.error = "Unable to open Realm write transaction" 17 } 18 self.shouldIndicateActivity = false 19 }) 20 .store(in: &cancellables) 21 }
After logging out of Realm, we simply set user
to nil:
1 func initLogoutPublisher() { 2 logoutPublisher 3 .receive(on: DispatchQueue.main) 4 .sink(receiveCompletion: { _ in 5 }, receiveValue: { _ in 6 self.user = nil 7 }) 8 .store(in: &cancellables) 9 }
#Enabling Email/Password Authentication in the Realm App (Unchanged)
After seeing what happens after a user has logged into Realm, we need to circle back and enable email/password authentication in the backend Realm app. Fortunately, it's straightforward to do.
From the Realm UI, select "Authentication" from the lefthand menu, followed by "Authentication Providers." Click the "Edit" button for "Email/Password":

Enable the provider and select "Automatically confirm users" and "Run a password reset function." Select "New function" and save without making any edits:

Don't forget to click on "REVIEW & DEPLOY" whenever you've made a change to the backend Realm app.
#Create User
Document on User Registration (Unchanged)
When a new user registers, we need to create a User
document in Atlas that will eventually synchronize with a User
object in the iOS app. Realm provides authentication triggers that can automate this.
Select "Triggers" and then click on "Add a Trigger":

Set the "Trigger Type" to "Authentication," provide a name, set the "Action Type" to "Create" (user registration), set the "Event Type" to "Function," and then select "New Function":

Name the function createNewUserDocument
and add the code for the function:
1 exports = function({user}) { 2 const db = context.services.get("mongodb-atlas").db("RChat"); 3 const userCollection = db.collection("User"); 4 const partition = `user=${user.id}`; 5 const defaultLocation = context.values.get("defaultLocation"); 6 const userPreferences = { 7 displayName: "" 8 }; 9 const userDoc = { 10 _id: user.id, 11 partition: partition, 12 userName: user.data.email, 13 userPreferences: userPreferences, 14 location: context.values.get("defaultLocation"), 15 lastSeenAt: null, 16 presence:"Off-Line", 17 conversations: [] 18 }; 19 return userCollection.insertOne(userDoc) 20 .then(result => { 21 console.log(`Added User document with _id: ${result.insertedId}`); 22 }, error => { 23 console.log(`Failed to insert User document: ${error}`); 24 }); 25 };
Note that we set the partition
to user=<id of the user>
, which matches the partition used when the iOS app opens the User Realm.
"Save" then "REVIEW & DEPLOY."
#Define Realm Schema (Unchanged)
Refer to Building a Mobile Chat App Using Realm – Data Architecture to better understand the app's schema and partitioning rules. This article skips the analysis phase and just configures the Realm schema.
Browse to the "Rules" section in the Realm UI and click on "Add Collection." Set "Database Name" to RChat
and "Collection Name" to User
. We won't be accessing the User
collection directly through Realm, so don't select a "Permissions Template." Click "Add Collection":

At this point, I'll stop reminding you to click "REVIEW & DEPLOY!"
Select "Schema," paste in this schema, and then click "SAVE":
1 { 2 "bsonType": "object", 3 "properties": { 4 "_id": { 5 "bsonType": "string" 6 }, 7 "conversations": { 8 "bsonType": "array", 9 "items": { 10 "bsonType": "object", 11 "properties": { 12 "displayName": { 13 "bsonType": "string" 14 }, 15 "id": { 16 "bsonType": "string" 17 }, 18 "members": { 19 "bsonType": "array", 20 "items": { 21 "bsonType": "object", 22 "properties": { 23 "membershipStatus": { 24 "bsonType": "string" 25 }, 26 "userName": { 27 "bsonType": "string" 28 } 29 }, 30 "required": [ 31 "membershipStatus", 32 "userName" 33 ], 34 "title": "Member" 35 } 36 }, 37 "unreadCount": { 38 "bsonType": "long" 39 } 40 }, 41 "required": [ 42 "unreadCount", 43 "id", 44 "displayName" 45 ], 46 "title": "Conversation" 47 } 48 }, 49 "lastSeenAt": { 50 "bsonType": "date" 51 }, 52 "partition": { 53 "bsonType": "string" 54 }, 55 "presence": { 56 "bsonType": "string" 57 }, 58 "userName": { 59 "bsonType": "string" 60 }, 61 "userPreferences": { 62 "bsonType": "object", 63 "properties": { 64 "avatarImage": { 65 "bsonType": "object", 66 "properties": { 67 "_id": { 68 "bsonType": "string" 69 }, 70 "date": { 71 "bsonType": "date" 72 }, 73 "picture": { 74 "bsonType": "binData" 75 }, 76 "thumbNail": { 77 "bsonType": "binData" 78 } 79 }, 80 "required": [ 81 "_id", 82 "date" 83 ], 84 "title": "Photo" 85 }, 86 "displayName": { 87 "bsonType": "string" 88 } 89 }, 90 "required": [], 91 "title": "UserPreferences" 92 } 93 }, 94 "required": [ 95 "_id", 96 "partition", 97 "userName", 98 "presence" 99 ], 100 "title": "User" 101 }

Repeat for the Chatster
schema:
1 { 2 "bsonType": "object", 3 "properties": { 4 "_id": { 5 "bsonType": "string" 6 }, 7 "avatarImage": { 8 "bsonType": "object", 9 "properties": { 10 "_id": { 11 "bsonType": "string" 12 }, 13 "date": { 14 "bsonType": "date" 15 }, 16 "picture": { 17 "bsonType": "binData" 18 }, 19 "thumbNail": { 20 "bsonType": "binData" 21 } 22 }, 23 "required": [ 24 "_id", 25 "date" 26 ], 27 "title": "Photo" 28 }, 29 "displayName": { 30 "bsonType": "string" 31 }, 32 "lastSeenAt": { 33 "bsonType": "date" 34 }, 35 "partition": { 36 "bsonType": "string" 37 }, 38 "presence": { 39 "bsonType": "string" 40 }, 41 "userName": { 42 "bsonType": "string" 43 } 44 }, 45 "required": [ 46 "_id", 47 "partition", 48 "presence", 49 "userName" 50 ], 51 "title": "Chatster" 52 }
And for the ChatMessage
collection:
1 { 2 "bsonType": "object", 3 "properties": { 4 "_id": { 5 "bsonType": "string" 6 }, 7 "author": { 8 "bsonType": "string" 9 }, 10 "image": { 11 "bsonType": "object", 12 "properties": { 13 "_id": { 14 "bsonType": "string" 15 }, 16 "date": { 17 "bsonType": "date" 18 }, 19 "picture": { 20 "bsonType": "binData" 21 }, 22 "thumbNail": { 23 "bsonType": "binData" 24 } 25 }, 26 "required": [ 27 "_id", 28 "date" 29 ], 30 "title": "Photo" 31 }, 32 "location": { 33 "bsonType": "array", 34 "items": { 35 "bsonType": "double" 36 } 37 }, 38 "partition": { 39 "bsonType": "string" 40 }, 41 "text": { 42 "bsonType": "string" 43 }, 44 "timestamp": { 45 "bsonType": "date" 46 } 47 }, 48 "required": [ 49 "_id", 50 "partition", 51 "text", 52 "timestamp" 53 ], 54 "title": "ChatMessage" 55 }
#Enable Realm Sync (Unchanged)
We use Realm Sync to synchronize objects between instances of the iOS app (and we'll extend this app also to include Android). It also syncs those objects with Atlas collections. Note that there are three options to create a Realm schema:
- Manually code the schema as a JSON schema document.
- Derive the schema from existing data stored in Atlas. (We don't yet have any data and so this isn't an option here.)
- Derive the schema from the Realm objects used in the mobile app.
We've already specified the schema and so will stick to the first option.
Select "Sync" and then select your Atlas cluster. Set the "Partition Key" to the partition
attribute (it appears in the list as it's already in the schema for all three collections), and the rules for whether a user can sync with a given partition:

The "Read" rule controls whether a user can establish a one-way read-only sync relationship to the mobile app for a given user and partition. In this case, the rule delegates this to a Realm function named canReadPartition
:
1 { 2 "%%true": { 3 "%function": { 4 "arguments": [ 5 "%%partition" 6 ], 7 "name": "canReadPartition" 8 } 9 } 10 }
The "Write" rule delegates to the canWritePartition
:
1 { 2 "%%true": { 3 "%function": { 4 "arguments": [ 5 "%%partition" 6 ], 7 "name": "canWritePartition" 8 } 9 } 10 }
Once more, we've already seen those functions in Building a Mobile Chat App Using Realm – Data Architecture but I'll include the code here for completeness.
1 exports = function(partition) { 2 console.log(`Checking if can sync a read for partition = ${partition}`); 3 const db = context.services.get("mongodb-atlas").db("RChat"); 4 const chatsterCollection = db.collection("Chatster"); 5 const userCollection = db.collection("User"); 6 const chatCollection = db.collection("ChatMessage"); 7 const user = context.user; 8 let partitionKey = ""; 9 let partitionVale = ""; 10 const splitPartition = partition.split("="); 11 if (splitPartition.length == 2) { 12 partitionKey = splitPartition[0]; 13 partitionValue = splitPartition[1]; 14 console.log(`Partition key = ${partitionKey}; partition value = ${partitionValue}`); 15 } else { 16 console.log(`Couldn't extract the partition key/value from ${partition}`); 17 return false; 18 } 19 switch (partitionKey) { 20 case "user": 21 console.log(`Checking if partitionValue(${partitionValue}) matches user.id(${user.id}) – ${partitionKey === user.id}`); 22 return partitionValue === user.id; 23 case "conversation": 24 console.log(`Looking up User document for _id = ${user.id}`); 25 return userCollection.findOne({ _id: user.id }) 26 .then (userDoc => { 27 if (userDoc.conversations) { 28 let foundMatch = false; 29 userDoc.conversations.forEach( conversation => { 30 console.log(`Checking if conversaion.id (${conversation.id}) === ${partitionValue}`) 31 if (conversation.id === partitionValue) { 32 console.log(`Found matching conversation element for id = ${partitionValue}`); 33 foundMatch = true; 34 } 35 }); 36 if (foundMatch) { 37 console.log(`Found Match`); 38 return true; 39 } else { 40 console.log(`Checked all of the user's conversations but found none with id == ${partitionValue}`); 41 return false; 42 } 43 } else { 44 console.log(`No conversations attribute in User doc`); 45 return false; 46 } 47 }, error => { 48 console.log(`Unable to read User document: ${error}`); 49 return false; 50 }); 51 case "all-users": 52 console.log(`Any user can read all-users partitions`); 53 return true; 54 default: 55 console.log(`Unexpected partition key: ${partitionKey}`); 56 return false; 57 } 58 };
1 exports = function(partition) { 2 console.log(`Checking if can sync a write for partition = ${partition}`); 3 const db = context.services.get("mongodb-atlas").db("RChat"); 4 const chatsterCollection = db.collection("Chatster"); 5 const userCollection = db.collection("User"); 6 const chatCollection = db.collection("ChatMessage"); 7 const user = context.user; 8 let partitionKey = ""; 9 let partitionVale = ""; 10 const splitPartition = partition.split("="); 11 if (splitPartition.length == 2) { 12 partitionKey = splitPartition[0]; 13 partitionValue = splitPartition[1]; 14 console.log(`Partition key = ${partitionKey}; partition value = ${partitionValue}`); 15 } else { 16 console.log(`Couldn't extract the partition key/value from ${partition}`); 17 return false; 18 } 19 switch (partitionKey) { 20 case "user": 21 console.log(`Checking if partitionKey(${partitionValue}) matches user.id(${user.id}) – ${partitionKey === user.id}`); 22 return partitionValue === user.id; 23 case "conversation": 24 console.log(`Looking up User document for _id = ${user.id}`); 25 return userCollection.findOne({ _id: user.id }) 26 .then (userDoc => { 27 if (userDoc.conversations) { 28 let foundMatch = false; 29 userDoc.conversations.forEach( conversation => { 30 console.log(`Checking if conversaion.id (${conversation.id}) === ${partitionValue}`) 31 if (conversation.id === partitionValue) { 32 console.log(`Found matching conversation element for id = ${partitionValue}`); 33 foundMatch = true; 34 } 35 }); 36 if (foundMatch) { 37 console.log(`Found Match`); 38 return true; 39 } else { 40 console.log(`Checked all of the user's conversations but found none with id == ${partitionValue}`); 41 return false; 42 } 43 } else { 44 console.log(`No conversations attribute in User doc`); 45 return false; 46 } 47 }, error => { 48 console.log(`Unable to read User document: ${error}`); 49 return false; 50 }); 51 case "all-users": 52 console.log(`No user can write to an all-users partitions`); 53 return false; 54 default: 55 console.log(`Unexpected partition key: ${partitionKey}`); 56 return false; 57 } 58 };
To create these functions, select "Functions" and click "Create New Function." Make sure you type the function name precisely, set "Authentication" to "System," and turn on the "Private" switch (which means it can't be called directly from external services such as our mobile app):

#Linking User and Chatster Documents (Unchanged)
As described in Building a Mobile Chat App Using Realm – Data Architecture, there are relationships between different User
and Chatster
documents. Now that we've defined the schemas and enabled Realm Sync, it's convenient to add the Realm function and database trigger to maintain those relationships.
Create a Realm function named userDocWrittenTo
, set "Authentication" to "System," and make it private. This article is aiming to focus on the iOS app more than the backend Realm app, and so we won't delve into this code:
1 exports = function(changeEvent) { 2 const db = context.services.get("mongodb-atlas").db("RChat"); 3 const chatster = db.collection("Chatster"); 4 const userCollection = db.collection("User"); 5 const docId = changeEvent.documentKey._id; 6 const user = changeEvent.fullDocument; 7 let conversationsChanged = false; 8 console.log(`Mirroring user for docId=${docId}. operationType = ${changeEvent.operationType}`); 9 switch (changeEvent.operationType) { 10 case "insert": 11 case "replace": 12 case "update": 13 console.log(`Writing data for ${user.userName}`); 14 let chatsterDoc = { 15 _id: user._id, 16 partition: "all-users=all-the-users", 17 userName: user.userName, 18 lastSeenAt: user.lastSeenAt, 19 presence: user.presence 20 }; 21 if (user.userPreferences) { 22 const prefs = user.userPreferences; 23 chatsterDoc.displayName = prefs.displayName; 24 if (prefs.avatarImage && prefs.avatarImage._id) { 25 console.log(`Copying avatarImage`); 26 chatsterDoc.avatarImage = prefs.avatarImage; 27 console.log(`id of avatarImage = ${prefs.avatarImage._id}`); 28 } 29 } 30 chatster.replaceOne({ _id: user._id }, chatsterDoc, { upsert: true }) 31 .then (() => { 32 console.log(`Wrote Chatster document for _id: ${docId}`); 33 }, error => { 34 console.log(`Failed to write Chatster document for _id=${docId}: ${error}`); 35 }); 36 37 if (user.conversations && user.conversations.length > 0) { 38 for (i = 0; i < user.conversations.length; i++) { 39 let membersToAdd = []; 40 if (user.conversations[i].members.length > 0) { 41 for (j = 0; j < user.conversations[i].members.length; j++) { 42 if (user.conversations[i].members[j].membershipStatus == "User added, but invite pending") { 43 membersToAdd.push(user.conversations[i].members[j].userName); 44 user.conversations[i].members[j].membershipStatus = "Membership active"; 45 conversationsChanged = true; 46 } 47 } 48 } 49 if (membersToAdd.length > 0) { 50 userCollection.updateMany({userName: {$in: membersToAdd}}, {$push: {conversations: user.conversations[i]}}) 51 .then (result => { 52 console.log(`Updated ${result.modifiedCount} other User documents`); 53 }, error => { 54 console.log(`Failed to copy new conversation to other users: ${error}`); 55 }); 56 } 57 } 58 } 59 if (conversationsChanged) { 60 userCollection.updateOne({_id: user._id}, {$set: {conversations: user.conversations}}); 61 } 62 break; 63 case "delete": 64 chatster.deleteOne({_id: docId}) 65 .then (() => { 66 console.log(`Deleted Chatster document for _id: ${docId}`); 67 }, error => { 68 console.log(`Failed to delete Chatster document for _id=${docId}: ${error}`); 69 }); 70 break; 71 } 72 };
Set up a database trigger to execute the new function whenever anything in the User
collection changes:

#Registering and Logging in from the iOS App
This section is virtually unchanged. As part of using the new Realm SDK features, there is now less in AppState
(including fewer publishers), and so less attributes need to be set up as part of the login process.
We've now created enough of the backend Realm app that mobile apps can now register new Realm users and use them to log into the app.
The app's top-level SwiftUI view is ContentView, which decides which sub-view to show based on whether our AppState
environment object indicates that a user is logged in or not:
1 var state: AppState 2 ... 3 if state.loggedIn { 4 if (state.user != nil) && !state.user!.isProfileSet || showingProfileView { 5 SetProfileView(isPresented: $showingProfileView) 6 .environment(\.realmConfiguration, app.currentUser!.configuration(partitionValue: "user=\(state.user?._id ?? "")")) 7 } else { 8 ConversationListView() 9 .environment(\.realmConfiguration, app.currentUser!.configuration(partitionValue: "user=\(state.user?._id ?? "")")) 10 .navigationBarTitle("Chats", displayMode: .inline) 11 .navigationBarItems( 12 trailing: state.loggedIn && !state.shouldIndicateActivity ? UserAvatarView( 13 photo: state.user?.userPreferences?.avatarImage, 14 online: true) { showingProfileView.toggle() } : nil 15 ) 16 } 17 } else { 18 LoginView() 19 } 20 ...
When first run, no user is logged in, and so LoginView
is displayed.
Note that AppState.loggedIn
checks whether a user is currently logged into the Realm app
:
1 var loggedIn: Bool { 2 app.currentUser != nil && user != nil && app.currentUser?.state == .loggedIn 3 }
The UI for LoginView contains cells to provide the user's email address and password, a radio button to indicate whether this is a new user, and a button to register or log in a user:

Clicking the button executes one of two functions:
1 ... 2 CallToActionButton( 3 title: newUser ? "Register User" : "Log In", 4 action: { self.userAction(username: self.username, password: self.password) }) 5 ... 6 private func userAction(username: String, password: String) { 7 state.shouldIndicateActivity = true 8 if newUser { 9 signup(username: username, password: password) 10 } else { 11 login(username: username, password: password) 12 } 13 }
signup
makes an asynchronous call to the Realm SDK to register the new user. Through a Combine pipeline, signup
receives an event when the registration completes, which triggers it to invoke the login
function:
1 private func signup(username: String, password: String) { 2 if username.isEmpty || password.isEmpty { 3 state.shouldIndicateActivity = false 4 return 5 } 6 self.state.error = nil 7 app.emailPasswordAuth.registerUser(email: username, password: password) 8 .receive(on: DispatchQueue.main) 9 .sink(receiveCompletion: { 10 state.shouldIndicateActivity = false 11 switch $0 { 12 case .finished: 13 break 14 case .failure(let error): 15 self.state.error = error.localizedDescription 16 } 17 }, receiveValue: { 18 self.state.error = nil 19 login(username: username, password: password) 20 }) 21 .store(in: &state.cancellables) 22 }
The login
function uses the Realm SDK to log in the user asynchronously. If/when the Realm login succeeds, the Combine pipeline sends the Realm user to the chatsterLoginPublisher
and loginPublisher
publishers (recall that we've seen how those are handled within the AppState
class):
1 private func login(username: String, password: String) { 2 if username.isEmpty || password.isEmpty { 3 state.shouldIndicateActivity = false 4 return 5 } 6 self.state.error = nil 7 app.login(credentials: .emailPassword(email: username, password: password)) 8 .receive(on: DispatchQueue.main) 9 .sink(receiveCompletion: { 10 state.shouldIndicateActivity = false 11 switch $0 { 12 case .finished: 13 break 14 case .failure(let error): 15 self.state.error = error.localizedDescription 16 } 17 }, receiveValue: { 18 self.state.error = nil 19 state.loginPublisher.send($0) 20 }) 21 .store(in: &state.cancellables) 22 }
#Saving the User Profile
On being logged in for the first time, the user is presented with SetProfileView. (They can also return here later by clicking on their avatar.) This is a SwiftUI sheet where the user can set their profile and preferences by interacting with the UI and then clicking "Save User Profile":

When the view loads, the UI is populated with any existing profile information found in the User
object in the AppState
environment object:
1 ... 2 var state: AppState 3 ... 4 .onAppear { initData() } 5 ... 6 private func initData() { 7 displayName = state.user?.userPreferences?.displayName ?? "" 8 photo = state.user?.userPreferences?.avatarImage 9 }
As the user updates the UI elements, the Realm User
object isn't changed. It's not until they click "Save User Profile" that we update the User
object. state.user
is an object that's being managed by Realm, and so it must be updated within a Realm transaction. Using one of the new Realm SDK features, the Realm for this user's partition is made available in SetProfileView
by injecting it into the environment from ContentView
:
1 SetProfileView(isPresented: $showingProfileView) 2 .environment(\.realmConfiguration, 3 app.currentUser!.configuration(partitionValue: "user=\(state.user?._id ?? "")"))
SetProfileView
receives userRealm
through the environment and uses it to create a transaction (line 10):
1 ... 2 var state: AppState 3 var userRealm (\.realm) 4 ... 5 CallToActionButton(title: "Save User Profile", action: saveProfile) 6 ... 7 private func saveProfile() { 8 state.shouldIndicateActivity = true 9 do { 10 try userRealm.write { 11 state.user?.userPreferences?.displayName = displayName 12 if photoAdded { 13 guard let newPhoto = photo else { 14 print("Missing photo") 15 state.shouldIndicateActivity = false 16 return 17 } 18 state.user?.userPreferences?.avatarImage = newPhoto 19 } 20 state.user?.presenceState = .onLine 21 } 22 } catch { 23 state.error = "Unable to open Realm write transaction" 24 } 25 }
Once saved to the local Realm, Realm Sync copies changes made to the User
object to the associated User
document in Atlas.
#List of Conversations
Once the user has logged in and set up their profile information, they're presented with the ConversationListView
. Again, we use the new SDK feature to implicitly open the Realm for this user partition and pass it through the environment from ContentView
:
1 if state.loggedIn { 2 if (state.user != nil) && !state.user!.isProfileSet || showingProfileView { 3 SetProfileView(isPresented: $showingProfileView) 4 .environment(\.realmConfiguration, 5 app.currentUser!.configuration(partitionValue: "user=\(state.user?._id ?? "")")) 6 } else { 7 ConversationListView() 8 .environment(\.realmConfiguration, 9 app.currentUser!.configuration(partitionValue: "user=\(state.user?._id ?? "")")) 10 .navigationBarTitle("Chats", displayMode: .inline) 11 .navigationBarItems( 12 trailing: state.loggedIn && !state.shouldIndicateActivity ? UserAvatarView( 13 photo: state.user?.userPreferences?.avatarImage, 14 online: true) { showingProfileView.toggle() } : nil 15 ) 16 } 17 } else { 18 LoginView() 19 }
ConversationListView receives the Realm through the environment and then uses another new Realm SDK feature (@ObservedResults
) to set users
to be a live result set of all User
objects in the partition (as each user has their own partition, there will be exactly one User
document in users
):
1 User.self) var users (
ConversationListView displays a list of all the conversations that the user is currently a member of (initially none) by looping over conversations
within their User
Realm object:
1 User.self) var users (2 ... 3 private let sortDescriptors = [ 4 SortDescriptor(keyPath: "unreadCount", ascending: false), 5 SortDescriptor(keyPath: "displayName", ascending: true) 6 ] 7 ... 8 if let conversations = users[0].conversations.sorted(by: sortDescriptors) { 9 List { 10 ForEach(conversations) { conversation in 11 Button(action: { 12 self.conversation = conversation 13 showConversation.toggle() 14 }) { ConversationCardView(conversation: conversation, isPreview: isPreview) } 15 } 16 } 17 ... 18 }
At any time, another user can include you in a new group conversation. This view needs to reflect those changes as they happen:

When the other user adds us to a conversation, our User
document is updated automatically through the magic of Realm Sync and our Realm trigger. Prior to Realm-Cocoa 10.6, we needed to observe the Realm and trick SwiftUI into refreshing the view when changes were received. The Realm/SwiftUI integration now refreshes the view automatically.
#Creating New Conversations
When you click in the new conversation button in ConversationListView
, a SwiftUI sheet is activated to host NewConversationView
. This time, we implicitly open and pass in the Chatster
Realm (for the universal partition all-users=all-the-users
:
1 .sheet(isPresented: $showingAddChat) { 2 NewConversationView() 3 .environmentObject(state) 4 .environment(\.realmConfiguration, app.currentUser!.configuration(partitionValue: "all-users=all-the-users"))
NewConversationView creates a live Realm result set (chatsters
) from the Realm passed through the environment:
1 Chatster.self) var chatsters (
NewConversationView
is similar to SetProfileView.
in that it lets the user provide a number of details which are then saved to Realm when the "Save" button is tapped.
In order to use the "Realm injection" approach, we now need to delegate the saving of the User
object to another view (NewConversationView
received the Chatster
Realm but the updated User
object needs be saved in a transaction for the User
Reealm):
1 code content 2 SaveConversationButton(name: name, members: members, done: { presentationMode.wrappedValue.dismiss() }) 3 .environment(\.realmConfiguration, 4 app.currentUser!.configuration(partitionValue: "user=\(state.user?._id ?? "")"))
Something that we haven't covered yet is applying a filter to the live Realm search results. Here we filter on the userName
within the Chatster objects:
1 Chatster.self) var chatsters (2 ... 3 private func searchUsers() { 4 var candidateChatsters: Results<Chatster> 5 if candidateMember == "" { 6 candidateChatsters = chatsters 7 } else { 8 let predicate = NSPredicate(format: "userName CONTAINS[cd] %@", candidateMember) 9 candidateChatsters = chatsters.filter(predicate) 10 } 11 candidateMembers = [] 12 candidateChatsters.forEach { chatster in 13 if !members.contains(chatster.userName) && chatster.userName != state.user?.userName { 14 candidateMembers.append(chatster.userName) 15 } 16 } 17 }
#Conversation Status (Unchanged)

When the status of a conversation changes (users go online/offline or new messages are received), the card displaying the conversation details should update.
We already have a Realm function to set the presence
status in Chatster
documents/objects when users log on or off. All Chatster
objects are readable by all users, and so ConversationCardContentsView can already take advantage of that information.
The conversation.unreadCount
is part of the User
object, and so we need another Realm trigger to update that whenever a new chat message is posted to a conversation.
We add a new Realm function chatMessageChange
that's configured as private and with "System" authentication (just like our other functions). This is the function code that will increment the unreadCount
for all User
documents for members of the conversation:
1 exports = function(changeEvent) { 2 if (changeEvent.operationType != "insert") { 3 console.log(`ChatMessage ${changeEvent.operationType} event – currently ignored.`); 4 return; 5 } 6 7 console.log(`ChatMessage Insert event being processed`); 8 let userCollection = context.services.get("mongodb-atlas").db("RChat").collection("User"); 9 let chatMessage = changeEvent.fullDocument; 10 let conversation = ""; 11 12 if (chatMessage.partition) { 13 const splitPartition = chatMessage.partition.split("="); 14 if (splitPartition.length == 2) { 15 conversation = splitPartition[1]; 16 console.log(`Partition/conversation = ${conversation}`); 17 } else { 18 console.log("Couldn't extract the conversation from partition ${chatMessage.partition}"); 19 return; 20 } 21 } else { 22 console.log("partition not set"); 23 return; 24 } 25 26 const matchingUserQuery = { 27 conversations: { 28 $elemMatch: { 29 id: conversation 30 } 31 } 32 }; 33 34 const updateOperator = { 35 $inc: { 36 "conversations.$[element].unreadCount": 1 37 } 38 }; 39 40 const arrayFilter = { 41 arrayFilters:[ 42 { 43 "element.id": conversation 44 } 45 ] 46 }; 47 48 userCollection.updateMany(matchingUserQuery, updateOperator, arrayFilter) 49 .then ( result => { 50 console.log(`Matched ${result.matchedCount} User docs; updated ${result.modifiedCount}`); 51 }, error => { 52 console.log(`Failed to match and update User docs: ${error}`); 53 }); 54 };
That function should be invoked by a new Realm database trigger (ChatMessageChange
) to fire whenever a document is inserted into the RChat.ChatMessage
collection.
#Within the Chat Room

ChatRoomView has a lot of similarities with ConversationListView
, but with one fundamental difference. Each conversation/chat room has its own partition, and so when opening a conversation, you need to open a new Realm. Again, we use the new SDK feature to open and pass in the Realm for the appropriate conversation partition:
1 ChatRoomBubblesView(conversation: conversation) 2 .environment(\.realmConfiguration, app.currentUser!.configuration(partitionValue: "conversation=\(conversation.id)"))
If you worked through Building a Mobile Chat App Using Realm – Integrating Realm into Your App, then you may have noticed that I had to introduce an extra view layer—ChatRoomBubblesView
—in order to open the Conversation Realm. This is because you can only pass in a single Realm through the environment, and ChatRoomView
needed the User Realm. On the plus side, we no longer need all of the boilerplate code to open the Realm from the view's onApppear
method explicitly.
ChatRoomBubblesView sorts the Realm result set by timestamp (we want the most recent chat message to appear at the bottom of the List):
1 ChatMessage.self, (2 sortDescriptor: SortDescriptor(keyPath: "timestamp", ascending: true)) var chats.
The Realm/SwiftUI integration means that the UI will automatically refresh whenever a new chat message is added to the Realm, but I also want to scroll to the bottom of the list so that the latest message is visible. We can achieve this by monitoring the Realm. Note that we only open a Conversation
Realm when the user opens the associated view because having too many realms open concurrently can exhaust resources. It's also important that we stop observing the Realm by setting it to nil
when leaving the view:
1 private var realmChatsNotificationToken: NotificationToken? 2 private var latestChatId = "" 3 ... 4 ScrollView(.vertical) { 5 ScrollViewReader { (proxy: ScrollViewProxy) in 6 VStack { 7 ForEach(chats) { chatMessage in 8 ChatBubbleView(chatMessage: chatMessage, 9 authorName: chatMessage.author != state.user?.userName ? chatMessage.author : nil, 10 isPreview: isPreview) 11 } 12 } 13 .onAppear { 14 scrollToBottom() 15 withAnimation(.linear(duration: 0.2)) { 16 proxy.scrollTo(latestChatId, anchor: .bottom) 17 } 18 } 19 .onChange(of: latestChatId) { target in 20 withAnimation { 21 proxy.scrollTo(target, anchor: .bottom) 22 } 23 } 24 } 25 } 26 ... 27 .onAppear { loadChatRoom() } 28 .onDisappear { closeChatRoom() } 29 ... 30 private func loadChatRoom() { 31 scrollToBottom() 32 realmChatsNotificationToken = chats.thaw()?.observe { _ in 33 scrollToBottom() 34 } 35 } 36 37 private func closeChatRoom() { 38 if let token = realmChatsNotificationToken { 39 token.invalidate() 40 } 41 } 42 43 private func scrollToBottom() { 44 latestChatId = chats.last?._id ?? "" 45 }
Note that we clear the notification token when leaving the view, ensuring that resources aren't wasted.
To send a message, all the app needs to do is to add the new chat message to Realm. Realm Sync will then copy it to Atlas, where it is then synced to the other users. Note that we no longer need to explicitly open a Realm transaction to append the new chat message to the Realm that was received through the environment:
1 ChatMessage.self, sortDescriptor: SortDescriptor(keyPath: "timestamp", ascending: true)) var chats (2 ... 3 private func sendMessage(chatMessage: ChatMessage) { 4 guard let conversataionString = conversation else { 5 print("comversation not set") 6 return 7 } 8 chatMessage.conversationId = conversataionString.id 9 $chats.append(chatMessage) 10 }
#Summary
Since the release of Building a Mobile Chat App Using Realm – Integrating Realm into Your App, Realm-Cocoa 10.6 added new features that make working with Realm and SwiftUI simpler. Simply by passing the Realm configuration through the environment, the Realm is opened and made available to the view, and that view can go on to make updates without explicitly starting a transaction. This article has shown how those new features can be used to simplify your code. It has gone through the key steps you need to take when building a mobile app using Realm, including:
- Managing the user lifecycle: registering, authenticating, logging in, and logging out.
- Managing and storing user profile information.
- Adding objects to Realm.
- Performing searches on Realm data.
- Syncing data between your mobile apps and with MongoDB Atlas.
- Reacting to data changes synced from other devices.
- Adding some backend magic using Realm triggers and functions.
We've skipped a lot of code and functionality in this article, and it's worth looking through the rest of the app to see how to use features such as these from a SwiftUI iOS app:
- Location data
- Maps
- Camera and photo library
- Actions when minimizing your app
- Notifications
We wrote the iOS version of the app first, but we plan on adding an Android (Kotlin) version soon—keep checking the developer hub and the repo for updates.
#References
- Read Building a Mobile Chat App Using Realm – Data Architecture to understand the data model and partitioning strategy behind the RChat app
- Read Building a Mobile Chat App Using Realm – Integrating Realm into Your App if you want to know how to build Realm into your app without using the new SwiftUI featured in Realm-Cocoa 10.6 (for example, if you need to use UIKit)
- If you're building your first SwiftUI/Realm app, then check out Build Your First iOS Mobile App Using Realm, SwiftUI, & Combine
If you have questions, please head to our developer community website where the MongoDB engineers and the MongoDB community will help you build your next big idea with MongoDB.
More from this series