Realm with SwiftUI QuickStart
On this page
Prerequisites
Have Xcode 12.4 or later (minimum Swift version 5.3.1).
Create a new Xcode project using the SwiftUI "App" template with a minimum iOS target of 15.0.
Install the Swift SDK. This SwiftUI app requires a minimum SDK version of 10.19.0.
Overview
Tip
See also: Use Realm with SwiftUI
This page provides a small working app to get you up and running with Realm and SwiftUI quickly. If you'd like to see additional examples, including more explanation about Realm's SwiftUI features, see: SwiftUI - Swift SDK.
This page contains all of the code for a working Realm and SwiftUI app.
The app starts on the ItemsView
, where you can edit a list of items:
Press the
Add
button on the bottom right of the screen to add randomly-generated items.Press the
Edit
button on the top right to modify the list order, which the app persists in the realm.You can also swipe to delete items.
When you have items in the list, you can press one of the items to
navigate to the ItemDetailsView
. This is where you can modify the
item name or mark it as a favorite:
Press the text field in the center of the screen and type a new name. When you press Return, the item name should update across the app.
You can also toggle its favorite status by pressing the heart toggle in the top right.
Tip
This guide optionally integrates with Device Sync. See Integrate Atlas Device Sync below.
Get Started
We assume you have created an Xcode project with the SwiftUI "App"
template. Open the main Swift file and delete all of the code inside,
including any @main
App
classes that Xcode generated for you. At
the top of the file, import the Realm and SwiftUI frameworks:
import RealmSwift import SwiftUI
Tip
Just want to dive right in with the complete code? Jump to Complete Code below.
Define Models
A common Realm data modeling use case is to have "things" and "containers of things". This app defines two related Realm object models: item and itemGroup.
An item has two user-facing properties:
A randomly generated-name, which the user can edit.
An
isFavorite
boolean property, which shows whether the user "favorited" the item.
An itemGroup contains items. You can extend the itemGroup to have a name and an association with a specific user, but that's out of scope of this guide.
Paste the following code into your main Swift file to define the models:
Because Flexible Sync does not automatically include linked objects,
we must add ownerId
to both objects. You can omit ownerId
if you
only want to use a local realm.
/// Random adjectives for more interesting demo item names let randomAdjectives = [ "fluffy", "classy", "bumpy", "bizarre", "wiggly", "quick", "sudden", "acoustic", "smiling", "dispensable", "foreign", "shaky", "purple", "keen", "aberrant", "disastrous", "vague", "squealing", "ad hoc", "sweet" ] /// Random noun for more interesting demo item names let randomNouns = [ "floor", "monitor", "hair tie", "puddle", "hair brush", "bread", "cinder block", "glass", "ring", "twister", "coasters", "fridge", "toe ring", "bracelet", "cabinet", "nail file", "plate", "lace", "cork", "mouse pad" ] /// An individual item. Part of an `ItemGroup`. final class Item: Object, ObjectKeyIdentifiable { /// The unique ID of the Item. `primaryKey: true` declares the /// _id member as the primary key to the realm. true) var _id: ObjectId (primaryKey: /// The name of the Item, By default, a random name is generated. var name = "\(randomAdjectives.randomElement()!) \(randomNouns.randomElement()!)" /// A flag indicating whether the user "favorited" the item. var isFavorite = false /// Users can enter a description, which is an empty string by default var itemDescription = "" /// The backlink to the `ItemGroup` this item is a part of. "items") var group: LinkingObjects<ItemGroup> (originProperty: /// Store the user.id as the ownerId so you can query for the user's objects with Flexible Sync /// Add this to both the `ItemGroup` and the `Item` objects so you can read and write the linked objects. var ownerId = "" } /// Represents a collection of items. final class ItemGroup: Object, ObjectKeyIdentifiable { /// The unique ID of the ItemGroup. `primaryKey: true` declares the /// _id member as the primary key to the realm. true) var _id: ObjectId (primaryKey: /// The collection of Items in this group. var items = RealmSwift.List<Item>() /// Store the user.id as the ownerId so you can query for the user's objects with Flexible Sync /// Add this to both the `ItemGroup` and the `Item` objects so you can read and write the linked objects. var ownerId = "" }
Views and Observed Objects
The entrypoint of the app is the ContentView
class that derives from
SwiftUI.App
. For now, this always displays the
LocalOnlyContentView
. Later, this will show the SyncContentView
when Device Sync is enabled.
/// The main screen that determines whether to present the SyncContentView or the LocalOnlyContentView. /// For now, it always displays the LocalOnlyContentView. @main struct ContentView: SwiftUI.App { var body: some Scene { WindowGroup { LocalOnlyContentView() } } }
Tip
You can use a realm other than the default realm by passing an environment object from higher in the View hierarchy:
LocalOnlyContentView() .environment(\.realmConfiguration, Realm.Configuration( /* ... */ ))
The LocalOnlyContentView has an @ObservedResults itemGroups. This implicitly uses the default realm to load all itemGroups when the view appears.
This app only expects there to ever be one itemGroup. If there is an itemGroup
in the realm, the LocalOnlyContentView renders an ItemsView
for
that itemGroup.
If there is no itemGroup already in the realm, then the
LocalOnlyContentView displays a ProgressView while it adds one. Because
the view observes the itemGroups thanks to the @ObservedResults
property
wrapper, the view immediately refreshes upon adding that first itemGroup and
displays the ItemsView.
/// The main content view if not using Sync. struct LocalOnlyContentView: View { var searchFilter: String = "" // Implicitly use the default realm's objects(ItemGroup.self) ItemGroup.self) var itemGroups ( var body: some View { if let itemGroup = itemGroups.first { // Pass the ItemGroup objects to a view further // down the hierarchy ItemsView(itemGroup: itemGroup) } else { // For this small app, we only want one itemGroup in the realm. // You can expand this app to support multiple itemGroups. // For now, if there is no itemGroup, add one here. ProgressView().onAppear { $itemGroups.append(ItemGroup()) } } } }
Tip
Starting in SDK version 10.12.0, you can use an optional key path parameter
with @ObservedResults
to filter change notifications to only those
occurring on the provided key path or key paths. For example:
@ObservedResults(MyObject.self, keyPaths: ["myList.property"])
The ItemsView receives the itemGroup from the parent view and stores it in an @ObservedRealmObject property. This allows the ItemsView to "know" when the object has changed regardless of where that change happened.
The ItemsView iterates over the itemGroup's items and passes each item to an
ItemRow
for rendering as a list.
To define what happens when a user deletes or moves a row, we pass the
remove
and move
methods of the Realm
List as the handlers of the respective
remove and move events of the SwiftUI List. Thanks to the
@ObservedRealmObject
property wrapper, we can use these methods
without explicitly opening a write transaction. The property wrapper
automatically opens a write transaction as needed.
/// The screen containing a list of items in an ItemGroup. Implements functionality for adding, rearranging, /// and deleting items in the ItemGroup. struct ItemsView: View { var itemGroup: ItemGroup /// The button to be displayed on the top left. var leadingBarButton: AnyView? var body: some View { NavigationView { VStack { // The list shows the items in the realm. List { ForEach(itemGroup.items) { item in ItemRow(item: item) }.onDelete(perform: $itemGroup.items.remove) .onMove(perform: $itemGroup.items.move) } .listStyle(GroupedListStyle()) .navigationBarTitle("Items", displayMode: .large) .navigationBarBackButtonHidden(true) .navigationBarItems( leading: self.leadingBarButton, // Edit button on the right to enable rearranging items trailing: EditButton()) // Action bar at bottom contains Add button. HStack { Spacer() Button(action: { // The bound collection automatically // handles write transactions, so we can // append directly to it. $itemGroup.items.append(Item()) }) { Image(systemName: "plus") } }.padding() } } } }
Finally, the ItemRow
and ItemDetailsView
classes use the
@ObservedRealmObject
property wrapper with the item passed in from
above. These classes demonstrate a few more examples of how to use the
property wrapper to display and update properties.
/// Represents an Item in a list. struct ItemRow: View { var item: Item var body: some View { // You can click an item in the list to navigate to an edit details screen. NavigationLink(destination: ItemDetailsView(item: item)) { Text(item.name) if item.isFavorite { // If the user "favorited" the item, display a heart icon Image(systemName: "heart.fill") } } } } /// Represents a screen where you can edit the item's name. struct ItemDetailsView: View { var item: Item var body: some View { VStack(alignment: .leading) { Text("Enter a new name:") // Accept a new name TextField("New name", text: $item.name) .navigationBarTitle(item.name) .navigationBarItems(trailing: Toggle(isOn: $item.isFavorite) { Image(systemName: item.isFavorite ? "heart.fill" : "heart") }) }.padding() } }
Tip
@ObservedRealmObject
is a frozen object. If you want to modify
the properties of an @ObservedRealmObject
directly in a write transaction, you must .thaw()
it first.
At this point, you have everything you need to work with Realm and SwiftUI. Test it out and see if everything is working as expected. Read on to learn how to integrate this app with Device Sync.
Integrate Atlas Device Sync
Now that we have a working Realm app, we can optionally integrate with Device Sync. Sync allows you to you see the changes you make across devices. Before you can add sync to this app, make sure to:
Choose Flexible Sync
Specify a cluster and database.
Turn on Development Mode.
Use
ownerId
as the queryable field.Enable Sync.
Define the rules that determine which permissions users have when using Device Sync. For this example, we assign a default role, which applies to any collection that does not have a collection-specific role. In this example, a user can read and write data where the
user.id
of the logged-in user matches theownerId
of the object:{ "roles": [ { "name": "owner-read-write", "apply_when": {}, "document_filters": { "write": { "ownerId": "%%user.id" }, "read": { "ownerId": "%%user.id" } }, "read": true, "write": true, "insert": true, "delete": true, "search": true } ] }
Now, deploy your application updates.
Tip
The Sync version of this app changes the app flow a bit. The first
screen becomes the LoginView
. When you press the Log
in button, the app navigates to the ItemsView, where you see the
synced list of items in a single itemGroup.
At the top of the source file, initialize an optional Realm app with your App ID:
// MARK: Atlas App Services (Optional) // The App Services App. Change YOUR_APP_SERVICES_APP_ID_HERE to your App Services App ID. // If you don't have a App Services App and don't wish to use Sync for now, // you can change this to: // let app: RealmSwift.App? = nil let app: RealmSwift.App? = RealmSwift.App(id: YOUR_APP_SERVICES_APP_ID_HERE)
Tip
You can change the app reference to nil
to switch back to
local-only (non-Device Sync) mode.
Let's update the main ContentView to show the SyncContentView
if the
app reference is not nil
:
/// The main screen that determines whether to present the SyncContentView or the LocalOnlyContentView. @main struct ContentView: SwiftUI.App { var body: some Scene { WindowGroup { // Using Sync? if let app = app { SyncContentView(app: app) } else { LocalOnlyContentView() } } } }
We define the SyncContentView below.
The SyncContentView observes the Realm app instance. The app instance is the interface to the App Services backend, which provides the user authentication required for Sync. By observing the app instance, the SyncContentView can react when a user logs in or out.
This view has two possible states:
If the Realm app does not have a currently logged-in user, show the
LoginView
.If the app does have a logged-in user, show the
OpenSyncedRealmView
.
In this view, after confirming we have a user, we create a
flexibleSyncConfiguration()
that includes the initialSubscriptions
parameter. We can use this
parameter to subscribe to queryable fields. These initial subscriptions
search for data that matches the queries, and syncs that data to the
realm. If no data matches the queries, the realm opens with an initial
empty state.
Your client application can only write objects that match the
subscription query to a realm opened with a flexibleSyncConfiguration
.
Trying to write objects that don't match the query causes the app to
perform a compensating write to undo the illegal write operation.
/// This view observes the Realm app object. /// Either direct the user to login, or open a realm /// with a logged-in user. struct SyncContentView: View { // Observe the Realm app object in order to react to login state changes. var app: RealmSwift.App var body: some View { if let user = app.currentUser { // Create a `flexibleSyncConfiguration` with `initialSubscriptions`. // We'll inject this configuration as an environment value to use when opening the realm // in the next view, and the realm will open with these initial subscriptions. let config = user.flexibleSyncConfiguration(initialSubscriptions: { subs in // Check whether the subscription already exists. Adding it more // than once causes an error. if let foundSubscriptions = subs.first(named: "user_groups") { // Existing subscription found - do nothing return } else { // Add queries for any objects you want to use in the app // Linked objects do not automatically get queried, so you // must explicitly query for all linked objects you want to include subs.append(QuerySubscription<ItemGroup>(name: "user_groups") { // Query for objects where the ownerId is equal to the app's current user's id // This means the app's current user can read and write their own data $0.ownerId == user.id }) subs.append(QuerySubscription<Item>(name: "user_items") { $0.ownerId == user.id }) } }) OpenSyncedRealmView() .environment(\.realmConfiguration, config) } else { // If there is no user logged in, show the login view. LoginView() } } }
In our subscriptions, we're querying for ItemGroup
and Item
objects
where the ownerId
matches the logged-in user's user.id
.
Together with the permissions we used when we enabled Device Sync
above, this means that the user can only read and write their own
data.
Flexible Sync does not automatically provide access to linked objects.
Because of this, we must add subscriptions for both the ItemGroup
and
Item
objects - we can't just query for one or the other and get
the related objects.
From here, we pass the flexibleSyncConfiguration to the
OpenSyncedRealmView as a realmConfiguration
using an environment
object. This is the view responsible for opening a realm and working
with the data. Sync uses this configuration to search for data
that should sync to the realm.
OpenSyncedRealmView() .environment(\.realmConfiguration, config)
Once logged in, we open the realm asynchronously with the AsyncOpen property wrapper.
Because we've injected a flexibleSyncConfiguration()
into the
view as an environment value, the property wrapper uses this
configuration to initiate Sync and download any matching data before
opening the realm. If we had not provided a configuration, the property
wrapper would create a default flexibleSyncConfiguration()
for us,
and we could subscribe to queries
in .onAppear
.
// We've injected a `flexibleSyncConfiguration` as an environment value, // so `@AsyncOpen` here opens a realm using that configuration. YOUR_APP_SERVICES_APP_ID_HERE, timeout: 4000) var asyncOpen (appId:
The OpenSyncedRealmView switches on the AsyncOpenState
enum, which lets us show different views
based on the state. In our example, we show a ProgressView
while we're
connecting to the App and the realm is syncing. We then open the
realm, passing the itemGroup
to the ItemsView
, or show an
ErrorView
if we can't open the realm.
Tip
When opening a synced realm, use the AsyncOpen property wrapper to always download synced changes
before opening the realm, or the AutoOpen property wrapper to open a realm while syncing
in the background. AsyncOpen
requires the user to be online,
while AutoOpen
opens a realm even if the user is offline.
This view has a few different states:
While connecting or waiting for login, show a
ProgressView
.While downloading changes to the realm, show a
ProgressView
with a progress indicator.When the realm opens, check for an itemGroup object. If one does not exist yet, create one. Then, show the ItemsView for the itemGroup in the realm. Provide a
LogoutButton
that the ItemsView can display on the top left of the navigation bar.If there is an error loading the realm, show an error view containing the error.
When you run the app and see the main UI, there are no items in the view. That's because we're using anonymous login, so this is the first time this specific user logs in.
/// This view opens a synced realm. struct OpenSyncedRealmView: View { // We've injected a `flexibleSyncConfiguration` as an environment value, // so `@AsyncOpen` here opens a realm using that configuration. YOUR_APP_SERVICES_APP_ID_HERE, timeout: 4000) var asyncOpen (appId: var body: some View { // Because we are setting the `ownerId` to the `user.id`, we need // access to the app's current user in this view. let user = app?.currentUser switch asyncOpen { // Starting the Realm.asyncOpen process. // Show a progress view. case .connecting: ProgressView() // Waiting for a user to be logged in before executing // Realm.asyncOpen. case .waitingForUser: ProgressView("Waiting for user to log in...") // The realm has been opened and is ready for use. // Show the content view. case .open(let realm): ItemsView(itemGroup: { if realm.objects(ItemGroup.self).count == 0 { try! realm.write { // Because we're using `ownerId` as the queryable field, we must // set the `ownerId` to equal the `user.id` when creating the object realm.add(ItemGroup(value: ["ownerId":user!.id])) } } return realm.objects(ItemGroup.self).first! }(), leadingBarButton: AnyView(LogoutButton())).environment(\.realm, realm) // The realm is currently being downloaded from the server. // Show a progress view. case .progress(let progress): ProgressView(progress) // Opening the Realm failed. // Show an error view. case .error(let error): ErrorView(error: error) } } }
In our subscriptions, we're querying for ItemGroup
and Item
objects
where the ownerId
matches the logged-in user's user.id
.
Together with the permissions we used when we created the Flexible Sync
app above, this means that the user can only read and write their own
data.
Flexible Sync does not automatically provide access to linked objects.
Because of this, we must add subscriptions for both the ItemGroup
and
Item
objects - we can't just query for one or the other and get
the related objects.
With this in mind, we must also update the view here where we are
creating a ItemGroup
object. We must set the ownerId
as the user.id
of the logged-in user.
ItemsView(itemGroup: { if realm.objects(ItemGroup.self).count == 0 { try! realm.write { // Because we're using `ownerId` as the queryable field, we must // set the `ownerId` to equal the `user.id` when creating the object realm.add(ItemGroup(value: ["ownerId":user!.id])) } } return realm.objects(ItemGroup.self).first! }(), leadingBarButton: AnyView(LogoutButton())).environment(\.realm, realm)
And we must also update the ItemsView
to add ownerId
when we
create Item
objects:
// Action bar at bottom contains Add button. HStack { Spacer() Button(action: { // The bound collection automatically // handles write transactions, so we can // append directly to it. // Because we are using Flexible Sync, we must set // the item's ownerId to the current user.id when we create it. $itemGroup.items.append(Item(value: ["ownerId":user!.id])) }) { Image(systemName: "plus") } }.padding()
Authenticate Users with Atlas App Services
The LoginView maintains some state in order to display an activity indicator or error. It uses a reference to the Realm app instance passed in from above to log in when the Log in anonymously button is clicked.
Tip
In the LoginView, you can implement email/password authentication or another authentication provider. For simplicity, this example uses Anonymous authentication.
Once login is complete, the LoginView itself doesn't need to do anything more. Because the parent view is observing the Realm app, it will notice when the user authentication state has changed and decide to show something other than the LoginView.
/// Represents the login screen. We will have a button to log in anonymously. struct LoginView: View { // Hold an error if one occurs so we can display it. var error: Error? // Keep track of whether login is in progress. var isLoggingIn = false var body: some View { VStack { if isLoggingIn { ProgressView() } if let error = error { Text("Error: \(error.localizedDescription)") } Button("Log in anonymously") { // Button pressed, so log in isLoggingIn = true Task { do { let user = try await app!.login(credentials: .anonymous) // Other views are observing the app and will detect // that the currentUser has changed. Nothing more to do here. print("Logged in as user with id: \(user.id)") } catch { print("Failed to log in: \(error.localizedDescription)") // Set error to observed property so it can be displayed self.error = error return } } }.disabled(isLoggingIn) } } }
The LogoutButton works just like the LoginView, but logs out instead of logging in:
/// A button that handles logout requests. struct LogoutButton: View { var isLoggingOut = false var body: some View { Button("Log Out") { guard let user = app!.currentUser else { return } isLoggingOut = true Task { do { try await app!.currentUser!.logOut() // Other views are observing the app and will detect // that the currentUser has changed. Nothing more to do here. } catch { print("Error logging out: \(error.localizedDescription)") } } }.disabled(app!.currentUser == nil || isLoggingOut) } }
Once logged in, the app follows the same flow as the local-only version.
Complete Code
If you would like to copy and paste or examine the complete code with or without Device Sync, see below.