Building a collaborative iOS Minesweeper game with Realm

Realm-Sweeper. Writing a multiplayer iOS Minesweeper game using SwiftUI and Realm

I wanted to build an app that we could use at events to demonstrate Realm Sync. It needed to be fun to interact with, and so a multiplayer game made sense. Tic-tac-toe is too simple to get excited about. I’m not a game developer and so Call Of Duty wasn’t an option. Then I remembered Microsoft’s Minesweeper.

Minesweeper was a Windows fixture from 1990 until Windows 8 relegated it to the app store in 2012. It was a single-player game, but it struck me as something that could be a lot of fun to play with others. Some family beta-testing of my first version while waiting for a ferry proved that it did get people to interact with each other (even if most interactions involved shouting, “Which of you muppets clicked on that mine?!”).

Family sat around a table, all playing the Realm-Sweeper game on their iPhones

You can download the back end and iOS apps from the Realm-Sweeper repo, and get it up and running in a few minutes if you want to play with it.

This article steps you through some of the key aspects of setting up the backend Realm app, as well as the iOS code. Hopefully, you’ll see how simple it is and try building something for yourself. If anyone’s looking for ideas, then Sokoban could be interesting.

Prerequisites

The Minesweeper game

The gameplay for Minesweeper is very simple.

You’re presented with a grid of gray tiles. You tap on a tile to expose what’s beneath. If you expose a mine, game over. If there isn’t a mine, then you’ll be rewarded with a hint as to how many mines are adjacent to that tile. If you deduce (or guess) that a tile is covering a mine, then you can plant a flag to record that.

You win the game when you correctly flag every mine and expose what’s behind every non-mined tile.

What Realm-Sweeper adds

Minesweeper wasn’t designed for touchscreen devices; you had to use a physical mouse. Realm-Sweeper brings the game into the 21st century by adding touch controls. Tap a tile to reveal what’s beneath; tap and hold to plant a flag.

Minesweeper was a single-player game. All people who sign into Realm-Sweeper with the same user ID get to collaborate on the same game in real time.

Animation of two iPhones. As a user taps a tile on one device, the change appears almost instantly on the other

You also get to configure the size of the grid and how many mines you’d like to hide.

The data model

I decided to go for a simple data model that would put Realm sync to the test.

Each game is a single document/object that contains meta data (score, number of rows/columns, etc.) together with the grid of tiles (the board):

Data model for the Game Class

This means that even a modestly sized grid (20×20 tiles) results in a Game document/object with more than 2,000 attributes.

Every time you tap on a tile, the Game object has to be synced with all other players. Those players are also tapping on tiles, and those changes have to be synced too. If you tap on a tile which isn’t adjacent to any mines, then the app will recursively ripple through exposing similar, connected tiles. That’s a lot of near-simultaneous changes being made to the same object from different devices—a great test of Realm’s automatic conflict resolution!

The backend Realm app

If you don’t want to set this up yourself, simply follow the instructions from the repo to import the app.

If you opt to build the backend app yourself, there are only two things to configure once you create the empty Realm app:

  1. Enable email/password authentication. I kept it simple by opting to auto-confirm new users and sticking with the default password-reset function (which does nothing).
  2. Enable partitioned Realm sync. Set the partition key to partition and enable developer mode (so that the schema will be created automatically when the iOS app syncs for the first time).

The partition field will be set to the username—allowing anyone who connects as that user to sync all of their games.

You can also add sync rules to ensure that a user can only sync their own games (in case someone hacks the mobile app). I always prefer using Realm functions for permissions. You can add this for both the read and write rules:

{
  "%%true": {
    "%function": {
      "arguments": [
        "%%partition"
      ],
      "name": "canAccessPartition"
    }
  }
}

The canAccessPartition function is:

exports = function(partition) {
  const user = context.user.data.email;
  return partition === user;
};

The iOS app

I’d suggest starting by downloading, configuring, and running the app—just follow the instructions from the repo. That way, you can get a feel for how it works.

This isn’t intended to be a full tutorial covering every line of code in the app. Instead, I’ll point out some key components.

As always with Realm and MongoDB, it all starts with the data…

Model

There’s a single top-level Realm Object—Game:

class Game: Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var numRows = 0
    @Persisted var numCols = 0
    @Persisted var score = 0
    @Persisted var startTime: Date? = Date()
    @Persisted var latestMoveTime: Date?
    @Persisted var secondsTakenToComplete: Int?
    @Persisted var board: Board?
    @Persisted var gameStatus = GameStatus.notStarted
    @Persisted var winningTimeInSeconds: Int?
    …
}

Most of the fields are pretty obvious. The most interesting is board, which contains the grid of tiles:

class Board: EmbeddedObject, ObjectKeyIdentifiable {
    @Persisted var rows = List<Row>()
    @Persisted var startingNumberOfMines = 0
    ... 
}

row is a list of Cells:

class Row: EmbeddedObject, ObjectKeyIdentifiable {
    @Persisted var cells = List<Cell>()
    ...
}

class Cell: EmbeddedObject, ObjectKeyIdentifiable {
    @Persisted var isMine = false
    @Persisted var numMineNeigbours = 0
    @Persisted var isExposed = false
    @Persisted var isFlagged = false
    @Persisted var hasExploded = false
    ...
}

The model is also where the ~~business~~ game logic is implemented. This means that the views can focus on the UI. For example, Game includes a computed variable to check whether the game has been solved:

var hasWon: Bool {
    guard let board = board else { return false }
    if board.remainingMines != 0 { return false }

    var result = true

    board.rows.forEach() { row in
        row.cells.forEach() { cell in
            if !cell.isExposed && !cell.isFlagged {
                result = false
                return
            }
        }
        if !result { return }
    }
    return result
}

Views

As with any SwiftUI app, the UI is built up of a hierarchy of many views.

Screen capture from Xcode showing the hierarchy of views making up the RealmSweeper UI

Here’s a quick summary of the views that make up Real-Sweeper:

ContentView is the top-level view. When the app first runs, it will show the LoginView. Once the user has logged in, it shows GameListView instead. It’s here that we set the Realm Sync partition (to be the username of the user that’s just logged in):

if username == "" {
    LoginView(username: $username)
} else {
    GameListView()
        .environment(\.realmConfiguration, realmApp.currentUser!.configuration(partitionValue: username))
        .navigationBarItems(leading: realmApp.currentUser != nil ? LogoutButton(username: $username) : nil)
}

ContentView also includes the LogoutButton view.

LoginView allows the user to provide a username and password:

Screen capture of the login view. Fields to enter username and password. Checkbox to indicate that you're registering a new user. Button to login,

Those credentials are then used to register or log into the backend Realm app:

func userAction() {
    Task {
        do {
            if newUser {
                try await realmApp.emailPasswordAuth.registerUser(
                    email: email, password: password)
            }
            let _ = try await realmApp.login(
                    credentials: .emailPassword(email: email, password: password))
            username = email
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

GameListView reads the list of this user’s existing games.

@ObservedResults(Game.self, 
    sortDescriptor: SortDescriptor(keyPath: "startTime", ascending: false)) var games

It displays each of the games within a GameSummaryView. If you tap one of the games, then you jump to a GameView for that game:

NavigationLink(destination: GameView(game: game)) {
    GameSummaryView(game: game)
}

GameListView, Screen capture of a list of games, together with buttons to logout, set settings, or create a new game

Tap the settings button and you’re sent to SettingsView.

Tap the “New Game” button and a new Game object is created and then stored in Realm by appending it to the games live query:

private func createGame() {
    numMines = min(numMines, numRows * numColumns)
    game = Game(rows: numRows, cols: numColumns, mines: numMines)
    if let game = game {
        $games.append(game)
    }
    startGame  = true
}

SettingsView lets the user choose the number of tiles and mines to use:

SettingsView. Steppers to set the number of rows, columns, and mines

If the user uses multiple devices to play the game (e.g., an iPhone and an iPad), then they may want different-sized boards (taking advantage of the extra screen space on the iPad). Because of that, the view uses the device’s UserDefaults to locally persist the settings rather than storing them in a synced realm:

@AppStorage("numRows") var numRows = 10
@AppStorage("numColumns") var numColumns = 10
@AppStorage("numMines") var numMines = 15

GameSummaryView displays a summary of one of the user’s current or past games.

GameSummaryView. Screen capture of view containing the start and completion times + emoji for the status of the game

GameView shows the latest stats for the current game at the top of the screen:

GameStatusView. Screen capture showing remaining mines, status of the game (smiling emoji) and elapsed time

It uses the LEDCounter and StatusButton views for the summary.

Below the summary, it displays the BoardView for the game.

LEDCounter displays the provided number as three digits using a retro LED font:

CounterView – 3 red LED numbers

StatusButton uses a ZStack to display the symbol for the game’s status on top of a tile image:

StatusButton. Smiling emoji in front of a gray tile

The view uses SwiftUI’s GeometryReader function to discover how much space is available so that it can select an appropriate font size for the symbol:

GeometryReader { geo in
    Text(status)
        .font(.system(size: geo.size.height * 0.7))
}

BoardView displays the game’s grid of tiles:

BoardView. A grid of tiles. Some tiles have been removed, revealing colored numbers. One tile contains a red flag

Each of the tiles is represented by a CellView view.

When a tile is tapped, this view exposes its contents:

.onTapGesture() {
    expose(row: row, col: col)
}

On a tap-and-hold, a flag is dropped:

.onLongPressGesture(minimumDuration: 0.1) {
    flag(row: row, col: col)
}

When my family tested the first version of the app, they were frustrated that they couldn’t tell whether they’d held long enough for the flag to be dropped. This was an easy mistake to make as their finger was hiding the tile at the time—an example of where testing with a mouse and simulator wasn’t a substitute for using real devices. It was especially frustrating as getting it wrong meant that you revealed a mine and immediately lost the game. Fortunately, this is easy to fix using iOS’s haptic feedback:

func hapticFeedback(_ isSuccess: Bool) {
    let generator = UINotificationFeedbackGenerator()
    generator.notificationOccurred(isSuccess ? .success : .error)
}

You now feel a buzz when the flag has been dropped.

CellView displays an individual tile:

CellView. Tile containing a crossed out red flag

What’s displayed depends on the contents of the Cell and the state of the game. It uses four further views to display different types of tile: FlagView, MineCountView, MineView, and TileView.

FlagView

FlagView. 2 Tiles, both containing a flag, one shows the  flag crossed out

MineCountView

MineCountView, 6 gray tiles. One is empty the others containing numbers 1 through 5, each in a different color

MineView

MineView. Two tiles containing mines, one with a gray background, one with a red background

TileView

TileView. A single gray tile

Conclusion

Realm-Sweeper gives a real feel for how quickly Realm is able to synchronize data over the internet.

I intentionally avoided optimizing how I updated the game data in Realm. When you see a single click exposing dozens of tiles, each cell change is an update to the Game object that needs to be synced.

GIF showing changes in one game board appearing in near-realtime in the game board on a different device

Note that both instances of the game are running in iPhone simulators on an overworked Macbook in England. The Realm backend app is running in the US—that’s a 12,000 km/7,500 mile round trip for each sync.

I took this approach as I wanted to demonstrate the performance of Realm synchronization. If an app like this became super-popular with millions of users, then it would put a lot of extra strain on the backend Realm app.

An obvious optimization would be to condense all of the tile changes from a single tap into a single write to the Realm object. If you’re interested in trying that out, just fork the repo and make the changes. If you do implement the optimization, then please create a pull request. (I’d probably add it as an option within the settings so that the “slow” mode is still an option.)

Got questions? Ask them in our Community forum.





Using Realm Flexible Sync in Your App—an iOS Tutorial

Introduction

We recently announced the release of the Realm Flexible Sync preview—an opportunity for developers to take it for a spin and give us feedback. That article provided an overview of the benefits of flexible sync and how it works. TL;DR: You typically don’t want to sync the entire backend database to every device—whether for capacity or security concerns. Realm Flexible Sync lets the developer provide queries to control exactly what the mobile app asks to sync, together with backend rules to ensure users can only access the data that they’re entitled to.

This post builds on that introduction by showing how to add flexible sync to the RChat mobile app. I’ll show how to configure the Realm backend app, and then what code needs adding to the mobile app.

Everything you see in this tutorial can be found in the flex-sync branch of the RChat repo.

Prerequisites

The RChat App

RChat is a messaging app. Users can add other users to a chat room and then share messages, images, and location with each other.

Screen capture video of running the RCha App on 2 different iOS simulators. Two users join a chat room and send messages to each other

All of the user and chat message data is shared between instances of the app via Realm Sync.

There’s a common Realm backend app. There are frontend apps for iOS and Android. This post focuses on the backend and the iOS app.

Configuring the Realm Backend App

The backend app contains a lot of functionality that isn’t connected to the sync functionality, and so I won’t cover that here. If you’re interested, then check out the original RChat series.

As a starting point, you can install the app. I’ll then explain the parts connected to Realm Sync.

Import the Backend Realm App

  1. If you don’t already have one, create a MongoDB Atlas Cluster, keeping the default name of Cluster0. The Atlas cluster must be running MongoDB 5.0 or later.
  2. Install the Realm CLI and create an API key pair.
  3. Download the repo and install the Realm app:
git clone https://github.com/ClusterDB/RChat.git
git checkout flex-sync
cd RChat/RChat-Realm/RChat
realm-cli login --api-key <your new public key> --private-api-key <your new private key>
realm-cli import # Then answer prompts, naming the app RChat

  1. From the Atlas UI, click on the Realm logo and you will see the RChat app. Open it and copy the App Id. You’ll need to use this before building the iOS app.

Screen capture of how to copy the Realm App ID in the Realm UI

How Flexible Sync is Enabled in the Back End

Schema

The schema represents how the data will be stored in MongoDB Atlas **and*- what the Swift (and Kotlin) model classes must contain.

Each collection/class requires a schema. If you enable Realm’s “Developer Mode” option, then Realm will automatically define the schema based on your Swift or Kotlin model classes. In this case, your imported Realm App includes the schemas, and so developer mode isn’t needed. You can view the schemas by browsing to the “Schema” section in the Realm UI:

Screen capture of schema section of the Realm UI

You can find more details about the schema/model in Building a Mobile Chat App Using Realm – Data Architecture, but note that for flexible sync (as opposed to the original partition-based sync), the partition field has been removed.

We’re interested in the schema for three collections/model-classes:

User:

{
  "bsonType": "object",
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "conversations": {
      "bsonType": "array",
      "items": {
        "bsonType": "object",
        "properties": {
          "displayName": {
            "bsonType": "string"
          },
          "id": {
            "bsonType": "string"
          },
          "members": {
            "bsonType": "array",
            "items": {
              "bsonType": "object",
              "properties": {
                "membershipStatus": {
                  "bsonType": "string"
                },
                "userName": {
                  "bsonType": "string"
                }
              },
              "required": [
                "membershipStatus",
                "userName"
              ],
              "title": "Member"
            }
          },
          "unreadCount": {
            "bsonType": "long"
          }
        },
        "required": [
          "unreadCount",
          "id",
          "displayName"
        ],
        "title": "Conversation"
      }
    },
    "lastSeenAt": {
      "bsonType": "date"
    },
    "presence": {
      "bsonType": "string"
    },
    "userName": {
      "bsonType": "string"
    },
    "userPreferences": {
      "bsonType": "object",
      "properties": {
        "avatarImage": {
          "bsonType": "object",
          "properties": {
            "_id": {
              "bsonType": "string"
            },
            "date": {
              "bsonType": "date"
            },
            "picture": {
              "bsonType": "binData"
            },
            "thumbNail": {
              "bsonType": "binData"
            }
          },
          "required": [
            "_id",
            "date"
          ],
          "title": "Photo"
        },
        "displayName": {
          "bsonType": "string"
        }
      },
      "required": [],
      "title": "UserPreferences"
    }
  },
  "required": [
    "_id",
    "userName",
    "presence"
  ],
  "title": "User"
}

User documents/objects represent users of the app.

Chatster:

{
  "bsonType": "object",
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "avatarImage": {
      "bsonType": "object",
      "properties": {
        "_id": {
          "bsonType": "string"
        },
        "date": {
          "bsonType": "date"
        },
        "picture": {
          "bsonType": "binData"
        },
        "thumbNail": {
          "bsonType": "binData"
        }
      },
      "required": [
        "_id",
        "date"
      ],
      "title": "Photo"
    },
    "displayName": {
      "bsonType": "string"
    },
    "lastSeenAt": {
      "bsonType": "date"
    },
    "presence": {
      "bsonType": "string"
    },
    "userName": {
      "bsonType": "string"
    }
  },
  "required": [
    "_id",
    "presence",
    "userName"
  ],
  "title": "Chatster"
}

Chatster documents/objects represent a read-only subset of instances of User documents. Chatster is needed because there’s a subset of User data that we want to make accessible to all users. E.g., I want everyone to be able to see my username, presence status, and avatar image, but I don’t want them to see which chat rooms I’m a member of.

Realm Sync lets you control which users can sync which documents, but it doesn’t let you sync just a subset of a document’s fields. That’s why Chatster is needed. I’m looking forward to when Realm Sync permissions allow me to control access on a per-field (rather than per-document/class) basis. At that point, I can remove Chatster from the app.

ChatMessage:

{
  "bsonType": "object",
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "author": {
      "bsonType": "string"
    },
    "authorID": {
      "bsonType": "string"
    },
    "conversationID": {
      "bsonType": "string"
    },
    "image": {
      "bsonType": "object",
      "properties": {
        "_id": {
          "bsonType": "string"
        },
        "date": {
          "bsonType": "date"
        },
        "picture": {
          "bsonType": "binData"
        },
        "thumbNail": {
          "bsonType": "binData"
        }
      },
      "required": [
        "_id",
        "date"
      ],
      "title": "Photo"
    },
    "location": {
      "bsonType": "array",
      "items": {
        "bsonType": "double"
      }
    },
    "text": {
      "bsonType": "string"
    },
    "timestamp": {
      "bsonType": "date"
    }
  },
  "required": [
    "_id",
    "authorID",
    "conversationID",
    "text",
    "timestamp"
  ],
  "title": "ChatMessage"
}

