Tag Archive for ios

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.





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.





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.





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 src=

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.





Using Maps and Location Data in Your SwiftUI (+Realm) App

Introduction

Embedding Apple Maps and location functionality in SwiftUI apps used to be a bit of a pain. It required writing your own SwiftUI wrapper around UIKit code—see these examples from the O-FISH app:

If you only need to support iOS14 and later, then you can forget most of that messy code 😊. If you need to support iOS13—sorry, you need to go the O-FISH route!

iOS14 introduced the Map SwiftUI view (part of Mapkit) allowing you to embed maps directly into your SwiftUI apps without messy wrapper code.

This article shows you how to embed Apple Maps into your app views using Mapkit’s Map view. We’ll then look at how you can fetch the user’s current location—with their permission, of course!

Finally, we’ll see how to store the location data in Realm in a format that lets MongoDB Realm sync it to MongoDB Atlas. Once in Atlas, you can add a geospatial index and use MongoDB Charts to plot the data on a map—we’ll look at that too.

Most of the code snippets have been extracted from the RChat app. That app is a good place to see maps and location data in action. Building a Mobile Chat App Using Realm – The New and Easier Way is a good place to learn more about the RChat app—including how to enable MongoDB Realm Sync.

Prerequisites

  • Realm-Cocoa 10.8.0+ (may work with some 10.7.X versions)
  • iOS 14.5+ (Mapkit was introduced in iOS 14.0 and so most features should work with earlier iOS 14.X versions)
  • XCode12+

How to Add an Apple Map to Your SwiftUI App

To begin, let’s create a simple view that displays a map, the coordinates of the center of that map, and the zoom level:

Gif of scrolling around an embedded Apple Map and seeing the reported coordinates changing

With Mapkit and SwiftUI, this only takes a few lines of code:

import MapKit
import SwiftUI

struct MyMapView: View {
    @State private var region: MKCoordinateRegion = MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: MapDefaults.latitude, longitude: MapDefaults.longitude),
        span: MKCoordinateSpan(latitudeDelta: MapDefaults.zoom, longitudeDelta: MapDefaults.zoom))

    private enum MapDefaults {
        static let latitude = 45.872
        static let longitude = -1.248
        static let zoom = 0.5
    }

    var body: some View {
        VStack {
            Text("lat: \(region.center.latitude), long: \(region.center.longitude). Zoom: \(region.span.latitudeDelta)")
            .font(.caption)
            .padding()
            Map(coordinateRegion: $region,
                interactionModes: .all,
                showsUserLocation: true)
        }
    }
}

Note that showsUserLocation won’t work unless the user has already given the app permission to use their location—we’ll get to that.

region is initialized to a starting location, but it’s updated by the Map view as the user scrolls and zooms in and out.

Adding Bells and Whistles to Your Maps (Pins at Least)

Pins can be added to a map in the form of “annotations.” Let’s start with a single pin:

Embedded Apple Map showing a red pin

Annotations are provided as an array of structs where each instance must contain the coordinates of the pin. The struct must also conform to the Identifiable protocol:

struct MyAnnotationItem: Identifiable {
    var coordinate: CLLocationCoordinate2D
    let id = UUID()
}

We can now create an array of MyAnnotationItem structs:

let annotationItems = [
    MyAnnotationItem(coordinate: CLLocationCoordinate2D(
        latitude: MapDefaults.latitude,
        longitude: MapDefaults.longitude))]

We then pass annotationItems to the MapView and indicate that we want a MapMarker at the contained coordinates:

Map(coordinateRegion: $region,
    interactionModes: .all,
    showsUserLocation: true,
    annotationItems: annotationItems) { item in
        MapMarker(coordinate: item.coordinate)
    }

That gives us the result we wanted.

What if we want multiple pins? Not a problem. Just add more MyAnnotationItem instances to the array.

All of the pins will be the same default color. But, what if we want different colored pins? It’s simple to extend our code to produce this:

Embedded Apple Map showing red, yellow, and plue pins at different locations

Firstly, we need to extend MyAnnotationItem to include an optional color and a tint that returns color if it’s been defined and “red” if not:

struct MyAnnotationItem: Identifiable {
    var coordinate: CLLocationCoordinate2D
    var color: Color?
    var tint: Color { color ?? .red }
    let id = UUID()
}

In our sample data, we can now choose to provide a color for each annotation:

let annotationItems = [
    MyAnnotationItem(
        coordinate: CLLocationCoordinate2D(
            latitude: MapDefaults.latitude,
            longitude: MapDefaults.longitude)),
    MyAnnotationItem(
        coordinate: CLLocationCoordinate2D(
            latitude: 45.8827419,
            longitude: -1.1932383),
        color: .yellow),
    MyAnnotationItem(
        coordinate: CLLocationCoordinate2D(
            latitude: 45.915737,
            longitude: -1.3300991),
        color: .blue)
]

The MapView can then use the tint:

Map(coordinateRegion: $region,
    interactionModes: .all,
    showsUserLocation: true,
    annotationItems: annotationItems) { item in
    MapMarker(
        coordinate: item.coordinate,
        tint: item.tint)
}

