This is a Swift 3 and Realm 2.1 update to the original article about Realm Notifications. The code and text are up to date, you’re welcome!
What are Fine-Grained Notifications?
Prior to Realm Objective-C & Swift 0.99, you could observe for changes on your Results
, List
, or AnyRealmCollection
types by adding a notification block. Any time that any of the data you were watching changed, you would get notified and could trigger an update to your UI.
A lot of people asked for more precise information about what exactly changed in the underlying data so they could implement more flexible updates to their app’s UI. The new API provides information not only about the change in general, but also about the precise indexes that have been inserted into the data set, deleted from it, or modified.
The new API takes a closure which takes a RealmCollectionChange
. This closure will be called whenever the data you are interested in changes. You can read more about using this new method in our docs on Collection Notifications, or simply follow this tutorial through for a practical example!
Building a GitHub Repository List App
In this post we’re going to look into creating a small app that shows all the GitHub repositories for a given user. The app will periodically ping GitHub’s JSON API and fetch the latest repo data, like the amount of stars and the date of the latest push.
If you want to dig through the complete app’s source code as you read this post, go ahead and clone the project.
The app is quite simple and consists of two main classes - one called GitHubAPI
, which periodically fetches the latest data from GitHub, and the other is the app’s only view controller, which displays the repos in a table view.
Naturally, we’ll start by designing a Repo
model class in order to be able to persist repositories in the app’s Realm:
import RealmSwift
class Repo: Object {
//MARK: properties
dynamic var name = ""
dynamic var id: Int = 0
dynamic var stars = 0
dynamic var pushedAt: NSTimeInterval = 0
//MARK: meta
override class func primaryKey() -> String? { return "id" }
}
The class stores four properties: the repo name, the number of stars, the date of the last push, and, last but not least, the repo’s id
, which is the primary key for the Repo
class.
GitHubAPI
will periodically re-fetch the user’s repos from the JSON API. The code would loop over all JSON objects and for each object will check if the id
already exists in the current Realm and update or insert the repo accordingly:
if let repo = realm.object(ofType: Repo.self, forPrimaryKey: id) {
//update - we'll add this later
} else {
//insert values fetched from JSON
let repo = Repo()
repo.name = name
repo.stars = stars
repo.id = id
repo.pushedAt = Date(fromString: pushedAt, format: .iso8601(.DateTimeSec)).timeIntervalSinceReferenceDate
realm.add(repo)
}
This piece of code inserts all new repos that GitHubAPI
fetches from the web into the app’s Realm.
Next we’ll need to show all Repo
objects in a table view. We’ll add a Results<Repo>
property to ViewController
:
let repos: Results<Repo> = {
let realm = try! Realm()
return realm.objects(Repo.self).sorted(byProperty: "pushedAt", ascending: false)
}()
var token: NotificationToken?
repos
defines a result set of all Repo
objects sorted by their pushedAt
property, effectively ordering them from the most recently updated repo to the one getting the least love. 💔
The view controller will need to implement the basic table view data source methods, but those are straightforward so we won’t go into any details:
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return repos.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let repo = repos[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "RepoCell") as! RepoCell
cell.configureWith(repo)
return cell
}
}
Inserting New Repos
Next, we’ll need to react to updates: In viewDidLoad()
we’ll add a notification block to repos
, using the new (bam! 💥) fine-grained notifications:
token = repos.addNotificationBlock {[weak self] (changes: RealmCollectionChange) in
guard let tableView = self?.tableView else { return }
switch changes {
case .initial:
tableView.reloadData()
break
case .update(let results, let deletions, let insertions, let modifications):
tableView.beginUpdates()
//re-order repos when new pushes happen
tableView.insertRows(at: insertions.map { IndexPath(row: $0, section: 0) },
with: .automatic)
tableView.deleteRows(at: deletions.map { IndexPath(row: $0, section: 0) },
with: .automatic)
//flash cells when repo gets more stars
for row in modifications {
let indexPath = IndexPath(row: row, section: 0)
let repo = results[indexPath.row]
let cell = tableView.cellForRow(at: indexPath) as! RepoCell
cell.configureWith(repo)
}
tableView.endUpdates()
break
case .error(let error):
print(error)
break
}
}
This is quite a long piece of code so let’s look what’s happening in there. We add a notification block to repos
and create a local constant tableView
to allows us to work with the controller’s table view.
The key to making the most of fine-grained notifications is the changes
parameter that you get in your notification block. It is a RealmCollectionChange
enumeration and there are three different values:
.initial(let result)
- This is the very first time the block is called; it’s the initial data you get from yourResults
,List
, etc. It does not contain information about any updates, because you still don’t have previous state - in a sense all the data has just been “inserted”. In the example above, we don’t need to use theResults
object itself - instead we simply calltableView.reloadData()
to make sure the table view shows what we need..update(let result, let insertions, let deletions, let updates)
- This is the case you get each time after the initial call. The last three parameters are[Int]
, arrays of integers, which tell you which indexes in the data set have been inserted, deleted, or modified..error(let error)
- This is everyone’s least favorite case - something went wrong when refreshing the data set.
Since we’re looking into how to handle fine-grained notifications, we are interested in the line that goes over insertions
and adds the corresponding rows into the table view:
tableView.insertRows(at: insertions.map { IndexPath(row: $0, section: 0) },
with: .automatic)
We convert (or map
if you will) insertions from a [Int]
to [NSIndexSet]
and pass it to insertRows(at:, with:)
. That’s all it takes to have the table view update with a nice animation!
When we run the app for the very first time it will fall on the .initial
case, but since there won’t be any Repo
objects yet (because we haven’t fetched anything yet), tableView.reloadData()
will not do anything visible on screen.
Each time you start the app after the very first time, there will be stored Repo objects, so initially the app will show the existing data and will update it with the latest values when it fetches the latest JSON from the web.
When the GitHubAPI
fetches the user’s repos from the API, the notification block will be called again and this time insertions
will contain all the indexes where repos were inserted much like so:
[0, 1, 2, 3, 4, 5, 6, etc.]
The table view will display all inserted rows with a nice animation:
That’s neat, right? And since GitHubAPI
is periodically fetching the latest data, when the user creates a new repo it will pop up shortly in the table view like so (i.e., it comes as another insertion update when it’s saved into the app’s Realm):
Re-ordering the list as new data comes in
repos
is ordered by pushedAt
, so any time the user pushes to any of their repositories that particular repo will move to the top of the table view.
When the order of the data set elements changes the notification block will get called with both insertions
and deletions
indexes:
insertions = [0]
deletions = [5]
What happened in the example above is that the element that used to be at position 5 (don’t forget the repos are ordered by their last push date) moved to position 0. This means we will have to update the table view code to handle both insertions and deletions:
tableView.insertRows(at: insertions.map { IndexPath(row: $0, section: 0) },
with: .automatic)
tableView.deleteRows(at: deletions.map { IndexPath(row: $0, section: 0) },
with: .automatic)
Do you see a pattern here? The parameters you get in the .update
case suit perfectly the UITableView
API. #notacoincidence
With code to handle both insertions and deletions in place, we only need to look into updating the stored repos with the latest JSON data and reflect the changes in the UI.
Back in GitHubAPI
, we will need our code to update or insert depending on whether a repo with the given id
already exists. The initial code that we had turns into:
if let repo = realm.object(ofType: Repo.self, forPrimaryKey: id) {
//update - this is new!
let lastPushDate = Date(fromString: pushedAt, format: .iso8601(.DateTimeSec))
if repo.pushedAt.distance(to: lastPushDate.timeIntervalSinceReferenceDate) > 1e-16 {
repo.pushedAt = lastPushDate.timeIntervalSinceReferenceDate
}
if repo.stars != stars {
repo.stars = stars
}
} else {
//insert - we had this before
let repo = Repo()
repo.name = name
repo.stars = stars
repo.id = id
repo.pushedAt = Date(fromString: pushedAt,
format: .iso8601(.DateTimeSec)).timeIntervalSinceReferenceDate
realm.add(repo)
}
This code checks if pushedAt
is newer in the received JSON data than the date we have in Realm and if so, updates the pushed date on the stored repo.
(It also checks if the star count changed and updates accordingly the repo. We’ll use this info in the next section.)
Now, any time the user pushes to one of their repositories on GitHub, the app will re-order the list accordingly (watch the jazzy repo below):
You can do the re-ordering in a more interesting way in certain cases. If you are sure that a certain pair of insert and delete indexes is actually an object being moved across the data set look into
UITableView.moveRow(at:, to:)
for an even nicer move animation.
Refreshing table cells for updated items
If you are well-versed with the UITableView
API, you probably already guessed that we could simply pass the modifications
array to UITableView.reloadRows(at:, with:)
and have the table view refresh rows that have been updated.
However… that’s too easy. Let’s spice it up a notch and write some custom update code!
When the star count on a repo changes the list will not re-order, thus it will be difficult for the user to notice the change. Let’s add a smooth flash animation on the row that got some stargazer love, to attract the user’s attention. ✨
In our custom cell class we’ll need a new method:
func flashBackground() {
backgroundView = UIView()
backgroundView!.backgroundColor = UIColor(red: 1.0, green: 1.0, blue: 0.7, alpha: 1.0)
UIView.animate(withDuration: 2.0, animations: {
self.backgroundView!.backgroundColor = UIColor.white
})
}
That new method replaces the cell background view with a bright yellow view and then tints it slowly to white.
Let’s call that new method on any cells that need to display updated star count. Back in the view controller we’ll add under the .Update
case:
... //initial case up here
case .update(let results, let deletions, let insertions, let modifications):
... //insert & delete rows
for row in modifications {
let indexPath = IndexPath(row: row, section: 0)
let cell = tableView.cellForRow(at: indexPath) as! RepoCell
let repo = results[indexPath.row]
cell.configureWith(repo)
cell.flashBackground()
}
break
... //error case down here
We simply loop over the modifications
array and build the corresponding table index paths to get each cell that needs to refresh its UI.
We fetch the Repo
object from the updated results
and pass it into the configureWith(_:)
method on the cell (which just updates the text of the cell labels). Finally we call flashBackground()
on the cell to trigger the tint animation.
Oh hey - somebody starred one of those repos as I was writing this post:
(OK, it was me who starred the repo - but my point remains valid. 😁)
Conclusion
As you can see, building a table view that reacts to changes in your Realm is pretty simple. With fine-grained notifications, you don’t have to reload the whole table view each time. You can simply use the built-in table APIs to trigger single updates as you please.
Keep in mind that Results
, List
, and other Realm collections are designed to observe changes for a list of a single type of objects. If you’d like to try building a table view with fine-grained notifications with more than one section you might run into complex cases when you will need to batch the notifications so that you can update the table with a single call to beginUpdates()
and endUpdates()
.
If you want to give the app from this post a test drive, you can clone it from this repo.
We’re super excited to have released the most demanded Realm feature of all time! You can read more in the Swift Docs, and show us what you built on Twitter.
Receive news and updates from Realm straight to your inbox