There’s a ChatMessage document object for every message sent to any chat room.

Flexible Sync Configuration

You can view and edit the sync configuration by browsing to the “Sync” section of the Realm UI:

Enabling Realm Flexible Sync in the Realm UI

For this deployment, I’ve selected the Atlas cluster to use. That cluster must be running MongoDB 5.0 or later. At the time of writing, MongoDB 5.0 isn’t available for shared clusters (including free-tier M0 instances)—that’s expected to change very soon, possibly by the time that you’re reading this.

You must specify which fields the mobile app can use in its sync filter queries. Without this, you can’t refer to those fields in your sync queries or permissions. You are currently limited to 10 fields.

Scrolling down, you can see the sync permissions:

Screenshot of a JSON document representing sync permissions in the Realm UI

The UI has flattened the permissions JSON document; here’s a version that’s easier to read:

{
   "rules": {
       "User": [
           {
               "name": "anyone",
               "applyWhen": {},
               "read": {
                   "_id": "%%user.id"
               },
               "write": {
                   "_id": "%%user.id"
               }
           }
       ],
       "Chatster": [
           {
               "name": "anyone",
               "applyWhen": {},
               "read": true,
               "write": false
           }
       ],
       "ChatMessage": [
           {
               "name": "anyone",
               "applyWhen": {},
               "read": true,
               "write": {
                   "authorID": "%%user.id"
               }
           }
       ]
   },
   "defaultRoles": [
       {
           "name": "all",
           "applyWhen": {},
           "read": {},
           "write": {}
       }
   ]
}

The rules component contains a sub-document for each of our collections. Each of those sub-documents contain an array of roles. Each role contains:

  • The name of the role, this should be something that helps other developers understand the purpose of the role (e.g., “admin,” “owner,” “guest”).
  • applyWhen, which defines whether the requesting user matches the role or not. Each of our collections have a single role, and so applyWhen is set to {}, which always evaluates to true.
  • A read rule—how to decide whether this user can view a given document. This is where our three collections impose different rules:
    • A user can read and write to their own User object. No one else can read or write to it.
    • Anyone can read any Chatster document, but no one can write to them. Note that these documents are maintained by database triggers to keep them consistent with their associated User document.
    • The author of a ChatMessage is allowed to write to it. Anyone can read any ChatMessage. Ideally, we’d restrict it to just members of the chat room, but permissions don’t currently support arrays—this is another feature that I’m keen to see added.

Adding Realm Flexible Sync to the iOS App

As with the back end, the iOS app is too big to cover in its entirety in this post. I’ll explain how to build and run the app and then go through the components relevant to Realm Flexible Sync.

Configure, Build, and Run the RChat iOS App

You’ve already downloaded the repo containing the iOS app, but you need to change directory before opening and running the app:

cd ../../RChat-iOS
pod install
open RChat.xcodeproj

Update RChatApp.swift with your Realm App Id (you copied that from the Realm UI when configuring your backend Realm app). In Xcode, select your device or simulator before building and running the app (⌘R). Select a second device or simulator and run the app a second time (⌘R).

On each device, provide a username and password and select the “Register new user” checkbox:
iOS screenshot of registering a new user through the RChat app

Once registered and logged in on both devices, you can create a new chat room, invite your second user, and start sharing messages and photos. To share location, you first need to enable it in the app’s settings.

Key Pieces of the iOS App Code

The Model

You’ve seen the schemas that were defined for the “User,” “Chatster,” and “ChatMessage” collections in the back end Realm app. Each of those collections has an associated Realm Object class in the iOS app. Sub-documents map to embedded objects that conform to RealmEmbeddedObject:

UML diagram showing the User, Chatster, and ChatMessage classes—together with their embedded classes

Let’s take a close look at each of these classes:

User Class

UML diagram showing the User class—together with its embedded classes

class User: Object, ObjectKeyIdentifiable {
   @Persisted(primaryKey: true) var _id = UUID().uuidString
   @Persisted var userName = ""
   @Persisted var userPreferences: UserPreferences?
   @Persisted var lastSeenAt: Date?
   @Persisted var conversations = List<Conversation>()
   @Persisted var presence = "On-Line"
}

class UserPreferences: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var displayName: String?
   @Persisted var avatarImage: Photo?
}

class Photo: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var _id = UUID().uuidString
   @Persisted var thumbNail: Data?
   @Persisted var picture: Data?
   @Persisted var date = Date()
}

class Conversation: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var id = UUID().uuidString
   @Persisted var displayName = ""
   @Persisted var unreadCount = 0
   @Persisted var members = List<Member>()
}

class Member: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var userName = ""
   @Persisted var membershipStatus = "User added, but invite pending"
}

Chatster Class

UML diagram showing the Chatster class—together with its embedded class

class Chatster: Object, ObjectKeyIdentifiable {
   @Persisted(primaryKey: true) var _id = UUID().uuidString // This will match the _id of the associated User
   @Persisted var userName = ""
   @Persisted var displayName: String?
   @Persisted var avatarImage: Photo?
   @Persisted var lastSeenAt: Date?
   @Persisted var presence = "Off-Line"
}

class Photo: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var _id = UUID().uuidString
   @Persisted var thumbNail: Data?
   @Persisted var picture: Data?
   @Persisted var date = Date()
}

ChatMessage Class

UML diagram showing the ChatMessage class—together with its embedded class

class ChatMessage: Object, ObjectKeyIdentifiable {
   @Persisted(primaryKey: true) var _id = UUID().uuidString
   @Persisted var conversationID = ""
   @Persisted var author: String? // username
   @Persisted var authorID: String
   @Persisted var text = ""
   @Persisted var image: Photo?
   @Persisted var location = List<Double>()
   @Persisted var timestamp = Date()
}

class Photo: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var _id = UUID().uuidString
   @Persisted var thumbNail: Data?
   @Persisted var picture: Data?
   @Persisted var date = Date()
}

Accessing Synced Realm Data

Any iOS app that wants to sync Realm data needs to create a Realm App instance, providing the Realm App ID so that the Realm SDK can connect to the backend Realm app:

let app = RealmSwift.App(id: "rchat-xxxxx") // TODO: Set the Realm application ID

When a SwiftUI view (in this case, LoggedInView) needs to access synced data, the parent view must flag that flexible sync will be used. It does this by passing the Realm configuration through the SwiftUI environment:

LoggedInView(userID: $userID)
   .environment(\.realmConfiguration,
               app.currentUser!.flexibleSyncConfiguration())

LoggedInView can then access two variables from the SwiftUI environment:

struct LoggedInView: View {
   ...
   @Environment(\.realm) var realm
   @ObservedResults(User.self) var users

The users variable is a live query containing all synced User objects in the Realm. But at this point, no User documents have been synced because we haven’t subscribed to anything.

That’s easy to fix. We create a new function (setSubscription) that’s invoked when the view is opened:

struct LoggedInView: View {
   ...
   @Binding var userID: String?
   ...
   var body: some View {
       ZStack {
           ...
       }
       .onAppear(perform: setSubscription)
   }

   private func setSubscription() {
       let subscriptions = realm.subscriptions
       subscriptions.write {
           if let currentSubscription = subscriptions.first(named: "user_id") {
               print("Replacing subscription for user_id")
               currentSubscription.update(toType: User.self) { user in
                   user._id == userID!
               }
           } else {
               print("Appending subscription for user_id")
               subscriptions.append(QuerySubscription<User>(name: "user_id") { user in
                   user._id == userID!
               })
           }
       }
   }
}

Subscriptions are given a name to make them easier to work with. I named this one user_id.

The function checks whether there’s already a subscription named user_id. If there is, then the function replaces it. If not, then it adds the new subscription. In either case, the subscription is defined by passing in a query that finds any User documents/objects where the _id field matches the current user’s ID.

The subscription should sync exactly one User object to the realm, and so the code for the view’s body can work with the first object in the results:

struct LoggedInView: View {
   ...
   @ObservedResults(User.self) var users
   @Binding var userID: String?
   ...
   var body: some View {
       ZStack {
           if let user = users.first {
               ...
               ConversationListView(user: user)
               ...
           }
       }
       .navigationBarTitle("Chats", displayMode: .inline)
       .onAppear(perform: setSubscription)
   }
}

Other views work with different model classes and sync queries. For example, when the user clicks on a chat room, a new view is opened that displays all of the ChatMessages for that conversation:

struct ChatRoomBubblesView: View {
   ...
   @ObservedResults(ChatMessage.self, sortDescriptor: SortDescriptor(keyPath: "timestamp", ascending: true)) var chats
   @Environment(\.realm) var realm
   ...
   var conversation: Conversation?
   ...
   var body: some View {
       VStack {
           ...
       }
       .onAppear { loadChatRoom() }
   }

   private func loadChatRoom() {
       ...
       setSubscription()
       ...
   }

   private func setSubscription() {
       let subscriptions = realm.subscriptions
       subscriptions.write {
           if let conversation = conversation {
               if let currentSubscription = subscriptions.first(named: "conversation") {
                   currentSubscription.update(toType: ChatMessage.self) { chatMessage in
                       chatMessage.conversationID == conversation.id
                   }
               } else {
                   subscriptions.append(QuerySubscription<ChatMessage>(name: "conversation") { chatMessage in
                       chatMessage.conversationID == conversation.id
                   })
               }
           }
       }
   }
}

In this case, the query syncs all ChatMessage objects where the conversationID matches the id of the Conversation object passed to the view.

The view’s body can then iterate over all of the matching, synced objects:

struct ChatRoomBubblesView: View {
...
   @ObservedResults(ChatMessage.self,
       sortDescriptor: SortDescriptor(keyPath: "timestamp", ascending: true)) var chats
   ...
   var body: some View {
       ...
       ForEach(chats) { chatMessage in
           ChatBubbleView(chatMessage: chatMessage,
                           authorName: chatMessage.author != user.userName ? chatMessage.author : nil,
                           isPreview: isPreview)
       }
       ...
   }
}

As it stands, there’s some annoying behavior. If you open conversation A, go back, and then open conversation B, you’ll initially see all of the messages from conversation A. The reason is that it takes a short time for the updated subscription to replace the ChatMessage objects in the synced Realm. I solve that by explicitly removing the subscription (which purges the synced objects) when closing the view:

struct ChatRoomBubblesView: View {
   ...
   @Environment(\.realm) var realm
   ...
   var body: some View {
       VStack {
           ...
       }
       .onDisappear { closeChatRoom() }
   }

   private func closeChatRoom() {
       clearSubscription()
       ...
   }

   private func clearSubscription() {
       print("Leaving room, clearing subscription")
       let subscriptions = realm.subscriptions
       subscriptions.write {
           subscriptions.remove(named: "conversation")
       }
   }
}

I made a design decision that I’d use the same name (“conversation”) for this view, regardless of which conversation/chat room it’s working with. An alternative would be to create a unique subscription whenever a new chat room is opened (including the ID of the conversation in the name). I could then avoid removing the subscription when navigating away from a chat room. This second approach would come with two advantages:

  1. The app should be more responsive when navigating between chat rooms (if you’d previously visited the chat room that you’re opening).
  2. You can switch between chat rooms even when the device isn’t connected to the internet.

The disadvantages of this approach would be:

  1. The app could end up with a lot of subscriptions (and there’s a cost to them).
  2. The app continues to store all of the messages from any chat room that you’ve ever visited from this device. That consumes extra device storage and network bandwidth as messages from all of those rooms continue to be synced to the app.

A third approach would be to stick with a single subscription (named “conversations”) that matches every ChatMessage object. The view would then need to apply a filter on the resulting ChatMessage objects so it only displayed those for the open chat room. This has the same advantages as the second approach, but can consume even more storage as the device will contain messages from all chat rooms—including those that the user has never visited.

Note that a different user can log into the app from the same device. You don’t want that user to be greeted with someone else’s data. To avoid that, the app removes all subscriptions when a user logs out:

struct LogoutButton: View {
   ...
   @Environment(\.realm) var realm


   var body: some View {
       Button("Log Out") { isConfirming = true }
       .confirmationDialog("Are you that you want to logout",
                           isPresented: $isConfirming) {
           Button("Confirm Logout", role: .destructive, action: logout)
           Button("Cancel", role: .cancel) {}
       }
       .disabled(state.shouldIndicateActivity)
   }

   private func logout() {
       ...
       clearSubscriptions()
       ...
   }

   private func clearSubscriptions() {
       let subscriptions = realm.subscriptions
       subscriptions.write {
           subscriptions.removeAll()
       }
   }
}

Conclusion

In this article, you’ve seen how to include Realm Flexible Sync in your mobile app. I’ve shown the code for Swift, but the approach would be the same when building apps with Kotlin, Javascript, or .NET.

This is a preview release and we want your feedback.

Realm Flexible Sync will evolve to include more query and permission operators. Up next, we’re looking to expose array operators (that would allow me to add tighter restrictions on who can ask to read which chat messages). We’ll also enable querying on embedded documents.

Another feature I’d like to see is to limit which fields from a document get synced to a given user. This could allow the removal of the Chatster collection, as it’s only there to provide a read-only view of a subset of User fields to other users.

Want to suggest an enhancement or up-vote an existing request? The most effective way is through our feedback portal.

Got questions? Ask them in our Community forum.





Realm-Swift Type Projections

Introduction

Realm natively provides a broad set of data types, including Bool, Int, Float, Double, String, Date, ObjectID, List, Mutable Set, enum, Map, …

But, there are other data types that many of your iOS apps are likely to use. As an example, if you’re using Core Graphics, then it’s hard to get away without using types such as CGFloat, CGPoint, etc. When working with SwiftUI, you use the Color struct when working with colors.

A typical design pattern is to persist data using types natively supported by Realm, and then use a richer set of types in your app. When reading data, you add extra boilerplate code to convert them to your app’s types. When persisting data, you add more boilerplate code to convert your data back into types supported by Realm.

That works fine and gives you complete control over the type conversions. The downside is that you can end up with dozens of places in your code where you need to make the conversion.

Type projections still give you total control over how to map a CGPoint into something that can be persisted in Realm. But, you write the conversion code just once and then forget about it. The Realm-Swift SDK will then ensure that types are converted back and forth as required in the rest of your app.

The Realm-Swift SDK enables this by adding two new protocols that you can use to extend any Swift type. You opt whether to implement CustomPersistable or the version that’s allowed to fail (FailableCustomPersistable):

protocol CustomPersistable {
   associatedtype PersistedType
   init(persisted: PersistedType)
   var persistableValue: PersistedType { get }
}
protocol FailableCustomPersistable {
   associatedtype PersistedType
   init?(persisted: PersistedType)
   var persistableValue: PersistedType { get }
}

In this post, I’ll show how the Realm-Drawing app uses type projections to interface between Realm and Core Graphics.

Prerequisites

The Realm-Drawing App

Realm-Drawing is a simple, collaborative drawing app. If two people log into the app using the same username, they can work on a drawing together. All strokes making up the drawing are persisted to Realm and then synced to all other instances of the app where the same username is logged in.

Animated screen captures showing two iOS devices working on the same drawing

It’s currently iOS-only, but it would also sync with any Android drawing app that is connected to the same Realm back end.

Using Type Projections in the App

The Realm-Drawing iOS app uses three types that aren’t natively supported by Realm:

  • CGFloat
  • CGPoint
  • Color (SwiftUI)

In this section, you’ll see how simple it is to use type projections to convert them into types that can be persisted to Realm and synced.

Realm Schema (The Model)

An individual drawing is represented by a single Drawing object:

class Drawing: Object, ObjectKeyIdentifiable {
   @Persisted(primaryKey: true) var _id: ObjectId
   @Persisted var name = UUID().uuidString
   @Persisted var lines = RealmSwift.List<Line>()
}

A Drawing contains a List of Line objects:

class Line: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var lineColor: Color
   @Persisted var lineWidth: CGFloat = 5.0
   @Persisted var linePoints = RealmSwift.List<CGPoint>()
}

It’s the Line class that uses the non-Realm-native types.

Let’s see how each type is handled.

CGFloat

I extend CGFloat to conform to Realm-Swift’s CustomPersistable protocol. All I needed to provide was:

  • An initializer to convert what’s persisted in Realm (a Double) into the CGFloat used by the model
  • A method to convert a CGFloat into a Double:
extension CGFloat: CustomPersistable {
   public typealias PersistedType = Double
   public init(persistedValue: Double) { self.init(persistedValue) }
   public var persistableValue: Double { Double(self) }
}

The view can then use lineWidth from the model object without worrying about how it’s converted by the Realm SDK:

context.stroke(path, with: .color(line.lineColor),
   style: StrokeStyle(
       lineWidth: line.lineWidth,
       lineCap: .round, l
       ineJoin: .round
   )
)

CGPoint

CGPoint is a little trickier, as it can’t just be cast into a Realm-native type. CGPoint contains the x and y coordinates for a point, and so, I create a Realm-friendly class (PersistablePoint) that stores just that—x and y values as Doubles:

public class PersistablePoint: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var x = 0.0
   @Persisted var y = 0.0

   convenience init(_ point: CGPoint) {
       self.init()
       self.x = point.x
       self.y = point.y
   }
}

I implement the CustomPersistable protocol for CGPoint by mapping between a CGPoint and the x and y coordinates within a PersistablePoint:

extension CGPoint: CustomPersistable {
   public typealias PersistedType = PersistablePoint   
   public init(persistedValue: PersistablePoint) { self.init(x: persistedValue.x, y: persistedValue.y) }
   public var persistableValue: PersistablePoint { PersistablePoint(self) }
}

SwiftUI.Color

Color is made up of the three RGB components plus the opacity. I use the PersistableColor class to persist a representation of Color:

public class PersistableColor: EmbeddedObject {
   @Persisted var red: Double = 0
   @Persisted var green: Double = 0
   @Persisted var blue: Double = 0
   @Persisted var opacity: Double = 0

   convenience init(color: Color) {
       self.init()
       if let components = color.cgColor?.components {
           if components.count >= 3 {
               red = components[0]
               green = components[1]
               blue = components[2]
           }
           if components.count >= 4 {
               opacity = components[3]
           }
       }
   }
}

The extension to implement CustomPersistable for Color provides methods to initialize Color from a PersistableColor, and to generate a PersistableColor from itself:

extension Color: CustomPersistable {
   public typealias PersistedType = PersistableColor

   public init(persistedValue: PersistableColor) { self.init(
       .sRGB,
       red: persistedValue.red,
       green: persistedValue.green,
       blue: persistedValue.blue,
       opacity: persistedValue.opacity) }

   public var persistableValue: PersistableColor {
       PersistableColor(color: self)
   }
}

The view can then use selectedColor from the model object without worrying about how it’s converted by the Realm SDK:

context.stroke(
   path,
   with: .color(line.lineColor),
   style: StrokeStyle(lineWidth:
   line.lineWidth,
   lineCap: .round,
   lineJoin: .round)
)

Conclusion

Type projections provide a simple, elegant way to convert any type to types that can be persisted and synced by Realm.

It’s your responsibility to define how the mapping is implemented. After that, the Realm SDK takes care of everything else.

Please provide feedback and ask any questions in the Realm Community Forum.





Introducing Flexible Sync (Preview) – The Next Iteration of Realm Sync

We are excited to announce the public preview of our next version of Realm Sync: Flexible Sync. This new method of syncing puts the power into the hands of the developer. Now, developers can get more granular control over the data synced to user applications with intuitive language-native queries and hierarchical permissions.

Introduction

Prior to launching the general availability of Realm Sync in February 2021, the Realm team spent countless hours with developers learning how they build best-in-class mobile applications. A common theme emerged—building real-time, offline-first mobile apps require an overwhelming amount of complex, non-differentiating work.

Our first version of Realm Sync addressed this pain by abstracting away offline-first, real-time syncing functionality using declarative APIs. It expedited the time-to-market for many developers and worked well for apps where data is static and compartmentalized, or where permissions rarely need to change. But for dynamic apps and complex use cases, developers still had to spend time creating workarounds instead of developing new features. With that in mind, we built the next iteration of Realm Sync: Flexible Sync. Flexible Sync is designed to help developers:

  • Get to market faster: Use intuitive, language-native queries to define the data synced to user applications instead of proprietary concepts.
  • Optimize real-time collaboration between users: Utilize object-level conflict-resolution logic.
  • Simplify permissions: Apply role-based logic to applications with an expressive permissions system that groups users into roles on a pe-class or collection basis.

Language-Native Querying

Flexible Sync’s query-based sync logic is distinctly different from how Realm Sync operates today. The new structure is designed to more closely mirror how developers are used to building sync today—typically using GET requests with query parameters.

One of the primary benefits of Flexible Sync is that it eliminates all the time developers spend determining what query parameters to pass to an endpoint. Instead, the Realm APIs directly integrate with the native querying system on the developer’s choice of platform—for example, a predicate-based query language for iOS, a Fluent query for Android, a string-based query for Javascript, and a LINQ query for .NET.

Under the hood, the Realm Sync thread sends the query to MongoDB Realm (Realm’s cloud offering). MongoDB Realm translates the query to MongoDB’s query language and executes the query against MongoDB Atlas. Atlas then returns the resulting documents. Those documents are then translated into Realm objects, sent down to the Realm client, and stored on disk. The Realm Sync thread keeps a queue of any changes made locally to synced objects—even when offline. As soon as connectivity is reestablished, any changes made to the server-side or client-side are synced down using built-in granular conflict resolution logic. All of this occurs behind the scenes while the developer is interacting with the data. This is the part we’ve heard our users describe as “magic.”

Flexible Sync also enables much more dynamic queries, based on user inputs. Picture a home listing app that allows users to search available properties in a certain area. As users define inputs—only show houses in Dallas, TX that cost less than $300k and have at least three bedrooms—the query parameters can be combined with logical ANDs and ORs to produce increasingly complex queries, and narrow down the search result even further. All query results are combined into a single realm file on the client’s device, which significantly simplifies code required on the client-side and ensures changes to data are synced efficiently and in real time.

Swift

// Set your Schema
class Listing: Object {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var location: String
    @Persisted var price: Int
    @Persisted var bedrooms: Int
}

// Configure your App and login
let app = App(id: "XXXX")
let user = try! await app.login(credentials:
            .emailPassword(email: "email", password: "password"))

// Set the new Flexible Sync Config and open the Realm
let config = user.flexibleSyncConfiguration()
let realm = try! await Realm(configuration: config, downloadBeforeOpen: .always)

// Create a Query and Add it to your Subscriptions
let subscriptions = realm.subscriptions

try! await subscriptions.write {
    subscriptions.append(QuerySubscription<Listing>(name: "home-search") {
        $0.location == "dallas" && $0.price < 300000 && $0.bedrooms >= 3
    })
}

// Now query the local realm and get your home listings - output is 100 listings
// in the results
print(realm.objects(Listing.self).count)

// Remove the subscription - the data is removed from the local device but stays
// on the server
try! await subscriptions.write {
    subscriptions.remove(named: "home-search")
}

// Output is 0 - listings have been removed locally
print(realm.objects(Listing.self).count)

Kotlin

// Set your Schema
open class Listing: ObjectRealm() {
  @PrimaryKey
  @RealmField("_id")
  var id: ObjectId
  var location: String = ""
  var price: Int = 0
  var bedrooms: Int = 0
}

// Configure your App and login
val app = App("<YOUR_APP_ID_HERE>")
val user = app.login(Credentials.emailPassword("email", "password"))

// Set the new Flexible Sync Config and open the Realm
let config = SyncConfiguration.defaultConfig(user)
let realm = Realm.getInstance(config)

// Create a Query and Add it to your Subscriptions
val subscriptions = realm.subscriptions
subscriptions.update { mutableSubscriptions ->
   val sub = Subscription.create(
      "home-search", 
      realm.where<Listing>()
         .equalTo("location", "dallas")
         .lessThan("price", 300_000)
         .greaterThanOrEqual("bedrooms", 3)
   )
   mutableSubscriptions.add(subscription)
}

// Wait for server to accept the new subscription and download data
subscriptions.waitForSynchronization()
realm.refresh()

// Now query the local realm and get your home listings - output is 100 listings 
// in the results
val homes = realm.where<Listing>().count()

// Remove the subscription - the data is removed from the local device but stays 
// on the server
subscriptions.update { mutableSubscriptions ->
   mutableSubscriptions.remove("home-search")
}
subscriptions.waitForSynchronization()
realm.refresh()

// Output is 0 - listings have been removed locally
val homes = realm.where<Listing>().count()

.NET

// Set your Schema
class Listing: RealmObject
{
    [PrimaryKey, MapTo("_id")]
    public ObjectId Id { get; set; }
    public string Location { get; set; }
    public int Price { get; set; }
    public int Bedrooms { get; set; }
}

// Configure your App and login
var app = App.Create(YOUR_APP_ID_HERE);
var user = await app.LogInAsync(Credentials.EmailPassword("email", "password"));

// Set the new Flexible Sync Config and open the Realm
var config = new FlexibleSyncConfiguration(user);
var realm = await Realm.GetInstanceAsync(config);

// Create a Query and Add it to your Subscriptions
var dallasQuery = realm.All<Listing>().Where(l => l.Location == "dallas" && l.Price < 300_000 && l.Bedrooms >= 3);
realm.Subscriptions.Update(() =>
{
    realm.Subscriptions.Add(dallasQuery);
});

await realm.Subscriptions.WaitForSynchronizationAsync();

// Now query the local realm and get your home listings - output is 100 listings
// in the results
var numberOfListings = realm.All<Listing>().Count();

// Remove the subscription - the data is removed from the local device but stays
// on the server

realm.Subscriptions.Update(() =>
{
    realm.Subscriptions.Remove(dallasQuery);
});

await realm.Subscriptions.WaitForSynchronizationAsync();

// Output is 0 - listings have been removed locally
numberOfListings = realm.All<Listing>().Count();

JavaScript

import Realm from "realm";

// Set your Schema
const ListingSchema = {
  name: "Listing",
  primaryKey: "_id",
  properties: {
    _id: "objectId",
    location: "string",
    price: "int",
    bedrooms: "int",
  },
};

// Configure your App and login
const app = new Realm.App({ id: YOUR_APP_ID_HERE });
const credentials = Realm.Credentials.emailPassword("email", "password");
const user = await app.logIn(credentials);

// Set the new Flexible Sync Config and open the Realm
const realm = await Realm.open({
  schema: [ListingSchema],
  sync: { user, flexible: true },
});

// Create a Query and Add it to your Subscriptions
await realm.subscriptions.update((mutableSubscriptions) => {
  mutableSubscriptions.add(
    realm
      .objects(ListingSchema.name)
      .filtered("location = 'dallas' && price < 300000 && bedrooms = 3", {
        name: "home-search",
      })
  );
});

// Now query the local realm and get your home listings - output is 100 listings
// in the results
let homes = realm.objects(ListingSchema.name).length;

// Remove the subscription - the data is removed from the local device but stays
// on the server
await realm.subscriptions.update((mutableSubscriptions) => {
  mutableSubscriptions.removeByName("home-search");
});

// Output is 0 - listings have been removed locally
homes = realm.objects(ListingSchema.name).length;

Optimizing for Real-Time Collaboration

Flexible Sync also enhances query performance and optimizes for real-time user collaboration by treating a single object or document as the smallest entity for synchronization. Flexible Sync allows for Sync Realms to more efficiently share data and for conflict resolution to incorporate changes faster and with less data transfer.

For example, you and a fellow employee are analyzing the remaining tasks for a week. Your coworker wants to see all of the time-intensive tasks remaining (workunits &gt; 5), and you want to see all the tasks you have left for the week (owner == ianward). Your queries will overlap where workunits &gt; 5 and owner == ianward. If your coworker notices one of your tasks is marked incorrectly as 7 workunits and changes the value to 6, you will see the change reflected on your device in real time. Under the hood, the merge algorithm will only sync the changed document instead of the entire set of query results increasing query performance.

Venn diagram showing that 2 different queries can share some of the same documents

Permissions

Whether it’s a company’s internal application or an app on the App Store, permissions are required in almost every application. That’s why we are excited by how seamless Flexible Sync makes applying a document-level permission model when syncing data—meaning synced documents can be limited based on a user’s role.

Consider how a sales organization uses a CRM application. An individual sales representative should only be able to access her own sales pipeline while her manager needs to be able to see the entire region’s sales pipeline. In Flexible Sync, a user’s role will be combined with the client-side query to determine the appropriate result set. For example, when the sales representative above wants to view her deals, she would send a query where opportunities.owner == "EmmaLullo" but when her manager wants to see all the opportunities for their entire team, they would query with opportunities.team == “West”. If a user sends a much more expansive query, such as querying for all opportunities, then the permissions system would only allow data to be synced for which the user had explicit access.