If you get bored of pins, you can use MapAnnotation to use any view you like for your annotations:

Map(coordinateRegion: $region,
    interactionModes: .all,
    showsUserLocation: true,
    annotationItems: annotationItems) { item in
    MapAnnotation(coordinate: item.coordinate) {
        Image(systemName: "gamecontroller.fill")
            .foregroundColor(item.tint)
    }
}

This is the result:

Apple Map showing red, yellow and blue game controller icons at different locations on the map

You could also include the name of the system image to use with each annotation.

This gist contains the final code for the view.

Finding Your User’s Location

Asking for Permission

Apple is pretty vocal about respecting the privacy of their users, and so it shouldn’t be a shock that your app will have to request permission before being able to access a user’s location.

The first step is to add a key-value pair to your Xcode project to indicate that the app may request permission to access the user’s location, and what text should be displayed in the alert. You can add the pair to the “Info.plist” file:

Privacy - Location When In Use Usage Description : We'll only use your location when you ask to include it in a message

Screenshot from Xcode showing the key-value pair for requesting permission for the app to access the user's location

Once that setting has been added, the user should see an alert the first time that the app attempts to access their current location:

iPhone screenshot – app is requesting permission to access the user's location

Accessing Current Location

While Mapkit has made maps simple and native in SwiftUI, the same can’t be said for location data.

You need to create a SwiftUI wrapper for Apple’s Core Location functionality. There’s not a lot of value in explaining this boilerplate code—just copy this code from RChat’s LocationHelper.swift file, and paste it into your app:

import CoreLocation

class LocationHelper: NSObject, ObservableObject {

    static let shared = LocationHelper()
    static let DefaultLocation = CLLocationCoordinate2D(latitude: 45.8827419, longitude: -1.1932383)

    static var currentLocation: CLLocationCoordinate2D {
        guard let location = shared.locationManager.location else {
            return DefaultLocation
        }
        return location.coordinate
    }

    private let locationManager = CLLocationManager()

    private override init() {
        super.init()
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestWhenInUseAuthorization()
        locationManager.startUpdatingLocation()
    }
}

extension LocationHelper: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { }

    public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Location manager failed with error: \(error.localizedDescription)")
    }

    public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        print("Location manager changed the status: \(status)")
    }
}

Once added, you can access the user’s location with this simple call:

let location = LocationHelper.currentLocation

Store Location Data in Your Realm Database

The Location Format Expected by MongoDB

Realm doesn’t have a native type for a geographic location, and so it’s up to us how we choose to store it in a Realm Object. That is, unless we want to synchronize the data to MongoDB Atlas using MongoDB Realm Sync, and go on to use MongoDB’s geospatial functionality.

To make the best use of the location data in Atlas, we need to add a geospatial index to the field (which we’ll see how to do soon.) That means storing the location in a supported format. Not all options will work with Realm Sync (e.g., it’s not guaranteed that attributes will appear in the same order in your Realm Object and the synced Atlas document). The most robust approach is to use an array where the first element is longitude and the second is latitude:

location: [<longitude>, <latitude>]

Your Realm Object

The RChat app gives users the option to include their location in a chat message—this means that we need to include the location in the ChatMessage Object:

@objcMembers class ChatMessage: Object, ObjectKeyIdentifiable {
  …
    let location = List<Double>()
  …
    convenience init(author: String, text: String, image: Photo?, location: [Double] = []) {
        ...
    location.forEach { coord in
            self.location.append(coord)
      }
        ...
        }
    }
   ….
}

The location array that’s passed to that initializer is formed like this:

let location = LocationHelper.currentLocation
self.location = [location.longitude, location.latitude]

Location Data in Your Backend MongoDB Realm App

The easiest way to create your backend MongoDB Realm schema is to enable Development Mode—that way, the schema is automatically generated from your Swift Realm Objects.

This is the generated schema for our “ChatMessage” collection:

{
    "bsonType": "object",
    "properties": {
      "_id": {
        "bsonType": "string"
      },
      ...
      "location": {
        "bsonType": "array",
        "items": {
          "bsonType": "double"
        }
      }
    },
    "required": [
      "_id",
      ...
    ],
    "title": "ChatMessage"
}

This is a document that’s been created from a synced Realm ChatMessage object:

Screen capture of an Atlas document, which includes an array named location

Adding a Geospatial Index in Atlas

Now that you have location data stored in Atlas, it would be nice to be able to work with it—e.g., running geospatial queries. To enable this, you need to add a geospatial index to the location field.

From the Atlas UI, select the “Indexes” tab for your collection and click “CREATE INDEX”:

Atlas screen capture of creating a new index

You should then configure a 2dsphere index:

Atlas screen capture of creating a new 2dsphere index

Most chat messages won’t include the user’s location and so I set the sparse option for efficiency.

Note that you’ll get an error message if your ChatMessage collection contains any documents where the value in the location attribute isn’t in a valid geospatial format.

