Archive for September 30, 2021

Most Useful iOS 15 SwiftUI Features

Introduction

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

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

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

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

Prerequisites

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

Lists

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

Custom Swipe Options

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

This was a massive pain.

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

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

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

The swipe actions are implemented in TasksView:

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

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

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

struct CompleteButton: View {
   @ObservedRealmObject var task: Task

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

Searchable Lists

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

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

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

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

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

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

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

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

This is the full view:

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

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

   @State private var searchText = ""

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

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

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

Pull to Refresh

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

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

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

Animation showing currencies being refreshed when the screen is dragged dowm

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

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

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

Text

Markdown

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

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

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

Text formatted. Included bold, italics and a link

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

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

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

Raw Markdown source code, rather than rendered text

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

struct MarkDown: View {
   let text: String

   @State private var formattedText: AttributedString?

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

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

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

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

RChat now supports markdown in user messages:

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

Dates

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

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

I created a TextDate view to perform this conditional formatting:

struct TextDate: View {
   let date: Date

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

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

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

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

Screen capture of dates rendered in various formatt

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

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

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

Keyboards

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

Submit Labels

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

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

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

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

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

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

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

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

Focus

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

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

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

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

enum Field: Hashable {
   case username
   case password
}

@FocusState private var focussedField: Field?

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

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

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

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

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

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

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

This is the complete body from LoginView:

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

Buttons

Formatting

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

Things get simpler in iOS 15.

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

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

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

Xcode. Showing button source code and the associated previews

Confirmation

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

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

Again, iOS 15 makes this simple.

This is the modified version of the LogoutButton view:

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

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

These are the changes I made:

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

Material

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

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

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

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

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

The result looks like this:

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

Developer Tools

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

Landscape Previews

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

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

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

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

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

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

Self._printChanges

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

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

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

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

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

ContentView: _username changed.

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

Conclusion

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

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

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

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

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





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

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

Introduction

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

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

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

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

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

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

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

Prerequisites

The RCurrency Mobile App

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

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

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

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

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

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

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

The REST API

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

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

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

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

You can try calling that endpoint directly from your browser.

The endpoint responds with a JSON document:

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

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

The RCurrency App Implementation

Data Model

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

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

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

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

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

class Info: Codable {
    var rate = 0.0
}

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

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

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

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

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

extension Symbols {
    static var data = Symbols()


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

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

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

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

Rate Data Lifecycle

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

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

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

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

#1 CurrencyContainerView loaded for currency represented by symbol

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

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

#2 rate = FetchFromRealm(symbol)

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

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

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

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

#3 rate found?

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

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

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

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

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

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

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

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

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

#6 Refresh Requested?

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

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

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

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

refreshNeeded is passed to each instance of CurrencyRowContainerView:

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

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

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

#4-6 Fetch rate from API — Refresh requested

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

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

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

#5-6 StoreInRealm(rate) — Refresh requested

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

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

#7 rate stale?

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

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

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

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

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

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

#4-7 Fetch rate from API — rate stale

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

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

#5-7 StoreInRealm(rate) — rate stale

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

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

#8 View rendered with rate

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

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

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

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

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

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

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

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

Conclusion

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

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

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

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

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

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

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

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

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





Migrating Your iOS App’s Synced Realm Schema in Production

Introduction

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

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

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

Prerequisites

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

Catch-Up — The RChat App

RChat is a basic chat app:

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

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

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

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

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

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

Updating the Backend Realm Schema

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

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

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

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

The resulting schema looks like this:

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

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

Updating the iOS RChat App

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

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

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

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

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

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

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

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

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

Oops!

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

Updating the Backend Realm Schema

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

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

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

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

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

Define the Schema for the Partner Collection

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

Click the button to create a new schema:

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

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

Setting the database and collection names in the Realm UI

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

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

This is the resulting schema:

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

Copy Existing Data to the Partner Collection

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

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

Pressing a button to pause Realm sync in the Realm UI

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

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

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

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

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

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

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

Click “MERGE DOCUMENTS”:

Clicking the "Merge Documents" button in the Realm UI

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

Add Database Triggers

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

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

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

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

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

Paste this code into the function editor and save:

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


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


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


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

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

Create the copyToChatMessage function in the same way:

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


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

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

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

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

Configure the ChatMessageProp trigger as shown:

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

Repeat for ChatMessageV2Change:

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

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

Updating the iOS RChat App

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

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

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

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

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

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

In Xcode, select the "ChatMessage" class name, right-click and select "Refactor -> Rename…"” /></p>
<p>Override the class name with <code>ChatMessageV2</code> and click “Rename”:</p>
<p><img 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.