{
  "Opportunities": {
    "roles": [
        {
                name: "manager", 
                applyWhen: { "%%user.custom_data.isSalesManager": true},
                read: {"team": "%%user.custom_data.teamManager"}
                write: {"team": "%%user.custom_data.teamManager"}
            },
        {
                name: "salesperson",
                applyWhen: {},
                read: {"owner": "%%user.id"}
                write: {"owner": "%%user.id"}
        }
    ]
  },
{
  "Bookings": {
    "roles": [
        {
                name: "accounting", 
                applyWhen: { "%%user.custom_data.isAccounting": true},
                read: true,
                write: true
            },
        {
                name: "sales",
                applyWhen: {},
                read: {"%%user.custom_data.isSales": true},
                write: false
        }
    ]
  }

Looking Ahead

Ultimately, our goal with Flexible Sync is to deliver a sync service that can fit any use case or schema design pattern imaginable without custom code or workarounds. And while we are excited that Flexible Sync is now in preview, we’re nowhere near done.

The Realm Sync team is planning to bring you more query operators and permissions integrations over the course of 2022. Up next we are looking to expose array operators and enable querying on embedded documents, but really, we look to you, our users, to help us drive the roadmap. Submit your ideas and feature requests to our feedback portal and ask questions in our Community forum. Happy building!





Goodbye NSPredicate, hello Realm Swift Query API

Introduction

I’m not a fan of writing code using pseudo-English text strings. It’s a major context switch when you’ve been writing “native” code. Compilers don’t detect errors in the strings, whether syntax errors or mismatched types, leaving you to learn of your mistakes when your app crashes.

I spent more than seven years working at MySQL and Oracle, and still wasn’t comfortable writing anything but the simplest of SQL queries. I left to join MongoDB because I knew that the object/document model was the way that developers should work with their data. I also knew that idiomatic queries for each programming language were the way to go.

That’s why I was really excited when MongoDB acquired Realm—a leading mobile object database. You work with Realm objects in your native language (in this case, Swift) to manipulate your data.

However, there was one area that felt odd in Realm’s Swift SDK. You had to use NSPredicate when searching for Realm objects that match your criteria. NSPredicates are strings with variable substitution. 🤦‍♂️

NSPredicates are used when searching for data in Apple’s Core Data database, and so it was a reasonable design decision. It meant that iOS developers could reuse the skills they’d developed while working with Core Data.

But, I hate writing code as strings.

The good news is that the Realm SDK for Swift has added the option to use type-safe queries through the Realm Swift Query API. 🥳.

You now have the option whether to filter using NSPredicates:

let predicate = NSPredicate(format: "isSoft == %@", NSNumber(value: wantSoft)
let decisions = unfilteredDecisions.filter(predicate)

or with the new Realm Swift Query API:

let decisions = unfilteredDecisions.where { $0.isSoft == wantSoft }

In this article, I’m going to show you some examples of how to use the Realm Swift Query API. I’ll also show you an example where wrangling with NSPredicate strings has frustrated me.

Prerequisites

Using The Realm Swift Query API

I have a number of existing Realm iOS apps using NSPredicates. When I learnt of the new query API, the first thing I wanted to do was try to replace some of “legacy” queries. I’ll start by describing that experience, and then show what other type-safe queries are possible.

Replacing an NSPredicate

I’ll start with the example I gave in the introduction (and how the NSPredicate version had previously frustrated me).

I have an app to train you on what decisions to make in Black Jack (based on the cards you’ve been dealt and the card that the dealer is showing). There are three different decision matrices based on the cards you’ve been dealt:

  • Whether you have the option to split your hand (you’ve been dealt two cards with the same value)
  • Your hand is “soft” (you’ve been dealt an ace, which can take the value of either one or eleven)
  • Any other hand

All of the decision-data for the app is held in Decisions objects:

class Decisions: Object, ObjectKeyIdentifiable {
   @Persisted var decisions = List<DecisionList>()
   @Persisted var isSoft = false
   @Persisted var isSplit = false
   ...
}

SoftDecisionView needs to find the Decisions object where isSoft is set to true. That requires a simple NSPredicate:

struct SoftDecisionView: View {
   @ObservedResults(Decisions.self, filter: NSPredicate(format: "isSoft == YES")) var decisions
   ...
}

But, what if I’d mistyped the attribute name? There’s no Xcode auto-complete to help when writing code within a string, and this code builds with no errors or warnings:

struct SoftDecisionView: View {
   @ObservedResults(Decisions.self, filter: NSPredicate(format: "issoft == YES")) var decisions
   ...
}

When I run the code, it works initially. But, when I’m dealt a soft hand, I get this runtime crash:

Terminating app due to uncaught exception 'Invalid property name', reason: 'Property 'issoft' not found in object of type 'Decisions''

Rather than having a dedicated view for each of the three types of hand, I want to experiment with having a single view to handle all three.

SwiftUI doesn’t allow me to use variables (or even named constants) as part of the filter criteria for @ObservedResults. This is because the struct hasn’t been initialized until after the @ObservedResults is defined. To live within SwitfUIs constraints, the filtering is moved into the view’s body:

struct SoftDecisionView: View {
   @ObservedResults(Decisions.self) var unfilteredDecisions
   let isSoft = true

   var body: some View {
       let predicate = NSPredicate(format: "isSoft == %@", isSoft)
       let decisions = unfilteredDecisions.filter(predicate)
   ...
}

Again, this builds, but the app crashes as soon as I’m dealt a soft hand. This time, the error is much more cryptic:

Thread 1: EXC_BAD_ACCESS (code=1, address=0x1)

It turns out that, you need to convert the boolean value to an NSNumber before substituting it into the NSPredicate string:

struct SoftDecisionView: View {
   @ObservedResults(Decisions.self) var unfilteredDecisions


   let isSoft = true


   var body: some View {
       let predicate = NSPredicate(format: "isSoft == %@", NSNumber(value: isSoft))
       let decisions = unfilteredDecisions.filter(predicate)
   ...
}

Who knew? OK, StackOverflow did, but it took me quite a while to find the solution.

Hopefully, this gives you a feeling for why I don’t like writing strings in place of code.

This is the same code using the new (type-safe) Realm Swift Query API:

struct SoftDecisionView: View {
   @ObservedResults(Decisions.self) var unfilteredDecisions
   let isSoft = true

   var body: some View {
       let decisions = unfilteredDecisions.where { $0.isSoft == isSoft }
   ...
}

The code’s simpler, and (even better) Xcode won’t let me use the wrong field name or type—giving me this error before I even try running the code:

Xcode showing the error "Binary operator '==' cannot be applied to operands of type 'Query<Boo>‘ and ‘Int'” title=”Xcode showing the error Binary operator ‘==’ cannot be applied to operands of type ‘Query<Bool>‘ and ‘Int'” /></p>
<h3>Experimenting With Other Sample Queries</h3>
<p>In my <a href=RCurrency app, I was able to replace this NSPredicate-based code:

struct CurrencyRowContainerView: View {
   @ObservedResults(Rate.self) var rates
   let baseSymbol: String
   let symbol: String

   var rate: Rate? {
       NSPredicate(format: "query.from = %@ AND query.to = %@", baseSymbol, symbol)).first
   }
   ...
}

With this:

struct CurrencyRowContainerView: View {
   @ObservedResults(Rate.self) var rates
   let baseSymbol: String
   let symbol: String

   var rate: Rate? {
       rates.where { $0.query.from == baseSymbol && $0.query.to == symbol }.first
   }
   ...
}

Again, I find this more Swift-like, and bugs will get caught as I type/build rather than when the app crashes.

I’ll use this simple Task Object to show a few more example queries:

class Task: Object, ObjectKeyIdentifiable {
   @Persisted var name = ""
   @Persisted var isComplete = false
   @Persisted var assignee: String?
   @Persisted var priority = 0
   @Persisted var progressMinutes = 0
}

All in-progress tasks assigned to name:

let myStartedTasks = realm.objects(Task.self).where {
   ($0.progressMinutes > 0) && ($0.assignee == name)
}

All tasks where the priority is higher than minPriority:

let highPriorityTasks = realm.objects(Task.self).where {
   $0.priority >= minPriority
}

All tasks that have a priority that’s an integer between -1 and minPriority:

let lowPriorityTasks = realm.objects(Task.self).where {
   $0.priority.contains(-1...minPriority)
}

All tasks where the assignee name string includes namePart:

let tasksForName = realm.objects(Task.self).where {
   $0.assignee.contains(namePart)
}

Filtering on Sub-Objects

You may need to filter your Realm objects on values within their sub-objects. Those sub-object may be EmbeddedObjects or part of a List.

I’ll use the Project class to illustrate filtering on the attributes of sub-documents:

class Project: Object, ObjectKeyIdentifiable {
   @Persisted var name = ""
   @Persisted var tasks: List<Task>
}

All projects that include a task that’s in-progress, and is assigned to a given user:

let myActiveProjects = realm.objects(Project.self).where {
   ($0.tasks.progressMinutes >= 1) && ($0.tasks.assignee == name)
}

Including the Query When Creating the Original Results (SwiftUI)

At the time of writing, this feature wasn’t released, but it can be tested using this PR.

You can include the where modifier directly in your @ObservedResults call. That avoids the need to refine your results inside your view’s body:

@ObservedResults(Decisions.self, where: { $0.isSoft == true }) var decisions

Unfortunately, SwiftUI rules still mean that you can’t use variables or named constants in your where block for @ObservedResults.

Conclusion

Realm type-safe queries provide a simple, idiomatic way to filter results in Swift. If you have a bug in your query, it should be caught by Xcode rather than at run-time.

You can find more information in the docs. If you want to see hundreds of examples, and how they map to equivalent NSPredicate queries, then take a look at the test cases.

For those that prefer working with NSPredicates, you can continue to do so. In fact, the Realm Swift Query API runs on top of the NSPredicate functionality, so they’re not going anywhere soon.

Please provide feedback and ask any questions in the Realm Community Forum.





Making SwiftUI Previews Work For You

Introduction

Canvas previews are an in-your-face feature of SwiftUI. When you create a new view, half of the boilerplate code is for the preview. A third of your Xcode real estate is taken up by the preview.

Despite the prominence of the feature, many developers simply delete the preview code from their views and rely on the simulator.

In past releases of Xcode (including the Xcode 13 betas), a reluctance to use previews was understandable. They’d fail for no apparent reason, and the error messages were beyond cryptic.

I’ve stuck with previews from the start, but at times, they’ve felt like more effort than they’re worth. But, with Xcode 13, I think we should all be using them for all views. In particular, I’ve noticed:

  • They’re more reliable.
  • The error messages finally make sense.
  • Landscape mode is supported.

Xcode showing a blackjack app with multiple previews for different devices, orientations, and color schemes

I consider previews a little like UI unit tests for your views. Like with unit tests, there’s some extra upfront effort required, but you get a big payback in terms of productivity and quality.

In this article, I’m going to cover:

  • What you can check in your previews (think light/dark mode, different devices, landscape mode, etc.) and how to do it.
  • Reducing the amount of boilerplate code you need in your previews.
  • Writing previews for stateful apps. (I’ll be using Realm, but the same approach can be used with Core Data.)
  • Troubleshooting your previews.

One feature I won’t cover is using previews as a graphical way to edit views. One of the big draws of SwiftUI is writing everything in code rather than needing storyboards and XML files. Using a drag-and-drop view builder for SwiftUI doesn’t appeal to me.

95% of the examples I use in this article are based on a BlackJack training app. You can find the final version in the repo.

Prerequisites

  • Xcode 13+
  • iOS 15+
  • Realm-Cocoa 10.17.0+

Note:

  • I’ve used Xcode 13 and iOS 15, but most of the examples in this post will work with older versions.
  • Previewing in landscape mode is new in Xcode 13.
  • The buttonStyle modifier is only available in iOS 15.
  • I used Realm-Cocoa 10.17.0, but earlier 10.X versions are likely to work.

Working with previews

Previews let you see what your view looks like without running it in a simulator or physical device. When you edit the code for your view, its preview updates in real time.

This section shows what aspects you can preview, and how it’s done.

A super-simple preview

When you create a new Xcode project or SwiftUI view, Xcode adds the code for the preview automatically. All you need to do is press the “Resume” button (or CMD-Alt-P).

A simple Xcode preview which updates as the code is edited

The preview code always has the same structure, with the View that needs previewing (in this case, ContentView) within the previews View:

struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
       ContentView()
   }
}

Views that require parameters

Most of your views will require that the enclosing view pass in parameters. Your preview must do the same—you’ll get a build error if you forget.

My ResetButton view requires that the caller provides two values—label and resetType:

struct ResetButton: View {
   var label: String
   var resetType: ResetType
   ...
}

The preview code needs to pass in those values, just like any embedding view:

struct ResetButton_Previews: PreviewProvider {
   static var previews: some View {
       ResetButton(label: "Reset All Matrices",
                   resetType: .all)
   }
}

Views that require Bindings

In a chat app, I have a LoginView that updates the username binding that’s past from the enclosing view:

struct LoginView: View {  
   @Binding var username: String
   ...
}

The simplest way to create a binding in your preview is to use the constant function:

struct LoginView_Previews: PreviewProvider {
   static var previews: some View {
       LoginView(username: .constant("Billy"))
   }
}

NavigationViews

In your view hierarchy, you only add a NavigationView at a single level. That NavigationView then wraps all subviews.

When previewing those subviews, you may or may not care about the NavigationView functionality. For example, you’ll only see titles and buttons in the top nav bar if your preview wraps the view in a NavigationView.

If I preview my PracticeView without adding a NavigationView, then I don’t see the title:

Preview where there is no title on the screen

To preview the title, my preview code needs to wrap PracticeView in a NavigationView:

struct PracticeView_Previews: PreviewProvider {
   static var previews: some View {
       NavigationView {
           PracticeView()
       }
   }
}

Preview with title

Smaller views

Sometimes, you don’t need to preview your view in the context of a full device screen. My CardView displays a single playing card. Previewing it in a full device screen just wastes desk space:

Lots of preview space taken up for the iPhone, but the view we're testing only takes up a tiny proportion of the space

We can add the previewLayout modifier to indicate that we only want to preview an area large enough for the view. It often makes sense to add some padding as well:

struct CardView_Previews: PreviewProvider {
   static var previews: some View {
       CardView(card: Card(suit: .heart))
           .previewLayout(.sizeThatFits)
           .padding()
   }
}

Lots of screen space freed up by only previewing the view (rather than the entire iPhone screen

Light and dark modes

It can be quite a shock when you finally get around to testing your app in dark mode. If you’ve not thought about light/dark mode when implementing each of your views, then the result can be ugly, or even unusable.

Previews to the rescue!

Returning to CardView, I can preview a card in dark mode using the preferredColorScheme view modifier:

struct CardView_Previews: PreviewProvider {
   static var previews: some View {
       CardView(card: Card(suit: .heart))
           .preferredColorScheme(.dark)
           .previewLayout(.sizeThatFits)
           .padding()
   }
}

10 Hearts displaying correctly in front of a dark background

That seems fine, but what if I previewed a spade instead?

Black spade isn't visible in front of dark background

That could be a problem.

Adding a white background to the view fixes it:

King of spades now shows up as it sits in a white box

Preview multiple view instances

Sometimes, previewing a single instance of your view doesn’t paint the full picture. Just look at the surprise I got when enabling dark mode for my card view. Wouldn’t it be better to simultaneously preview both hearts and spades in both dark and light modes?

You can create multiple previews for the same view using the Group view:

struct CardView_Previews: PreviewProvider {
   static var previews: some View {
       Group {
           CardView(card: Card(suit: .heart))
           CardView(card: Card(suit: .spade))
           CardView(card: Card(suit: .heart))
               .preferredColorScheme(.dark)
           CardView(card: Card(suit: .spade))
               .preferredColorScheme(.dark)
       }
       .previewLayout(.sizeThatFits)
       .padding()
   }
}

Multiple cards, each with their own preview

Composing views in a preview

A preview of a single view in isolation might look fine, but what will they look like within a broader context?

Previewing a single DecisionCell view looks great:

struct DecisionCell_Previews: PreviewProvider {
   static var previews: some View {
       DecisionCell(
           decision: Decision(handValue: 6, dealerCardValue: .nine, action: .hit), myHandValue: 8, dealerCardValue: .five)
           .previewLayout(.sizeThatFits)
           .padding()
   }
}

A single, yellow Hit cell

But, the app will never display a single DecisionCell. They’ll always be in a grid. Also, the text, background color, and border vary according to state. To create a more realistic preview, I created some sample data within the view and then composed multiple DecisionCells using vertical and horizontal stacks:

struct DecisionCell_Previews: PreviewProvider {
   static var previews: some View {
       let decisions: [Decision] = [
           Decision(handValue: 6, dealerCardValue: .nine, action: .split),
           Decision(handValue: 6, dealerCardValue: .nine, action: .stand),
           Decision(handValue: 6, dealerCardValue: .nine, action: .double),
           Decision(handValue: 6, dealerCardValue: .nine, action: .hit)
       ]
       return Group {
           VStack(spacing: 0) {
               ForEach(decisions) { decision in
                   HStack (spacing: 0) {
                       DecisionCell(decision: decision, myHandValue: 8, dealerCardValue: .three)
                       DecisionCell(decision: decision, myHandValue: 6, dealerCardValue: .three)
                       DecisionCell(decision: decision, myHandValue: 8, dealerCardValue: .nine)
                       DecisionCell(decision: decision, myHandValue: 6, dealerCardValue: .nine)
                   }
               }
           }
           VStack(spacing: 0) {
               ForEach(decisions) { decision in
                   HStack (spacing: 0) {
                       DecisionCell(decision: decision, myHandValue: 8, dealerCardValue: .three)
                       DecisionCell(decision: decision, myHandValue: 6, dealerCardValue: .three)
                       DecisionCell(decision: decision, myHandValue: 8, dealerCardValue: .nine)
                       DecisionCell(decision: decision, myHandValue: 6, dealerCardValue: .nine)
                   }
               }
           }
           .preferredColorScheme(.dark)
       }
       .previewLayout(.sizeThatFits)
       .padding()
   }

I could then see that the black border didn’t work too well in dark mode:

Dark border around selected cells is lost in front of the dark background

Switching the border color from black to primary quickly fixed the issue:

White frame around selected cell is visible in front of dark background

Landscape mode

Previews default to portrait mode. Use the previewInterfaceOrientation modifier to preview in landscape mode instead:

struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
       ContentView()
           .previewInterfaceOrientation(.landscapeRight)
   }
}

iPhone app previewed in lanscape mode

Device type

Previews default to the simulator device that you’ve selected in Xcode. Chances are that you want your app to work well on multiple devices. Typically, I find that there’s extra work needed to make an app I designed for the iPhone work well on an iPad.

The previewDevice modifier lets us specify the device type to use in the preview:

struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
       ContentView()
           .previewDevice(PreviewDevice(rawValue: "iPad (9th generation)"))
   }
}

App previewed on an iPad

You can find the names of the available devices from Xcode’s simulator menu, or from the terminal using xcrun simctl list devices.

Pinning views

In the bottom-left corner of the preview area, there’s a pin button. Pressing this “pins” the current preview so that it’s still shown when you browse to the code for other views:

Pin icon in Xcode

This is useful to observe how a parent view changes as you edit the code for the child view:

Animation of the pinned view continuing to be shown and updates as the developer switches to other views in Xcode

Live previews

At the start of this article, I made a comparison between previews and unit testing. Live previews mean that you really can test your views in isolation (to be accurate, the view you’re testing plus all of the views it embeds or links to).

Press the play button above the preview to enter live mode:

Play button above Xcode preview

You can now interact with your view:

Preview responds to user actions

Getting rid of excess boilerplate preview code

As you may have noticed, some of my previews now have more code than the actual views. This isn’t necessarily a problem, but there’s a lot of repeated boilerplate code used by multiple views. Not only that, but you’ll be embedding the same boilerplate code into previews in other projects.

To streamline my preview code, I’ve created several view builders. They all follow the same pattern—receive a View and return a new View that’s built from that View.

I start the name of each view builder with _Preview to make it easy to take advantage of Xcode’s code completion feature.

Light/dark mode

_PreviewColorScheme returns a Group of copies of the view. One is in light mode, the other dark:

struct _PreviewColorScheme<Value: View>: View {
   private let viewToPreview: Value

   init(_ viewToPreview: Value) {
       self.viewToPreview = viewToPreview
   }

   var body: some View {
       Group {
           viewToPreview
           viewToPreview.preferredColorScheme(.dark)
       }
   }
}

To use this view builder in a preview, simply pass in the View you’re previewing:

struct CardView_Previews: PreviewProvider {
   static var previews: some View {
       _PreviewColorScheme(
           VStack {
               ForEach(Suit.allCases, id: \.rawValue) { suit in
                   CardView(card: Card(suit: suit))
               }
           }
               .padding()
               .previewLayout(.sizeThatFits)
       )
   }
}

multiple cards shown in the same preview

Orientation

_PreviewOrientation returns a Group containing the original View in portrait and landscape modes:

struct _PreviewOrientation<Value: View>: View {
   private let viewToPreview: Value

   init(_ viewToPreview: Value) {
       self.viewToPreview = viewToPreview
   }

   var body: some View {
       Group {
           viewToPreview
           viewToPreview.previewInterfaceOrientation(.landscapeRight)
       }
   }
}

To use this view builder in a preview, simply pass in the View you’re previewing:

struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
       _PreviewOrientation(
           ContentView()
       )
   }
}