Atlas will then build the index. This will be very quick, unless you already have a huge number of documents containing the location field. Once complete, you can move onto the next section.

Plotting Your Location Data in MongoDB Charts

MongoDB Charts is a simple way to visualize MongoDB data. You can access it through the same UI as Realm and Atlas. Just click on the “Charts” button:

Atlas screen capture of MongoDB Charts button

The first step is to click the “Add Data Source” button:

Charts screen capture of adding a new data source

Select your Atlas cluster:

Charts screen capture of adding Atlas cluster as a data source

Select the RChat.ChatMessage collection:

Charts screen capture of selecting the ChatMessage collection in the RChat database

Click “Finish.” You’ll be taken to the default Dashboards view, which is empty for now. Click “Add Dashboard”:

Charts screen capture of adding a new dashboard

In your new dashboard, click “ADD CHART”:

Charts screen capture of adding a new chart

Configure your chart as shown here by:
– Setting the chart type to “Geospatial” and the sub-type to “Scatter.”
– Dragging the “location” attribute to the coordinates box.
– Dragging the “author” field to the “Color” box.

Charts screen capture of configuring a new chart

Once you’ve created your chart, you can embed it in web apps, etc. That’s beyond the scope of this article, but check out the MongoDB Charts docs if you’re interested.

Conclusion

SwiftUI makes it easy to embed Apple Maps in your SwiftUI apps. As with most Apple frameworks, there are extra maps features available if you break out from SwiftUI, but I’d suggest that the simplicity of working with SwiftUI is enough incentive for you to avoid that unless you have a compelling reason.

Accessing location information from within SwiftUI still feels a bit of a hack, but in reality, you cut and paste the helper code once, and then you’re good to go.

By storing the location as a [longitude, latitude] array (List) in your Realm database, it’s simple to sync it with MongoDB Atlas. Once in Atlas, you have the full power of MongoDB’s geospatial functionality to work your location data.

If you have questions, please head to our developer community website where the MongoDB engineers and the MongoDB community will help you build your next big idea with MongoDB.





Realm Partitioning Strategies

I’ve just released a new article – Realm Partitioning Strategies.

Realm partitioning can be used to control what data is synced to each mobile device, ensuring that your app is efficient, performant and secure. This article will help you pick the right partitioning strategy for your app.

MongoDB Realm Sync stores the superset of your application data in the cloud using MongoDB Atlas. The simplest strategy is that every instance of your mobile app contains the full database, but that quickly consumes a lot of space on the users’ devices and makes the app slow to start while it syncs all of the data for the first time.
Alternative strategies include partitioning by:

  • User
  • Group/team/store
  • Chanel/room/topic
  • Geographic region
  • Bucket of time
  • Any combination of these

The article discusses all of those strategies so that you adopt one, or craft a different strategy that’s customized to your app’s needs.





New article on migrating Apple’s Scrumdinger tutorial app to Realm

Apple published a great tutorial to teach developers how to create iOS apps using Swift and SwiftUI. I particularly like it because it doesn’t make any assumptions about existing UIKit experience, making it ideal for developers new to iOS. That tutorial is built around an app named “Scrumdinger,” which is designed to facilitate daily scrum meetings. Apple’s Scrumdinger implementation saves the app data to a local file whenever the user minimizes the app, and loads it again when they open the app. It seemed an interesting exercise to modify Scrumdinger to use Realm rather than a flat file to persist the data. So. I wrote “Adapting Apple’s Scrumdinger SwiftUI Tutorial App to Use Realm” to step through what changes were required to rebase Scrumdinger onto Realm. An immediate benefit of the move is that changes are now persisted immediately, so nothing is lost if the device or app crashes. It’s beyond the scope of this article, but now that the app data is stored in Realm, it would be straightforward to add enhancements such as:
  • Search meeting minutes for a string.- Filter minutes by date or attendees.
  • Sync data so that the same user can see all of their data on multiple iOS (and optionally, Android) devices.
  • Use Realm Sync Partitions to share scrum data between team members.
  • Sync the data to MongoDB Atlas so that it can be accessed by web apps or through a GraphQL API




Building a Mobile Chat App Using Realm – Integrating with Realm

I’ve just completed an article on how to integrate Realm and Realm Sync into an iOS chat app. It was timed to coincide with the GA of MongoDB Realm Sync.
Realm is used for both persisting data on the iOS device and synchronizing the data between instances of the mobile app.
The app is currently iOS-only (using SwiftUI), but we plan on building an Android version soon. One of the nice things about Realm Sync is that there’s no extra work needed to map between operating systems and languages when syncing data between iOS and Android.
That data is also synced to MongoDB Atlas and so can be accessed from web or other kinds of apps too.
The data stored and synced covers everything in the app:
  • User profile
  • User presence
  • Lists of chatrooms and members
  • The messages themselves
You can download all of the code from the GitHub repo.
Checkout Building a Mobile Chat App Using Realm – Integrating Realm into Your App for all of the details.
Also, if you want to learn more about how the app was built and ask some questions then I’ll be speaking at a virtual meetup on 17th Feb.