Preview showing the same view in portrait and lanscape modes

No device

_PreviewNoDevice returns a view built from adding the previewLayout modifier and adding `padding to the input view:

struct _PreviewNoDevice<Value: View>: View {
   private let viewToPreview: Value

   init(_ viewToPreview: Value) {
       self.viewToPreview = viewToPreview
   }

   var body: some View {
       Group {
           viewToPreview
               .previewLayout(.sizeThatFits)
               .padding()
       }
   }
}

To use this view builder in a preview, simply pass in the View you’re previewing:

struct CardView_Previews: PreviewProvider {
   static var previews: some View {
       _PreviewNoDevice(
           CardView(card: Card())
       )
   }
}

9 Clubs previewed in a small window

Multiple devices

_PreviewDevices returns a Group containing a copy of the View for each device type. You can modify devices in the code to include the devices you want to see previews for:

struct _PreviewDevices<Value: View>: View {
   let devices = [
       "iPhone 13 Pro Max",
       "iPhone 13 mini",
       "iPad (9th generation)"
   ]

   private let viewToPreview: Value

   init(_ viewToPreview: Value) {
       self.viewToPreview = viewToPreview
   }

   var body: some View {
       Group {
           ForEach(devices, id: \.self) { device in
               viewToPreview
                   .previewDevice(PreviewDevice(rawValue: device))
                   .previewDisplayName(device)
           }
       }
   }
}

I’d be cautious about adding too many devices as it will make any previews using this view builder slow down and consume resources.

To use this view builder in a preview, simply pass in the View you’re previewing:

struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
       _PreviewDevices(
           ContentView()
       )
   }
}

The same view previewed on 3 different device types

Combining multiple view builders

Each view builder receives a view and returns a new view. That means that you can compose the functions by passing the results of one view builder to another. In the extreme case, you can use up to three on the same view preview:

struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
       _PreviewOrientation(
           _PreviewColorScheme(
               _PreviewDevices(ContentView())
           )
       )
   }
}

This produces 12 views to cover all permutations of orientation, appearance, and device.

For each view, you should consider which modifiers add value. For the CardView, it makes sense to use _PreviewNoDevice and _PreviewColorScheme, but previewing on different devices and orientations wouldn’t add any value.

Previewing stateful views (Realm)

Often, a SwiftUI view will fetch state from a database such as Realm or Core Data. For that to work, there needs to be data in that database.

Previews are effectively running on embedded iOS simulators. That helps explain how they are both slower and more powerful than you might expect from a “preview” feature. That also means that each preview also contains a Realm database (assuming that you’re using the Realm-Cocoa SDK). The preview can store data in that database, and the view can access that data.

In the BlackJack training app, the action to take for each player/dealer hand combination is stored in Realm. For example, DefaultDecisionView uses @ObservedResults to access data from Realm:

struct DefaultDecisionView: View {
   @ObservedResults(Decisions.self,
                    filter: NSPredicate(format: "isSoft == NO AND isSplit == NO")) var decisions

To ensure that there’s data for the previewed view to find, the preview checks whether the Realm database already contains data (Decisions.areDecisionsPopulated). If not, then it adds the required data (Decisions.bootstrapDecisions()):

struct DefaultDecisionView_Previews: PreviewProvider {
   static var previews: some View {
       if !Decisions.areDecisionsPopulated {
           Decisions.bootstrapDecisions()
       }
       return _PreviewOrientation(
           _PreviewColorScheme(
               Group {
                   NavigationView {
                       DefaultDecisionView(myHandValue: 6, dealerCardValue: .nine)
                   }
                   NavigationView {
                       DefaultDecisionView(myHandValue: 6, dealerCardValue: .nine, editable: true)
                   }
               }
                   .navigationViewStyle(StackNavigationViewStyle())
           )
       )
   }
}

DefaultDecisionView is embedded in DecisionMatrixView and so the preview for DecisionMatrixView must also conditionally populate the Realm data. In turn, DecisionMatrixView is embedded in PracticeView, and PracticeView in ContentView—and so, they too need to bootstrap the Realm data so that it’s available further down the view hierarchy.

This is the implementation of the bootstrap functions:

extension Decisions {
   static var areDecisionsPopulated: Bool {
       do {
           let realm = try Realm()
           let decisionObjects = realm.objects(Decisions.self)
           return decisionObjects.count >= 3
       } catch {
           print("Error, couldn't read decision objects from Realm: \(error.localizedDescription)")
           return false
       }
   }

   static func bootstrapDecisions() {
       do {
           let realm = try Realm()
           let defaultDecisions = Decisions()
           let softDecisions = Decisions()
           let splitDecisions = Decisions()

           defaultDecisions.bootstrap(defaults: defaultDefaultDecisions, handType: .normal)
           softDecisions.bootstrap(defaults: defaultSoftDecisions, handType: .soft)
           splitDecisions.bootstrap(defaults: defaultSplitDecisions, handType: .split)
           try realm.write {
               realm.delete(realm.objects(Decision.self))
               realm.delete(realm.objects(Decisions.self))
               realm.delete(realm.objects(Decision.self))
               realm.delete(realm.objects(Decisions.self))
               realm.add(defaultDecisions)
               realm.add(softDecisions)
               realm.add(splitDecisions)
           }
       } catch {
           print("Error, couldn't read decision objects from Realm: \(error.localizedDescription)")
       }
   }
}

Partitioned, synced realms

The BlackJack training app uses a standalone Realm database. But what happens if the app is using Realm Sync?

One option could be to have the SwiftUI preview sync data with your backend Realm service. I think that’s a bit too complex, and it breaks my paradigm of treating previews like unit tests for views.

I’ve found that the simplest solution is to make the view aware of whether it’s been created by a preview or by a running app. I’ll explain how that works.

AuthorView from the RChat app fetches data from Realm:

struct AuthorView: View {
   @ObservedResults(Chatster.self) var chatsters
   ...
}

Its preview code bootstraps the embedded realm:

struct AuthorView_Previews: PreviewProvider {
   static var previews: some View {
       Realm.bootstrap()


       return AppearancePreviews(AuthorView(userName: "rod@contoso.com"))
           .previewLayout(.sizeThatFits)
           .padding()
   }
}

The app adds bootstrap as an extension to Realm:

extension Realm: Samplable {
   static func bootstrap() {
       do {
           let realm = try Realm()
           try realm.write {
               realm.deleteAll()
               realm.add(Chatster.samples)
               realm.add(User(User.sample))
               realm.add(ChatMessage.samples)
           }
       } catch {
           print("Failed to bootstrap the default realm")
       }
   }
}

A complication is that AuthorView is embedded in ChatBubbleView. For the app to work, ChatBubbleView must pass the synced realm configuration to AuthorView:

AuthorView(userName: authorName)
   .environment(\.realmConfiguration,
                   app.currentUser!.configuration(
                   partitionValue: "all-users=all-the-users"))

But, when previewing ChatBubbleView, we want AuthorView to use the preview’s local, embedded realm (not to be dependent on a Realm back-end app). That means that ChatBubbleView must check whether or not it’s running as part of a preview:

struct ChatBubbleView: View {
   ...
   var isPreview = false
   ...
   var body: some View {
       ...
       if isPreview {
           AuthorView(userName: authorName)
       } else {
           AuthorView(userName: authorName)
               .environment(\.realmConfiguration,
                   app.currentUser!.configuration(
                       partitionValue: "all-users=all-the-users"))
       }
       ...
   }
}

The preview is then responsible for bootstrapping the local realm and flagging to ChatBubbleView that it’s a preview:

struct ChatBubbleView_Previews: PreviewProvider {
   static var previews: some View {
       Realm.bootstrap()
       return ChatBubbleView(
           chatMessage: .sample,
           authorName: "jane",
           isPreview: true)
   }
}

Troubleshooting your previews

As mentioned at the beginning of this article, the error messages for failed previews are actually useful in Xcode 13.

That’s the good news.

The bad news is that you still can’t use breakpoints or print to the console.

One mitigation is that the previews static var in your preview is a View. That means that you can replace the body of your ContentView with your previews code. You can then run the app in a simulator and add breakpoints or print to the console. It feels odd to use this approach, but I haven’t found a better option yet.

Conclusion

I’ve had a mixed relationship with SwiftUI previews.

When they work, they’re a great tool, making it quicker to write your views. Previews allow you to unit test your views. Previews help you avoid issues when your app is running in dark or landscape mode or on different devices.

But, they require effort to build. Prior to Xcode 13, it would be tough to justify that effort because of reliability issues.

I believe that Xcode 13 is the tipping point where the efficiency and quality gains far outweigh the effort of writing preview code. That’s why I’ve written this article now.

In this article, you’ve seen a number of tips to make previews as useful as possible. I’ve provided four view builders that you can copy directly into your SwiftUI projects, letting you build the best previews with the minimum of code. Finally, you’ve seen how you can write previews for views that work with data held in a database such as Realm or Core Data.

Please provide feedback and ask any questions in the Realm Community Forum.





Most Useful iOS 15 SwiftUI Features

Introduction

I’m all-in on using SwiftUI to build iOS apps. I find it so much simpler than wrangling with storyboards and UIKit. Unfortunately, there are still occasions when SwiftUI doesn’t let you do what you need—forcing you to break out into UIKit.

That’s why I always focus on Apple’s SwiftUI enhancements at each year’s WWDC. And, each year I’m rewarded with a few more enhancements that make SwiftUI more powerful and easy to work with. For example, iOS14 made it much easier to work with Apple Maps.

WWDC 2021 was no exception, introducing a raft of SwiftUI enhancements that were coming in iOS 15/ SwiftUI 3 / Xcode 13. As iOS 15 has now been released, it feels like a good time to cover the features that I’ve found the most useful.

I’ve revisited some of my existing iOS apps to see how I could exploit the new iOS 15 SwiftUI features to improve the user experience and/or simplify my code base. This article steps through the features I found most interesting/useful, and how I tested them out on my apps. These are the apps/branches that I worked with:

Prerequisites

  • Xcode 13
  • iOS 15
  • Realm-Cocoa (varies by app, but 10.13.0+ is safe for them all)

Lists

SwiftUI Lists are pretty critical to data-based apps. I use Lists in almost every iOS app I build, typically to represent objects stored in Realm. That’s why I always go there first when seeing what’s new.

Custom Swipe Options

We’ve all used mobile apps where you swipe an item to the left for one action, and to the right for another. SwiftUI had a glaring omission—the only supported action was to swipe left to delete an item.

This was a massive pain.

This limitation meant that my task-tracker-swiftui app had a cumbersome UI. You had to click on a task to expose a sheet that let you click on your preferred action.

With iOS 15, I can replace that popup sheet with swipe actions:

iOS app showing that action buttons are revealed when swiping a list item to the left or right

The swipe actions are implemented in TasksView:

List {
   ForEach(tasks) { task in
       TaskView(task: task)
           .swipeActions(edge: .leading) {
               if task.statusEnum == .Open || task.statusEnum == .InProgress {
                   CompleteButton(task: task)
               }
               if task.statusEnum == .Open || task.statusEnum == .Complete {
                   InProgressButton(task: task)
               }
               if task.statusEnum == .InProgress || task.statusEnum == .Complete {
                   NotStartedButton(task: task)
               }
           }
           .swipeActions(edge: .trailing) {
               Button(role: .destructive, action: { $tasks.remove(task) }) {
                   Label("Delete", systemImage: "trash")
               }
           }
   }
}

The role of the delete button is set to .destructive which automatically sets the color to red.

For the other actions, I created custom buttons. For example, this is the code for CompleteButton:

struct CompleteButton: View {
   @ObservedRealmObject var task: Task

   var body: some View {
       Button(action: { $task.statusEnum.wrappedValue = .Complete }) {
           Label("Complete", systemImage: "checkmark")
       }
       .tint(.green)
   }
}

Searchable Lists

When you’re presented with a long list of options, it helps the user if you offer a way to filter the results.

RCurrency lets the user choose between 150 different currencies. Forcing the user to scroll through the whole list wouldn’t make for a good experience. A search bar lets them quickly jump to the items they care about:

Animation showing currencies being filtered as a user types into the search box

The selection of the currency is implemented in the SymbolPickerView view.

The view includes a state variable to store the searchText (the characters that the user has typed) and a searchResults computed value that uses it to filter the full list of symbols:

struct SymbolPickerView: View {
   ...
   @State private var searchText = ""
   ...
   var searchResults: Dictionary<String, String> {
       if searchText.isEmpty {
           return Symbols.data.symbols
       } else {
           return Symbols.data.symbols.filter {
               $0.key.contains(searchText.uppercased()) || $0.value.contains(searchText)}
       }
   }
}

The List then loops over those searchResults. We add the .searchable modifier to add the search bar, and bind it to the searchText state variable:

List {
   ForEach(searchResults.sorted(by: <), id: \.key) { symbol in
       ...
   }
}
.searchable(text: $searchText)

This is the full view:

struct SymbolPickerView: View {
   @Environment(\.presentationMode) var presentationMode

   var action: (String) -> Void
   let existingSymbols: [String]

   @State private var searchText = ""

   var body: some View {
       List {
           ForEach(searchResults.sorted(by: <), id: \.key) { symbol in
               Button(action: {
                   pickedSymbol(symbol.key)
               }) {
                   HStack {
                       Image(symbol.key.lowercased())
                       Text("\(symbol.key): \(symbol.value)")
                   }
                   .foregroundColor(existingSymbols.contains(symbol.key) ? .secondary : .primary)
               }
               .disabled(existingSymbols.contains(symbol.key))
           }
       }
       .searchable(text: $searchText)
       .navigationBarTitle("Pick Currency", displayMode: .inline)
   }

   private func pickedSymbol(_ symbol: String) {
       action(symbol)
       presentationMode.wrappedValue.dismiss()
   }

   var searchResults: Dictionary<String, String> {
       if searchText.isEmpty {
           return Symbols.data.symbols
       } else {
           return Symbols.data.symbols.filter {
               $0.key.contains(searchText.uppercased()) || $0.value.contains(searchText)}
       }
   }
}

Pull to Refresh

We’ve all used this feature in iOS apps. You’re impatiently waiting on an important email, and so you drag your thumb down the page to get the app to check the server.

This feature isn’t always helpful for apps that use Realm and Realm Sync. When Realm cloud data changes, the local realm is updated, and your SwiftUI view automatically refreshes to show the new data.

However, the feature is useful for the RCurrency app. I can use it to refresh all of the locally-stored exchange rates with fresh data from the API:

Animation showing currencies being refreshed when the screen is dragged dowm

We allow the user to trigger the refresh by adding a .refreshable modifier and action (refreshAll) to the list of currencies in CurrencyListContainerView:

List {
   ForEach(userSymbols.symbols, id: \.self) { symbol in
       CurrencyRowContainerView(baseSymbol: userSymbols.baseSymbol,
                                   baseAmount: $baseAmount,
                                   symbol: symbol,
                                   refreshNeeded: refreshNeeded)
           .listRowSeparator(.hidden)
   }
   .onDelete(perform: deleteSymbol)
}
.refreshable{ refreshAll() }

In that code snippet, you can see that I added the .listRowSeparator(.hidden) modifier to the List. This is another iOS 15 feature that hides the line that would otherwise be displayed between each List item. Not a big feature, but every little bit helps in letting us use native SwiftUI to get the exact design we want.

Text

Markdown

I’m a big fan of Markdown. Markdown lets you write formatted text (including tables, links, and images) without taking your hands off the keyboard. I added this post to our CMS in markdown.

iOS 15 allows you to render markdown text within a Text view. If you pass a literal link to a Text view, then it’s automatically rendered correctly:

struct MarkDownTest: View {
   var body: some View {
       Text("Let's see some **bold**, *italics* and some ***bold italic text***. ~~Strike that~~. We can even include a [link](https://realm.io).")
   }
}

Text formatted. Included bold, italics and a link

But, it doesn’t work out of the box for string constants or variables (e.g., data read from Realm):

struct MarkDownTest: View {
   let myString = "Let's see some **bold**, *italics* and some ***bold italic text***. ~~Strike that~~. We can even include a [link](https://realm.io)."

   var body: some View {
       Text(myString)
   }
}

Raw Markdown source code, rather than rendered text

The issue is that the version of Text that renders markdown expects to be passed an AttributedString. I created this simple Markdown view to handle this for us:

struct MarkDown: View {
   let text: String

   @State private var formattedText: AttributedString?

   var body: some View {
       Group {
           if let formattedText = formattedText {
               Text(formattedText)
           } else {
               Text(text)
           }
       }
       .onAppear(perform: formatText)
   }

   private func formatText() {
       do {
           try formattedText = AttributedString(markdown: text)
       } catch {
           print("Couldn't convert this from markdown: \(text)")
       }
   }
}

I updated the ChatBubbleView in RChat to use the Markdown view:

if chatMessage.text != "" {
   MarkDown(text: chatMessage.text)
   .padding(Dimensions.padding)
}

RChat now supports markdown in user messages:

Animation showing that Markdown source is converted to formated text in the RChat app

Dates

We all know that working with dates can be a pain. At least in iOS 15 we get some nice new functionality to control how we display dates and times. We use the new Date.formatted syntax.

In RChat, I want the date/time information included in a chat bubble to depend on how recently the message was sent. If a message was sent less than a minute ago, then I care about the time to the nearest second. If it were sent a day ago, then I want to see the day of the week plus the hour and minutes. And so on.

I created a TextDate view to perform this conditional formatting:

struct TextDate: View {
   let date: Date

   private var isLessThanOneMinute: Bool { date.timeIntervalSinceNow > -60 }
   private var isLessThanOneDay: Bool { date.timeIntervalSinceNow > -60 * 60 * 24 }
   private var isLessThanOneWeek: Bool { date.timeIntervalSinceNow > -60 * 60 * 24 * 7}
   private var isLessThanOneYear: Bool { date.timeIntervalSinceNow > -60 * 60 * 24 * 365}

   var body: some View {
       if isLessThanOneMinute {
           Text(date.formatted(.dateTime.hour().minute().second()))
       } else {
           if isLessThanOneDay {
               Text(date.formatted(.dateTime.hour().minute()))
           } else {
               if isLessThanOneWeek {
                   Text(date.formatted(.dateTime.weekday(.wide).hour().minute()))
               } else {
                   if isLessThanOneYear {
                       Text(date.formatted(.dateTime.month().day()))
                   } else {
                       Text(date.formatted(.dateTime.year().month().day()))
                   }
               }
           }
       }
   }
}

This preview code lets me test it’s working in the Xcode Canvas preview:

struct TextDate_Previews: PreviewProvider {
   static var previews: some View {
       VStack {
           TextDate(date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 365)) // 1 year ago
           TextDate(date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 7))   // 1 week ago
           TextDate(date: Date(timeIntervalSinceNow: -60 * 60 * 24))       // 1 day ago
           TextDate(date: Date(timeIntervalSinceNow: -60 * 60))            // 1 hour ago
           TextDate(date: Date(timeIntervalSinceNow: -60))                 // 1 minute ago
           TextDate(date: Date())                                          // Now
       }
   }
}

Screen capture of dates rendered in various formatt

We can then use TextDate in RChat’s ChatBubbleView to add context-sensitive date and time information:

TextDate(date: chatMessage.timestamp)
   .font(.caption)

Screen capture of properly formatted dates against each chat message in the RChat app

Keyboards

Customizing keyboards and form input was a real pain in the early days of SwiftUI—take a look at the work we did for the WildAid O-FISH app if you don’t believe me. Thankfully, iOS 15 has shown some love in this area. There are a couple of features that I could see an immediate use for…

Submit Labels

It’s now trivial to rename the on-screen keyboard’s “return” key. It sounds trivial, but it can give the user a big hint about what will happen if they press it.

To rename the return key, add a .submitLabel modifier to the input field. You pass the modifier one of these values:

  • done
  • go
  • send
  • join
  • route
  • search
  • return
  • next
  • continue

I decided to use these labels to improve the login flow for the LiveTutorial2021 app. In LoginView, I added a submitLabel to both the “email address” and “password” TextFields:

TextField("email address", text: $email)
   .submitLabel(.next)
SecureField("password", text: $password)
   .onSubmit(userAction)
   .submitLabel(.go)

Screen capture showing that the "return" key is replaced with "next" when editing the email/username field

Screen capture showing that the "return" key is replaced with "go" when editing the password field

Note the .onSubmit(userAction) modifier on the password field. If the user taps “go” (or hits return on an external keyboard), then the userAction function is called. userAction either registers or logs in the user, depending on whether “Register new user” is checked.

Focus

It can be tedious to have to click between different fields on a form. iOS 15 makes it simple to automate that shifting focus.

Sticking with LiveTutorial2021, I want the “email address” field to be selected when the view opens. When the user types their address and hits ~~”return”~~ “next”, focus should move to the “password” field. When the user taps “go,” the app logs them in.

You can use the new FocusState SwiftUI property wrapper to create variables to represent the placement of focus in the view. It can be a boolean to flag whether the associated field is in focus. In our login view, we have two fields that we need to switch focus between and so we use the enum option instead.

In LoginView, I define the Field enumeration type to represent whether the username (email address) or password is in focus. I then create the focussedField @FocusState variable to store the value using the Field type:

enum Field: Hashable {
   case username
   case password
}

@FocusState private var focussedField: Field?

I use the .focussed modifier to bind focussedField to the two fields:

TextField("email address", text: $email)
   .focused($focussedField, equals: .username)
   ...
SecureField("password", text: $password)
    .focused($focussedField, equals: .password)
   ...

It’s a two-way binding. If the user selects the email field, then focussedField is set to .username. If the code sets focussedField to .password, then focus switches to the password field.

This next step feels like a hack, but I’ve not found a better solution yet. When the view is loaded, the code waits half a second before setting focus to the username field. Without the delay, the focus isn’t set:

VStack(spacing: 16) {
   ...
}
.onAppear {
   DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
       focussedField = .username
    ...
   }
}

The final step is to shift focus to the password field when the user hits the “next” key in the username field:

TextField("email address", text: $email)
   .onSubmit { focussedField = .password }
   ...

This is the complete body from LoginView:

var body: some View {
   VStack(spacing: 16) {
       Spacer()
       TextField("email address", text: $email)
           .focused($focussedField, equals: .username)
           .submitLabel(.next)
           .onSubmit { focussedField = .password }
       SecureField("password", text: $password)
           .focused($focussedField, equals: .password)
           .onSubmit(userAction)
           .submitLabel(.go)
       Button(action: { newUser.toggle() }) {
           HStack {
               Image(systemName: newUser ? "checkmark.square" : "square")
               Text("Register new user")
               Spacer()
           }
       }
       Button(action: userAction) {
           Text(newUser ? "Register new user" : "Log in")
       }
       .buttonStyle(.borderedProminent)
       .controlSize(.large)
       Spacer()
   }
   .onAppear {
       DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
           focussedField = .username
       }
   }
   .padding()
}

Buttons

Formatting

Previously, I’ve created custom SwiftUI views to make buttons look like…. buttons.

Things get simpler in iOS 15.

In LoginView, I added two new modifiers to my register/login button:

Button(action: userAction) {
   Text(newUser ? "Register new user" : "Log in")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)

Before making this change, I experimented with other button styles:

Xcode. Showing button source code and the associated previews

Confirmation

It’s very easy to accidentally tap the “Logout” button, and so I wanted to add this confirmation dialog:

Dialog for the user to confirm that they wish to log out

Again, iOS 15 makes this simple.

This is the modified version of the LogoutButton view:

struct LogoutButton: View {
   ...
   @State private var isConfirming = false

   var body: some View {
       Button("Logout") { isConfirming = true }
       .confirmationDialog("Are you sure want to logout",
                           isPresented: $isConfirming) {
           Button(action: logout) {
               Text("Confirm Logout")
           }
           Button("Cancel", role: .cancel) {}
       }
   }
   ...
}

These are the changes I made:

  • Added a new state variable (isConfirming)
  • Changed the logout button’s action from calling the logout function to setting isConfirming to true
  • Added the confirmationDialog modifier to the button, providing three things:
    • The dialog title (I didn’t override the titleVisibility option and so the system decides whether this should be shown)
    • A binding to isConfirming that controls whether the dialog is shown or not
    • A view containing the contents of the dialog:
      • A button to logout the user
      • A cancel button

Material

I’m no designer, and this is blurring the edges of what changes I consider worth adding.

The RChat app may have to wait a moment while the backend MongoDB Realm application confirms that the user has been authenticated and logged in. I superimpose a progress view while that’s happening:

A semi-transparrent overlay to indicate that the apps is working on something

To make it look a bit more professional, I can update OpaqueProgressView to use Material to blur the content that’s behind the overlay. To get this effect, I update the background modifier for the VStack:

var body: some View {
   VStack {
       if let message = message {
           ProgressView(message)
       } else {
           ProgressView()
       }
   }
   .padding(Dimensions.padding)
   .background(.ultraThinMaterial,
               in: RoundedRectangle(cornerRadius: Dimensions.cornerRadius))
}

The result looks like this:

A semi-transparrent overlay, with the background blurred, to indicate that the apps is working on something

Developer Tools

Finally, there are a couple of enhancements that are helpful during your development phase.

Landscape Previews

I’m a big fan of Xcode’s “Canvas” previews. Previews let you see what your view will look like. Previews update in more or less real time as you make code changes. You can even display multiple previews at once for example:

  • For different devices: .previewDevice(PreviewDevice(rawValue: "iPhone 12 Pro Max"))
  • For dark mode: .preferredColorScheme(.dark)

A glaring omission was that there was no way to preview landscape mode. That’s fixed in iOS 15 with the addition of the .previewInterfaceOrientation modifier.

For example, this code will show two devices in the preview. The first will be in portrait mode. The second will be in landscape and dark mode:

struct CurrencyRow_Previews: PreviewProvider {
   static var previews: some View {
       Group {
           List {
               CurrencyRowView(value: 3.23, symbol: "USD", baseValue: .constant(1.0))
               CurrencyRowView(value: 1.0, symbol: "GBP", baseValue: .constant(10.0))
           }
           List {
               CurrencyRowView(value: 3.23, symbol: "USD", baseValue: .constant(1.0))
               CurrencyRowView(value: 1.0, symbol: "GBP", baseValue: .constant(10.0))
           }
           .preferredColorScheme(.dark)
           .previewInterfaceOrientation(.landscapeLeft)
       }
   }
}

Animation of Xcode preview. Shows that the preview updates in real time as the code is changed. There are previews for both landscape and portrait modes

Self._printChanges

SwiftUI is very smart at automatically refreshing views when associated state changes. But sometimes, it can be hard to figure out exactly why a view is or isn’t being updated.

iOS 15 adds a way to print out what pieces of state data have triggered each refresh for a view. Simply call Self._printChanges() from the body of your view. For example, I updated ContentView for the LiveChat app:

struct ContentView: View {
   @State private var username = ""

   var body: some View {
       print(Self._printChanges())
       return NavigationView {
           Group {
               if app.currentUser == nil {
                   LoginView(username: $username)
               } else {
                   ChatRoomsView(username: username)
               }
           }
           .navigationBarTitle(username, displayMode: .inline)
           .navigationBarItems(trailing: app.currentUser != nil ? LogoutButton(username: $username) : nil) }
    }
}

If I log in and check the Xcode console, I can see that it’s the update to username that triggered the refresh (rather than app.currentUser):

ContentView: _username changed.

There can be a lot of these messages, and so remember to turn them off before going into production.

Conclusion

SwiftUI is developing at pace. With each iOS release, there is less and less reason to not use it for all/some of your mobile app.

This post describes how to use some of the iOS 15 SwiftUI features that caught my attention. I focussed on the features that I could see would instantly benefit my most recent mobile apps. In this article, I’ve shown how those apps could be updated to use these features.

There are lots of features that I didn’t include here. A couple of notable omissions are:

  • AsyncImage is going to make it far easier to work with images that are stored in the cloud. I didn’t need it for any of my current apps, but I’ve no doubt that I’ll be using it in a project soon.
  • The task view modifier is going to have a significant effect on how people run asynchronous code when a view is loaded. I plan to cover this in a future article that takes a more general look at how to handle concurrency with Realm.
  • Adding a toolbar to your keyboards (e.g., to let the user switch between input fields).

If you have any questions or comments on this post (or anything else Realm-related), then please raise them on our community forum. To keep up with the latest Realm news, follow @realm on Twitter and join the Realm global community.





Build Offline-First Mobile Apps by Caching API Results in Realm

Note that this post was originally published at Build Offline-First Mobile Apps by Caching API Results in Realm.

Introduction

When building a mobile app, there’s a good chance that you want it to pull in data from a cloud service—whether from your own or from a third party. While other technologies are growing (e.g., GraphQL and MongoDB Realm Sync), REST APIs are still prevalent.

It’s easy to make a call to a REST API endpoint from your mobile app, but what happens when you lose network connectivity? What if you want to slice and dice that data after you’ve received it? How many times will your app have to fetch the same data (consuming data bandwidth and battery capacity each time)? How will your users react to a sluggish app that’s forever fetching data over the internet?

By caching the data from API calls in Realm, the data is always available to your app. This leads to higher availability, faster response times, and reduced network and battery consumption.

This article shows how the RCurrency mobile app fetches exchange rate data from a public API, and then caches it in Realm for always-on, local access.

Is Using the API from Your Mobile App the Best Approach?

This app only reads data through the API. Writing an offline-first app that needs to reliably update cloud data via an API is a far more complex affair. If you need to update cloud data when offline, then I’d strongly recommend you consider MongoDB Realm Sync.

Many APIs throttle your request rate or charge per request. That can lead to issues as your user base grows. A more scalable approach is to have your backend Realm app fetch the data from the API and store it in Atlas. Realm Sync then makes that data available locally on every user’s mobile device—without the need for any additional API calls.

Prerequisites

The RCurrency Mobile App

The RCurrency app is a simple exchange rate app. It’s intended for uses such as converting currencies when traveling.

You choose a base currency and a list of other currencies you want to convert between.

When opened for the first time, RCurrency uses a REST API to retrieve exchange rates, and stores the data in Realm. From that point on, the app uses the data that’s stored in Realm. Even if you force-close the app and reopen it, it uses the local data.

If the stored rates are older than today, the app will fetch the latest rates from the API and replace the Realm data.

The app supports pull-to-refresh to fetch and store the latest exchange rates from the API.

You can alter the amount of any currency, and the amounts for all other currencies are instantly recalculated.

Animation of the RCurrency app running on an iPhone. Includes the user selecting currencies, changing amounts and observing the amounts changing for other currencies

The REST API

I’m using the API provided by exchangerate.host. The API is a free service that provides a simple API to fetch currency exchange rates.

One of the reasons I picked this API is that it doesn’t require you to register and then manage access keys/tokens. It’s not rocket science to handle that complexity, but I wanted this app to focus on when to fetch data, and what to do once you receive it.

The app uses a single endpoint (where you can replace USD and EUR with the currencies you want to convert between):

https://api.exchangerate.host/convert?from=USD&to=EUR

You can try calling that endpoint directly from your browser.

The endpoint responds with a JSON document:

{
  "motd": {
    "msg": "If you or your company use this project or like what we doing, please consider backing us so we can continue maintaining and evolving this project.",
    "url": "https://exchangerate.host/#/donate"
  },
  "success": true,
  "query": {
    "from": "USD",
    "to": "EUR",
    "amount": 1
  },
  "info": {
    "rate": 0.844542
  },
  "historical": false,
  "date": "2021-09-02",
  "result": 0.844542
}

Note that the exchange rate for each currency is only updated once every 24 hours. That’s fine for our app that’s helping you decide whether you can afford that baseball cap when you’re on vacation. If you’re a currency day-trader, then you should look elsewhere.

The RCurrency App Implementation

Data Model

JSON is the language of APIs. That’s great news as most modern programming languages (including Swift) make it super easy to convert between JSON strings and native objects.

The app stores the results from the API query in objects of type Rate. To make it as simple as possible to receive and store the results, I made the Rate class match the JSON format of the API results:

class Rate: Object, ObjectKeyIdentifiable, Codable {
    var motd = Motd()
    var success = false
    @Persisted var query: Query?
    var info = Info()
    @Persisted var date: String
    @Persisted var result: Double
}

class Motd: Codable {
    var msg = ""
    var url = ""
}

class Query: EmbeddedObject, ObjectKeyIdentifiable, Codable {
    @Persisted var from: String
    @Persisted var to: String
    var amount = 0
}

class Info: Codable {
    var rate = 0.0
}

Note that only the fields annotated with @Persisted will be stored in Realm.

Swift can automatically convert between Rate objects and the JSON strings returned by the API because we make the class comply with the Codable protocol.

There are two other top-level classes used by the app.

Symbols stores all of the supported currency symbols. In the app, the list is bootstrapped from a fixed list. For future-proofing, it would be better to fetch them from an API:

class Symbols {
    var symbols = Dictionary<String, String>()
}

extension Symbols {
    static var data = Symbols()


    static func loadData() {
        data.symbols["AED"] = "United Arab Emirates Dirham"
        data.symbols["AFN"] = "Afghan Afghani"
        data.symbols["ALL"] = "Albanian Lek"
        ...
    }
}

UserSymbols is used to store the user’s chosen base currency and the list of currencies they’d like to see exchange rates for:

class UserSymbols: Object, ObjectKeyIdentifiable {
    @Persisted var baseSymbol: String
    @Persisted var symbols: List<String>
}

An instance of UserSymbols is stored in Realm so that the user gets the same list whenever they open the app.

Rate Data Lifecycle

This flowchart shows how the exchange rate for a single currency (represented by the symbol string) is managed when the CurrencyRowContainerView is used to render data for that currency:

Flowchart showing how the app fetches data from the API and stored in in Realm. The mobile app's UI always renders what's stored in MongoDB. The following sections will describe each block in the flow diagram.

Note that the actual behavior is a little more subtle than the diagram suggests. SwiftUI ties the Realm data to the UI. If stage #2 finds the data in Realm, then it will immediately get displayed in the view (stage #8). The code will then make the extra checks and refresh the Realm data if needed. If and when the Realm data is updated, SwiftUI will automatically refresh the UI to render it.

Let’s look at each of those steps in turn.

#1 CurrencyContainerView loaded for currency represented by symbol

CurrencyListContainerView iterates over each of the currencies that the user has selected. For each currency, it creates a CurrencyRowContainerView and passes in strings representing the base currency (baseSymbol) and the currency we want an exchange rate for (symbol):

List {
   ForEach(userSymbols.symbols, id: \.self) { symbol in
       CurrencyRowContainerView(baseSymbol: userSymbols.baseSymbol,
                                   baseAmount: $baseAmount,
                                   symbol: symbol,
                                   refreshNeeded: refreshNeeded)
   }
   .onDelete(perform: deleteSymbol)
}

#2 rate = FetchFromRealm(symbol)

CurrencyRowContainerView then uses the @ObservedResults property wrapper to query all Rate objects that are already stored in Realm:

struct CurrencyRowContainerView: View {
   @ObservedResults(Rate.self) var rates
   ...
}

The view then filters those results to find one for the requested baseSymbol/symbol pair:

var rate: Rate? {
   rates.filter(
       NSPredicate(format: "query.from = %@ AND query.to = %@",
                   baseSymbol, symbol)).first
}

#3 rate found?

The view checks whether rate is set or not (i.e., whether a matching object was found in Realm). If rate is set, then it’s passed to CurrencyRowDataView to render the details (step #8). If rate is nil, then a placeholder “Loading Data…” TextView is rendered, and loadData is called to fetch the data using the API (step #4-3):

var body: some View {
   if let rate = rate {
       HStack {
           CurrencyRowDataView(rate: rate, baseAmount: $baseAmount, action: action)
           ...
       }
   } else {
       Text("Loading Data...")
           .onAppear(perform: loadData)
   }
}

#4-3 Fetch rate from API — No matching object found in Realm

The API URL is formed by inserting the base currency (baseSymbol) and the target currency (symbol) into a template string. loadData then sends the request to the API endpoint and handles the response:

private func loadData() {
   guard let url = URL(string: "https://api.exchangerate.host/convert?from=\(baseSymbol)&to=\(symbol)") else {
       print("Invalid URL")
       return
   }
   let request = URLRequest(url: url)
   print("Network request: \(url.description)")
   URLSession.shared.dataTask(with: request) { data, response, error in
       guard let data = data else {
           print("Error fetching data: \(error?.localizedDescription ?? "Unknown error")")
           return
       }
       if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
           // TODO: Step #5-3
       } else {
           print("No data received")
       }
   }
   .resume()
}

#5-3 StoreInRealm(rate) — No matching object found in Realm

Rate objects stored in Realm are displayed in our SwiftUI views. Any data changes that impact the UI must be done on the main thread. When the API endpoint sends back results, our code receives them in a callback thread, and so we must use DispatchQueue to run our closure in the main thread so that we can add the resulting Rate object to Realm:

if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
   DispatchQueue.main.async {
       $rates.append(decodedResponse)
   }
} else {
   print("No data received")
}

Notice how simple it is to convert the JSON response into a Realm Rate object and store it in our local realm!

#6 Refresh Requested?

RCurrency includes a pull-to-refresh feature which will fetch fresh exchange rate data for each of the user’s currency symbols. We add the refresh functionality by appending the .refreshable modifier to the List of rates in CurrencyListContainerView:

List {
   ...
}
.refreshable(action: refreshAll)

refreshAll sets the refreshNeeded variable to true, waits a second to allow SwiftUI to react to the change, and then sets it back to false:

private func refreshAll() {
   refreshNeeded = true
   DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
       refreshNeeded = false
   }
}

refreshNeeded is passed to each instance of CurrencyRowContainerView:

CurrencyRowContainerView(baseSymbol: userSymbols.baseSymbol,
                        baseAmount: $baseAmount,
                        symbol: symbol,
                        refreshNeeded: refreshNeeded)

CurrencyRowContainerView checks refreshNeeded. If true, it displays a temporary refresh image and invokes refreshData (step #4-6):

if refreshNeeded {
   Image(systemName: "arrow.clockwise.icloud")
       .onAppear(perform: refreshData)
}

#4-6 Fetch rate from API — Refresh requested

refreshData fetches the data in exactly the same way as loadData in step #4-3:

private func refreshData() {
   guard let url = URL(string: "https://api.exchangerate.host/convert?from=\(baseSymbol)&to=\(symbol)") else {
       print("Invalid URL")
       return
   }
   let request = URLRequest(url: url)
   print("Network request: \(url.description)")
   URLSession.shared.dataTask(with: request) { data, response, error in
       guard let data = data else {
           print("Error fetching data: \(error?.localizedDescription ?? "Unknown error")")
           return
       }
       if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
           DispatchQueue.main.async {
               // TODO: #5-5
           }
       } else {
           print("No data received")
       }
   }
   .resume()
}

The difference is that in this case, there may already be a Rate object in Realm for this currency pair, and so the results are handled differently…

#5-6 StoreInRealm(rate) — Refresh requested

If the Rate object for this currency pair had been found in Realm, then we reference it with existingRate. existingRate is then updated with the API results:

if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
   DispatchQueue.main.async {
       if let existingRate = rate {
           do {
               let realm = try Realm()
               try realm.write() {
                   guard let thawedrate = existingRate.thaw() else {
                       print("Couldn't thaw existingRate")
                       return
                   }
                   thawedrate.date = decodedResponse.date
                   thawedrate.result = decodedResponse.result
               }
           } catch {
               print("Unable to update existing rate in Realm")
           }
       }
   }
}

#7 rate stale?

The exchange rates available through the API are updated daily. The date that the rate applies to is included in the API response, and it’s stored in the Realm Rate object. When displaying the exchange rate data, CurrencyRowDataView invokes loadData:

var body: some View {
   CurrencyRowView(value: (rate.result) * baseAmount,
                   symbol: rate.query?.to ?? "",
                   baseValue: $baseAmount,
                   action: action)
       .onAppear(perform: loadData)
}

loadData checks that the existing Realm Rate object applies to today. If not, then it will refresh the data (stage 4-7):

private func loadData() {
   if !rate.isToday {
       // TODO: 4-7
   }
}

isToday is a Rate method to check whether the stored data matches the current date:

extension Rate {
   var isToday: Bool {
       let today = Date().description.prefix(10)
       return  date == today
   }
}

#4-7 Fetch rate from API — rate stale

By now, the code to fetch the data from the API should be familiar:

private func loadData() {
   if !rate.isToday {
       guard let query = rate.query else {
           print("Query data is missing")
           return
       }
       guard let url = URL(string: "https://api.exchangerate.host/convert?from=\(query.from)&to=\(query.to)") else {
           print("Invalid URL")
           return
       }
       let request = URLRequest(url: url)
       URLSession.shared.dataTask(with: request) { data, response, error in
           guard let data = data else {
               print("Error fetching data: \(error?.localizedDescription ?? "Unknown error")")
               return
           }
           if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
               DispatchQueue.main.async {
                   // TODO: #5.7
               }
           } else {
               print("No data received")
           }
       }
       .resume()
   }
}

#5-7 StoreInRealm(rate) — rate stale

loadData copies the new date and exchange rate (result) to the stored Realm Rate object:

if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
   DispatchQueue.main.async {
       $rate.date.wrappedValue = decodedResponse.date
       $rate.result.wrappedValue = decodedResponse.result
   }
}

#8 View rendered with rate

CurrencyRowView receives the raw exchange rate data, and the amount to convert. It’s responsible for calculating and rendering the results:

Screen capture showing the row for a single currency. In this case it shows the US flag, the "USD" symbol and the amount (3.23...)

The number shown in this view is part of a TextField, which the user can overwrite:

@Binding var baseValue: Double
...
TextField("Amount", text: $amount)
   .keyboardType(.decimalPad)
   .onChange(of: amount, perform: updateValue)
   .font(.largeTitle)

When the user overwrites the number, the onChange function is called which recalculates baseValue (the value of the base currency that the user wants to convert):

private func updateValue(newAmount: String) {
   guard let newValue = Double(newAmount) else {
       print("\(newAmount) cannot be converted to a Double")
       return
   }
   baseValue = newValue / rate
}

As baseValue was passed in as a binding, the new value percolates up the view hierarchy, and all of the currency values are updated. As the exchange rates are held in Realm, all of the currency values are recalculated without needing to use the API:

Animation showing the app running on an iPhone. When the user changes the amount for 1 currency, the amounts for all of the others changes immediately

Conclusion

REST APIs let your mobile apps act on a vast variety of cloud data. The downside is that APIs can’t help you when you don’t have access to the internet. They can also make your app seem sluggish, and your users may get frustrated when they have to wait for data to be downloaded.

A common solution is to use Realm to cache data from the API so that it’s always available and can be accessed locally in an instant.

This article has shown you a typical data lifecycle that you can reuse in your own apps. You’ve also seen how easy it is to store the JSON results from an API call in your Realm database:

if let decodedResponse = try? JSONDecoder().decode(Rate.self, from: data) {
   DispatchQueue.main.async {
       $rates.append(decodedResponse)
   }
}

We’ve focussed on using a read-only API. Things get complicated very quickly when your app starts modifying data through the API. What should your app do when your device is offline?

  • Don’t allow users to do anything that requires an update?
  • Allow local updates and maintain a list of changes that you iterate through when back online?
    • Will some changes you accept from the user have to be backed out once back online and you discover conflicting changes from other users?

If you need to modify data that’s accessed by other users or devices, consider MongoDB Realm Sync as an alternative to accessing APIs directly from your app. It will save you thousands of lines of tricky code!

The API you’re using may throttle access or charge per request. You can create a backend MongoDB Realm app to fetch the data from the API just once, and then use Realm Sync to handle the fan-out to all instances of your mobile app.

If you have any questions or comments on this post (or anything else Realm-related), then please raise them on our community forum. To keep up with the latest Realm news, follow @realm on Twitter and join the Realm global community.





Migrating Your iOS App’s Synced Realm Schema in Production

Introduction

In the previous post in this series, we saw how to migrate your Realm data when you upgraded your iOS app with a new schema. But, that only handled the data in your local, standalone Realm database. What if you’re using MongoDB Realm Sync to replicate your local Realm data with other instances of your mobile app and with MongoDB Atlas? That’s what this article will focus on.

We’ll start with the original RChat app. We’ll then extend the iOS app and backend Realm schema to add a new feature that allows chat messages to be tagged as high priority. The next (and perhaps surprisingly more complicated from a Realm perspective) upgrade is to make the author attribute of the existing ChatMessage object non-optional.

You can find all of the code for this post in the RChat repo under these branches:

Prerequisites

Realm Cocoa 10.13.0 or later (for versions of the app that you’re upgrading to)

Catch-Up — The RChat App

RChat is a basic chat app:

  • Users can register and log in using their email address and a password.
  • Users can create chat rooms and include other users in those rooms.
  • Users can post messages to a chat room (optionally including their location and photos).
  • All members of a chatroom can see messages sent to the room by themselves or other users.

Upgrade #1: Add a High-Priority Flag to Chat Messages

The first update is to allow a user to tag a message as being high-priority as they post it to the chat room:

Screenshot showing the option to click a thermometer button to tag the message as urgent

That message is then highlighted with bold text and a “hot” icon in the list of chat messages:

Screenshot showing that a high-priority message has bold text and a hot thermometer icon

Updating the Backend Realm Schema

Adding a new field is an additive change—meaning that you don’t need to restart sync (which would require every deployed instance of the RChat mobile app to recognize the change and start sync from scratch, potentially losing local changes).

We add the new isHighPriority bool to our Realm schema through the Realm UI:

Screenshot from the RealmUI showing that the isHighPriority bool has been added to the schema

We also make isHighPriority a required (non-optional field).

The resulting schema looks like this:

{
   "bsonType": "object",
   "properties": {
     "_id": {
       "bsonType": "string"
     },
     "author": {
       "bsonType": "string"
     },
     "image": {
       "bsonType": "object",
       "properties": {
         "_id": {
           "bsonType": "string"
         },
         "date": {
           "bsonType": "date"
         },
         "picture": {
           "bsonType": "binData"
         },
         "thumbNail": {
           "bsonType": "binData"
         }
       },
       "required": [
         "_id",
         "date"
       ],
       "title": "Photo"
     },
     "isHighPriority": {
       "bsonType": "bool"
     },
     "location": {
       "bsonType": "array",
       "items": {
         "bsonType": "double"
       }
     },
     "partition": {
       "bsonType": "string"
     },
     "text": {
       "bsonType": "string"
     },
     "timestamp": {
       "bsonType": "date"
     }
   },
   "required": [
     "_id",
     "partition",
     "text",
     "timestamp",
     "isHighPriority"
   ],
   "title": "ChatMessage"
 }

Note that existing versions of our iOS RChat app can continue to work with our updated backend Realm app, even though their local ChatMessage Realm objects don’t include the new field.

Updating the iOS RChat App

While existing versions of the iOS RChat app can continue to work with the updated Realm backend app, they can’t use the new isHighPriority field as it isn’t part of the ChatMessage object.

To add the new feature, we need to update the mobile app after deploying the updated Realm backend application.

The first change is to add the isHighPriority field to the ChatMessage class:

class ChatMessage: Object, ObjectKeyIdentifiable {
   @Persisted(primaryKey: true) var _id = UUID().uuidString
   @Persisted var partition = "" // "conversation=<conversation-id>"
   @Persisted var author: String? // username
   @Persisted var text = ""
   @Persisted var image: Photo?
   @Persisted var location = List<Double>()
   @Persisted var timestamp = Date()
   @Persisted var isHighPriority = false
   ...
}

As seen in the previous post in this series, Realm can automatically update the local realm to include this new attribute and initialize it to false. Unlike with standalone realms, we **don’t*- need to signal to the Realm SDK that we’ve updated the schema by providing a schema version.

The new version of the app will happily exchange messages with instances of the original app on other devices (via our updated backend Realm app).

Upgrade #2: Make author a Non-Optional Chat Message field

When the initial version of RChat was written, the author field of ChatMessage was declared as being optional. We’ve since realized that there are no scenarios where we wouldn’t want the author included in a chat message. To make sure that no existing or future client apps neglect to include the author, we need to update our schema to make author a required field.

Unfortunately, changing a field from optional to required (or vice versa) is a destructive change, and so would break sync for any deployed instances of the RChat app.

Oops!

This means that there’s extra work needed to make the upgrade seamless for the end users. We’ll go through the process now.

Updating the Backend Realm Schema

The change we need to make to the schema is destructive. This means that the new document schema is incompatible with the schema that’s currently being used in our mobile app.

If RChat wasn’t already deployed on the devices of hundreds of millions of users (we can dream!), then we could update the Realm schema for the ChatMessage collection and restart Realm Sync. During development, we can simply remove the original RChat mobile app and then install an updated version on our test devices.

To avoid that trauma for our end users, we leave the ChatMessage collection’s schema as is and create a partner collection. The partner collection (ChatMessageV2) will contain the same data as ChatMessage, except that its schema makes author a required field.

These are the steps we’ll go through to create the partner collection:

  • Define a Realm schema for the ChatMessageV2 collection.
  • Run an aggregation to copy all of the documents from ChatMessage to ChatMessageV2. If author is missing from a ChatMessage document, then the aggregation will add it.
  • Add a trigger to the ChatMessage collection to propagate any changes to ChatMessageV2 (adding author if needed).
  • Add a trigger to the ChatMessageV2 collection to propagate any changes to ChatMessage.

Define the Schema for the Partner Collection

From the Realm UI, copy the schema from the ChatMessage collection.

Click the button to create a new schema:

Showing a plus button in the Realm UI to add a new collection

Set the database and collection name before clicking “Add Collection”:

Setting the database and collection names in the Realm UI

Paste in the schema copied from ChatMessage, add author to the required section, change the title to ChatMessageV2, and the click the “SAVE” button:

Adding "author" to the required attribute list and naming the class ChatMessageV2 in the Realm UI

This is the resulting schema:

{
   "bsonType": "object",
   "properties": {
     "_id": {
       "bsonType": "string"
     },
     "author": {
       "bsonType": "string"
     },
     "image": {
       "bsonType": "object",
       "properties": {
         "_id": {
           "bsonType": "string"
         },
         "date": {
           "bsonType": "date"
         },
         "picture": {
           "bsonType": "binData"
         },
         "thumbNail": {
           "bsonType": "binData"
         }
       },
       "required": [
         "_id",
         "date"
       ],
       "title": "Photo"
     },
     "isHighPriority": {
       "bsonType": "bool"
     },
     "location": {
       "bsonType": "array",
       "items": {
         "bsonType": "double"
       }
     },
     "partition": {
       "bsonType": "string"
     },
     "text": {
       "bsonType": "string"
     },
     "timestamp": {
       "bsonType": "date"
     }
   },
   "required": [
     "_id",
     "partition",
     "text",
     "timestamp",
     "isHighPriority",
     "author"
   ],
   "title": "ChatMessageV2"
 }

Copy Existing Data to the Partner Collection

We’re going to use an aggregation pipeline to copy and transform the existing data from the original collection (ChatMessage) to the partner collection (ChatMessageV2).

You may want to pause sync just before you run the aggregation, and then unpause it after you enable the trigger on the ChatMessage collection in the next step:

Pressing a button to pause Realm sync in the Realm UI

The end users can continue to create new messages while sync is paused, but those messages won’t be published to other users until sync is resumed. By pausing sync, you can ensure that all new messages will make it into the partner collection (and so be visible to users running the new version of the mobile app).

If pausing sync is too much of an inconvenience, then you could create a temporary trigger on the ChatMessage collection that will copy and transform document inserts to the ChatMessageV2 collection (it’s a subset of the ChatMessageProp trigger we’ll define in the next section.).

From the Atlas UI, select “Collections” -> “ChatMessage”, “New Pipeline From Text”:

Navigating through "Atlas/ChatMessage/Collections/New Pipeline from Text" in the Realm UI

Paste in this aggregation pipeline and click the “Create New” button:

[
 {
   '$addFields': {
     'author': {
       '$convert': {
         'input': '$author',
         'to': 'string',
         'onError': 'unknown',
         'onNull': 'unknown'
       }
     }
   }
 },
 {
   '$merge': {
     into: "ChatMessageV2",
     on: "_id",
     whenMatched: "replace",
     whenNotMatched: "insert"
   }
 }
]

This aggregation will take each ChatMessage document, set author to “unknown” if it’s not already set, and then add it to the ChatMessageV2 collection.

Click “MERGE DOCUMENTS”:

Clicking the "Merge Documents" button in the Realm UI

ChatMessageV2 now contains a (possibly transformed) copy of every document from ChatMessage. But, changes to one collection won’t be propagated to the other. To address that, we add a database trigger to each collection…

Add Database Triggers

We need to create two Realm Functions—one to copy/transfer documents to ChatMessageV2, and one to copy documents to ChatMessage.

From the “Functions” section of the Realm UI, click “Create New Function”:

Clicking the "Create New Function" button in the Realm UI

Name the function copyToChatMessageV2. Set the authentication method to “System”—this will circumvent any access permissions on the ChatMessageV2 collection. Ensure that the “Private” switch is turned on—that means that the function can be called from a trigger, but not directly from a frontend app. Click “Save”:

Set the name to "copyToChatMessageV2", authentication to "System" and "Private" to "On". Then click the "Save" button in the Realm UI

Paste this code into the function editor and save:

exports = function (changeEvent) {
   const db = context.services.get("mongodb-atlas").db("RChat");


   if (changeEvent.operationType === "delete") {
     return db.collection("ChatMessageV2").deleteOne({ _id: changeEvent.documentKey._id });
   }


   const author = changeEvent.fullDocument.author ? changeEvent.fullDocument.author : "Unknown";
   const pipeline = [
     { $match: { _id: changeEvent.documentKey._id } },
     {
       $addFields: {
         author: author,
       }
     },
     { $merge: "ChatMessageV2" }];


   return db.collection("ChatMessage").aggregate(pipeline);
};

This function will receive a ChatMessage document from our trigger. If the operation that triggered the function is a delete, then this function deletes the matching document from ChatMessageV2. Otherwise, the function either copies author from the incoming document or sets it to “Unknown” before writing the transformed document to ChatMessageV2. We could initialize author to any string, but I’ve used “Unknown” to tell the user that we don’t know who the author was.

Create the copyToChatMessage function in the same way:

exports = function (changeEvent) {
   const db = context.services.get("mongodb-atlas").db("RChat");


   if (changeEvent.operationType === "delete") {
     return db.collection("ChatMessage").deleteOne({ _id: changeEvent.documentKey._id })
   }
    const pipeline = [
     { $match: { _id: changeEvent.documentKey._id } },
     { $merge: "ChatMessage" }]
   return db.collection("ChatMessageV2").aggregate(pipeline);
};

The final change needed to the backend Realm application is to add database triggers that invoke these functions.

From the “Triggers” section of the Realm UI, click “Add a Trigger”:

Click the "Add a Trigger" button in the Realm UI

Configure the ChatMessageProp trigger as shown:

In the Realm UI, set "Trigger Type" to "Database". Set "Name" to "ChatMessageProp". Enabled = On. Event Ordering = on. Cluster Name = Cluster 0. Database name = RChat. Collection name = ChatMessage. Check all operation types. Full Document = on. Document Preimage = off. Event Type = Function. Function = copyToChatMessageV2

Repeat for ChatMessageV2Change:

In the Realm UI, set "Trigger Type" to "Database". Set "Name" to "ChatMessageProp". Enabled = On. Event Ordering = on. Cluster Name = Cluster 0. Database name = RChat. Collection name = ChatMessage. Check all operation types. Full Document = off. Document Preimage = off. Event Type = Function. Function = copyToChatMessage

If you paused sync in the previous section, then you can now unpause it.

Updating the iOS RChat App

We want to ensure that users still running the old version of the app can continue to exchange messages with users running the latest version.

Existing versions of RChat will continue to work. They will create ChatMessage objects which will get synced to the ChatMessage Atlas collection. The database triggers will then copy/transform the document to the ChatMessageV2 collection.

We now need to create a new version of the app that works with documents from the ChatMessageV2 collection. We’ll cover that in this section.

Recall that we set title to ChatMessageV2 in the partner collection’s schema. That means that to sync with that collection, we need to rename the ChatMessage class to ChatMessageV2 in the iOS app.

Changing the name of the class throughout the app is made trivial by Xcode.

Open ChatMessage.swift and right-click on the class name (ChatMessage), select “Refactor” and then “Rename…”:

In Xcode, select the "ChatMessage" class name, right-click and select "Refactor -> Rename…"” /></p>
<p>Override the class name with <code>ChatMessageV2</code> and click “Rename”:</p>
<p><img decoding=

The final step is to make the author field mandatory. Remove the ? from the author attribute to make it non-optional:

class ChatMessageV2: Object, ObjectKeyIdentifiable {
   @Persisted(primaryKey: true) var _id = UUID().uuidString
   @Persisted var partition = "" // "conversation=<conversation-id>"
   @Persisted var author: String
   ...
}

Conclusion

Modifying a Realm schema is a little more complicated when you’re using Realm Sync for a deployed app. You’ll have end users who are using older versions of the schema, and those apps need to continue to work.

Fortunately, the most common schema changes (adding or removing fields) are additive. They simply require updates to the back end and iOS schema.

Things get a little trickier for destructive changes, such as changing the type or optionality of an existing field. For these cases, you need to create and maintain a partner collection to avoid loss of data or service for your users.

This article has stepped through how to handle both additive and destructive schema changes, allowing you to add new features or fix issues in your apps without impacting users running older versions of your app.

Remember, you can find all of the code for this post in the RChat repo under these branches:

If you’re looking to upgrade the Realm schema for an iOS app that isn’t using Realm Sync, then refer to the previous post in this series.

If you have any questions or comments on this post (or anything else Realm-related), then please raise them on our community forum. To keep up with the latest Realm news, follow @realm on Twitter and join the Realm global community.





Migrating Your iOS App’s Realm Schema in Production

Introduction

Murphy’s law dictates that as soon as your mobile app goes live, you’ll receive a request to add a new feature. Then another. Then another.

This is fine if these features don’t require any changes to your data schema. But, that isn’t always the case.

Fortunately, Realm has built-in functionality to make schema migration easier.

This tutorial will step you through updating an existing mobile app to add some new features that require changes to the schema. In particular, we’ll look at the Realm migration code that ensures that no existing data is lost when the new app versions are rolled out to your production users.

We’ll use the Scrumdinger app that I modified in a previous post to show how Apple’s sample Swift app could be ported to Realm. The starting point for the app can be found in this branch of our Scrumdinger repo and the final version is in this branch.

Note that the app we’re using for this post doesn’t use MongoDB Realm Sync. If it did, then the schema migration process would be very different—I’ll cover that in a future tutorial, but for now, you can check out the docs.

Prerequisites

This tutorial has a dependency on Realm-Cocoa 10.13.0+.

Baseline App/Realm Schema

As a reminder, the starting point for this tutorial is the “realm” branch of the Scrumdinger repo.

Animated gif of the original Scrumdinger app in action

There are two Realm model classes that we’ll extend to add new features to Scrumdinger. The first, DailyScrum, represents one scrum:

class DailyScrum: Object, ObjectKeyIdentifiable {
   @Persisted var title = ""
   @Persisted var attendeeList = RealmSwift.List<String>()
   @Persisted var lengthInMinutes = 0
   @Persisted var colorComponents: Components?
   @Persisted var historyList = RealmSwift.List<History>()


   var color: Color { Color(colorComponents ?? Components()) }
   var attendees: [String] { Array(attendeeList) }
   var history: [History] { Array(historyList) }
   ...
}

The second, History, represents the minutes of a meeting from one of the user’s scrums:

class History: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var date: Date?
   @Persisted var attendeeList = List<String>()
   @Persisted var lengthInMinutes: Int = 0
   @Persisted var transcript: String?
   var attendees: [String] { Array(attendeeList) }
   ...
}

We can use Realm Studio to examine the contents of our Realm database after the DailyScrum and History objects have been created:

DailyScrum data shown in RealmStudio

History data shown in RealmStudio

Accessing Realm Data on iOS Using Realm Studio explains how to locate and open the Realm files from your iOS simulator.

Schema Change #1—Mark Scrums as Public/Private

The first new feature we’ve been asked to add is a flag to indicate whether each scrum is public or private:

Screen capture highlighting the new status - set to "Private"

Screen capture showing the user setting the meeting status to "Public"

This feature requires the addition of a new Bool named isPublic to DailyScrum:

class DailyScrum: Object, ObjectKeyIdentifiable {
   @Persisted var title = ""
   @Persisted var attendeeList = RealmSwift.List<String>()
   @Persisted var lengthInMinutes = 0
   @Persisted var isPublic = false
   @Persisted var colorComponents: Components?
   @Persisted var historyList = RealmSwift.List<History>()


   var color: Color { Color(colorComponents ?? Components()) }
   var attendees: [String] { Array(attendeeList) }
   var history: [History] { Array(historyList) }
   ...
}

Remember that our original version of Scrumdinger is already in production, and the embedded Realm database is storing instances of DailyScrum. We don’t want to lose that data, and so we must migrate those objects to the new schema when the app is upgraded.

Fortunately, Realm has built-in functionality to automatically handle the addition and deletion of fields. When adding a field, Realm will use a default value (e.g., 0 for an Int, and false for a Bool).

If we simply upgrade the installed app with the one using the new schema, then we’ll get a fatal error. That’s because we need to tell Realm that we’ve updated the schema. We do that by setting the schema version to 1 (the version defaulted to 0 for the original schema):

@main
struct ScrumdingerApp: SwiftUI.App {
   var body: some Scene {
       WindowGroup {
           NavigationView {
               ScrumsView()
                   .environment(\.realmConfiguration,
                       Realm.Configuration(schemaVersion: 1))
           }
       }
   }
}

After upgrading the app, we can use Realm Studio to confirm that our DailyScrum object has been updated to initialize isPublic to false:

RealmStudio showing that the new, isPublic field has been initialised to false

Schema Change #2—Store The Number of Attendees at Each Meeting

The second feature request is to show the number of attendees in the history from each meeting:

Screen capture showing that the number of attendees is now displayed in the meeting minutes

We could calculate the count every time that it’s needed, but we’ve decided to calculate it just once and then store it in our History object in a new field named numberOfAttendees:

class History: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var date: Date?
   @Persisted var attendeeList = List<String>()
   @Persisted var numberOfAttendees = 0
   @Persisted var lengthInMinutes: Int = 0
   @Persisted var transcript: String?
   var attendees: [String] { Array(attendeeList) }
   ...
}

We increment the schema version to 2. Note that the schema version applies to all Realm objects, and so we have to set the version to 2 even though this is the first time that we’ve changed the schema for History.

If we leave it to Realm to initialize numberOfAttendees, then it will set it to 0—which is not what we want. Instead, we provide a migrationBlock which initializes new fields based on the old schema version:

@main
struct ScrumdingerApp: SwiftUI.App {
   var body: some Scene {
       WindowGroup {
           NavigationView {
               ScrumsView()
                   .environment(\.realmConfiguration, Realm.Configuration(
                       schemaVersion: 2,
                       migrationBlock: { migration, oldSchemaVersion in
                            if oldSchemaVersion < 1 {
                                // Could init the `DailyScrum.isPublic` field here, but the default behavior of setting
                                // it to `false` is what we want.
                            }
                            if oldSchemaVersion < 2 {
                                migration.enumerateObjects(ofType: History.className()) { oldObject, newObject in
                                    let attendees = oldObject!["attendeeList"] as? RealmSwift.List<DynamicObject>
                                    newObject!["numberOfAttendees"] = attendees?.count ?? 0
                                }
                            }
                            if oldSchemaVersion < 3 {
                                // TODO: This is where you'd add you're migration code to go from version
                                // to version 3 when you next modify the schema
                            }
                        }
                   ))
           }
       }
   }
}

Note that all other fields are migrated automatically.

It’s up to you how you use data from the previous schema to populate fields in the new schema. E.g., if you wanted to combine firstName and lastName from the previous schema to populate a fullName field in the new schema, then you could do so like this:

migration.enumerateObjects(ofType: Person.className()) { oldObject, newObject in
   let firstName = oldObject!["firstName"] as! String
   let lastName = oldObject!["lastName"] as! String
   newObject!["fullName"] = "\(firstName) \(lastName)"
}

We can’t know what “old version” of the schema will be already installed on a user’s device when it’s upgraded to the latest version (some users may skip some versions,) and so the migrationBlock must handle all previous versions. Best practice is to process the incremental schema changes sequentially:

  • oldSchemaVersion < 1 : Process the delta between v0 and v1
  • oldSchemaVersion < 2 : Process the delta between v1 and v2
  • oldSchemaVersion < 3 : Process the delta between v2 and v3

Realm Studio shows that our code has correctly initialized numberOfAttendees:

Realm Studio showing that the numberOfAttendees field has been set to 2 – matching the number of attendees in the meeting history

Conclusion

It’s almost inevitable that any successful mobile app will need some schema changes after it’s gone into production. Realm makes adapting to those changes simple, ensuring that users don’t lose any of their existing data when upgrading to new versions of the app.

For changes such as adding or removing fields, all you need to do as a developer is to increment the version with each new deployed schema. For more complex changes, you provide code that computes the values for fields in the new schema using data from the old schema.

This tutorial stepped you through adding two new features that both required schema changes. You can view the final app in the new-schema branch of the Scrumdinger repo.

Next Steps

This post focussed on schema migration for an iOS app. You can find some more complex examples in the repo.

If you’re working with an app for a different platform, then you can find instructions in the docs:

If you’ve any questions about schema migration, or anything else related to Realm, then please post them to our community forum.