Skip to content
Robbie Hanson edited this page Nov 11, 2019 · 8 revisions

Mappings: Taking Views to the next level.

The YapDatabaseViewMappings class makes it easier to use Views in your app.

They're an advanced tool, and require you to understand Views. But once you wrap your head around them, you'll discover they're oh-so-powerful !

 

Intro to Mappings

The collection/key/value component of the database is unordered. You can think of it as similar to an Dictionary of Dictionaries:

// If you imagine the database like this Dictionary of Dictionaries:
[
  "movies": [ "abc123": <Movie:Goldfinger>,
              "def456": <Movie:Pretty In Pink>,
              "xyz123": <Movie:GoldenEye>, ...
            ],
  "actors": [ "abc123": <Actor:Pierce Brosnan>,
              "xyz789": <Actor:Daniel Craig>,
              "klm456": <Actor:Sean Connery>, ...
            ]
]

// Then it matches your mental model of how lookups essentially work:
let goldfinger = transaction.object(forKey: "abc123", inCollection: "movies") as? Movie

A YapDatabaseView allows you to sort the database (or a subset of it), however you want. You can think of it as similar to an Dictionary of Arrays:

// If you imagine the View like this Dictionary of Arrays:
[
  "bond movies": [ // (sorting chronologically)
                   ("movies", "abc123"), // => <Movie:Goldfinger> 
                   ("movies", "xyz123")  // => <Movie:GoldenEye>
                 ],
  "80s movies" : [ // (sorting chronologically)
                   ("movies", "def456")  // => <Movie:Pretty In Pink>
                 ],  
  "bond actors": [ // (sorting alphabetically)
                   ("actors", "xyz789"), // => <Actor:Daniel Craig> 
                   ("actors", "abc123"), // => <Actor:Pierce Brosnan>
                   ("actors", "klm456")  // => <Actor:Sean Connery>
                 ]   
]

// This should match your mental model of how lookups in a view work:
if let viewTransaction = transaction.ext("myView") as? YapDatabaseViewTransaction {
  let goldfinger = viewTransaction.object(atIndex:0, inGroup:"bond movies") 
}

There is one very important thing to notice here. A view is similar to a "dictionary of arrays" and NOT an "array of arrays".

But wait... I need an "array of arrays" for my tableView / collectionView ...

This is where mappings come in. They act as glue between your DatabaseView and your TableView or CollectionView. They allow you to choose:

  • Which groups in your database View would you like to include in your UI ?
  • What order would you like those groups to be in ?
  • Would you like to perform any transformations on your groups ?

 

Mappings 101

Say we want to display a tableView in our new JamesBondViewController. Section 0 of our tableView is to be the list of all Bond movies, sorted chronologically. And section 1 is to be the list of Bond actors, sorted alphabetically.

Now this doesn't exactly match what we have in our existing View. There's also the "80s movies" category that we don't care about here. No problem:

let groups = [ "bond movies", "bond actors" ]
let mappings = YapDatabaseViewMappings(groups: groups, view: "myView")

Here we've specified that we're only interested in 2 groups. And we've specified the order that we want them in. So "bond movies" comes before "bond actors".

And thus mappings gives us an explicit "mapping" from unordered groups in a view, to a specific order of sections in our tableView.

But mappings also support various transformations. For example, a single line of code could allow you to toggle between chronological order (oldest to newest), and reverse chronological order (newest to oldest):

mappings.setIsReversed(true, forGroup: "bond movies") // Boom

Oh yeah! Achievement unlocked!

Wait, wait... What? How the heck does that work?

The view itself doesn't change at all. (Which means no disk IO.) Only the mappings change. It works seamlessly for you because the mappings layer can sit between your code and the actual view instance. Like so:

if let viewTransaction = transaction.ext("myView") as? YapDatabaseViewTransaction {
  
  // Our mappings is configured to reverse this section.
  // So we're going to get goldeneye (not goldfinger)
  let goldeneye = viewTransaction.object(atRow:0, inSection:0, withMappings:mappings) as? Movie
  // 
  // Basically, the mappings layer automatically transformed the above method call into:
  // viewTransaction.object(atIndex:1, inGroup:"bond movies")
}

One of the beautiful things about mappings, is that much of your code becomes boilerplate. You can simply ask mappings for the information you need:

func numberOfSections(in tableView: UITableView) -> Int {
  if let mappings = self.mappings {
    return Int(mappings.numberOfSections())
  } else {
    return 0
  }
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  if let mappings = self.mappings {
    return Int(mappings.numberOfItems(inSection: UInt(section)))
  } else {
    return 0
  }
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  
  var obj: Any? = nil
  dbConnection.read {(transaction) in
	  if let viewTransaction = transaction.ext("myView") as? YapDatabaseViewTransaction,
       let mappings = self.mappings
    {
      obj = viewTransaction.object(at: indexPath, with: mappings)
    }
  }
  
  // ...
}

And the mappings layer can perform a bunch of other tricks. Which are described below.

 

Managing Mappings

You can create a YapDatabaseViewMappings instance using either a static list of groups, or a dynamic list.

In the example above, we used a static list of groups because we knew exactly what we wanted ahead of time:

let groups = ["bond movies", "bond actors"]
let mappings = YapDatabaseViewMappings(groups:groups, view:"myView")

We can also create mappings using a dynamic list of groups. This is useful when you don't know all the group names ahead of time:

let filtering: YapDatabaseViewMappingGroupFilter = {(group, transaction) in
  return true // group == departmentName; Include all departments.
}
let sorting: YapDatabaseViewMappingGroupSort = (group1, group2, transaction) in
  return group1.compare(group2) // sorted by department name
}

let mappings = YapDatabaseViewMappings(groupFilterBlock: filtering, sortBlock: sorting, view: "myView")

After you've created the mappings, you need to initialize them once. The recommended way to do this is:

override func viewDidLoad() {

  // ...

  // Freeze our databaseConnection on the current commit.
  // This gives us a snapshot-in-time of the database,
  // and thus a stable data source for our UI thread.
  dbConnection.beginLongLivedReadTransaction()
    
  // Initialize our mappings.
  // Note that we do this AFTER we've started our database longLived transaction.
  initializeMappings()

  // And register for notifications when the database changes.
  // Our method will be invoked on the main-thread,
  // and will allow us to move our stable data-source from
  // our existing commit to an updated commit.
  let nc = NotificationCenter.default
  nc.addObserver( self,
	      selector: #selector(self.yapDatabaseModified(notification:)),
	          name: Notification.Name.YapDatabaseModified,
	        object: nil)
}

func initializeMappings() {
  
  dbConnection.read {(transaction) in
    guard
      let _ = transaction.ext("myView") as? YapDatabaseViewTransaction
    else {
      // The underlying View isn't ready yet.
      // Delay creating mappings until it's ready.
      return
    }
    
    // The view may have a whole bunch of groups.
    // In this example, we just want to look at a single group (1 section).
    let groups = ["bond movies", "bond actors"]
    mappings = YapDatabaseViewMappings(groups:groups, view:"myView")
    
    // One-time initialization
    mappings?.update(with: transaction)
  }
}

Now let's look at what we need to do when we get a YapDatabaseModifiedNotification:

@objc func yapDatabaseModified(notification: Notification) {

  // Jump to the most recent commit.
  // End & Re-Begin the long-lived transaction atomically.
  // Also grab all the notifications for all the commits that I jump.
  // If the UI is a bit backed up, I may jump multiple commits.
  let notifications = dbConnection.beginLongLivedReadTransaction()

  guard let mappings = self.mappings {
    initializeMappings()
    self.tableView.reloadData()
    return
  }
  
  guard let ext = dbConnection.ext("myView") as? YapDatabaseViewConnection else {
    return
  }
  
  // Process the notification(s),
  // and get the change-set(s),
  // as applies to my view and mappings configuration.
  let (sectionChanges, rowChanges) = ext.getChanges(forNotifications: notifications, withMappings: mappings)
  
  if (sectionChanges.count == 0) && (rowChanges.count == 0) {
    // There aren't any changes that affect our tableView!
    return
  }

  // Animate tableView updates !
  tableView.beginUpdates()
  for change in sectionChanges {
    switch change.type {
      case .delete:
        tableView.deleteSections(IndexSet(integer: Int(change.index)), with: .automatic)

      case .insert:
        tableView.insertSections(IndexSet(integer: Int(change.index)), with: .automatic)
      
      default: break
    }
  }
  for change in rowChanges {
    switch change.type {
      case .delete:
        tableView.deleteRows(at: [change.indexPath!], with: .automatic)

      case .insert:
        tableView.insertRows(at: [change.newIndexPath!], with: .automatic)

      case .move:
        tableView.moveRow(at: change.indexPath!, to: change.newIndexPath!)
      
      case .update:
        tableView.reloadRows(at: [change.indexPath!], with: .automatic)

      default: break
    }
  }
  tableView.endUpdates()
}

 

Gotchas

It's important to understand the following:

Mappings are implicitly tied to a databaseConnection's longLivedReadTransaction. That is, when you invoke dbConnection.beginLongLivedReadTransaction() you are freezing the connection on a particular commit (a snapshot of the database at that point in time). Mappings MUST always be on the same snapshot as its corresponding dbConnection. Which means EVERY-TIME you move the dbConnection to a newer commit, you MUST also update the mappings.

If you fail to do this, you'll eventually get an exception that looks like this:

YapDatabaseViewConnection: Throwing exception: ViewConnection[0x10d56cc80, RegisteredName=order] was asked for changes, but given mismatched mappings & notifications.

Here's an example of some bad code that would cause this exception:

@objc func yapDatabaseModified(notification: Notification) {

  // Move my databaseConnection to the most recent commit.
  let notifications = dbConnection.beginLongLivedReadTransaction()

  // We added this optimization to our code,
  // and then we started to get CRASHES...
  if self.isViewVisible() == false {
    // Oh snap !
    // I updated my databaseConnection to a new commit / snapshot,
    // but I failed to update my mappings. That's BAD.
    
    return; // Danger ! There's a crash in my immediate future !
  }

  // Normal code here...
}

Why do I need to update the mappings?

Because the method getChanges(forNotifications:withMappings:) can only guarantee it gives you the right change-set if it's in-sync with the connection. Otherwise all bets are off, and the app probably crashes in tableView.endUpdates().

But fear not. There's a shortcut to quickly update mappings if you don't need the UI animation info. Here's how easy it is:

@objc func yapDatabaseModified(notification: Notification) {
  
  // Move my databaseConnection to the most recent commit.
  let notifications = dbConnection.beginLongLivedReadTransaction()

  if self.isViewVisible() == false {
    // We've disabled the tableView in this situation.
    // It's configured to supply zero sections & zero rows.
    // 
    // For best practice,
    // we update th mappings so they're on the same commit as the connection.
    dbConnection.read {(transaction) in
      mappings.update(with: transaction)
    }
    return // Good to go :)
  }

  // Normal code here...
}

 

Dynamic Sections

If a section is empty, should it disappear from the tableView / collectionView ?

This is obviously a question for you to decide. Either way, mappings can help.

A "dynamic" section is how mappings refers to a section that automatically disappears when it becomes empty. If the section later becomes non-empty, it reappears.

For example: You're making a chat application, and you're designing the tableView for the roster. You have 3 sections:

  • available
  • away
  • offline

You want the "available" and "away" sections to automatically get removed if they don't have any users in them. But you've decided you want the "offline" section to always be visible. In mappings terminology, the "available" and "away" sections are "dynamic", and the "offline" section is "static". Here's how you would configure this:

override func viewDidLoad() {

  // Freeze our connection for use on the main-thread.
  // This gives us a stable data-source that won't change until we tell it to.
  dbConnection.beginLongLivedReadTransaction()

  // Initialize the mappings.
  // Notice that we do this AFTER starting the long-lived transaction.
  initializeMappings()
  
  // Register for notifications when the database changes.
  // Our method will be invoked on the main-thread,
  // and will allow us to move our stable data-source
  // from our existing commit to an updated commit.
  let nc = NotificationCenter.default
  nc.addObserver( self,
	      selector: #selector(self.yapDatabaseModified(notification:)),
	          name: Notification.Name.YapDatabaseModified,
	        object: nil)
}

func initializeMappings() {
  
  dbConnection.read {(transaction) in
    guard
      let _ = transaction.ext("roster") as? YapDatabaseViewTransaction
    else {
      // The underlying View isn't ready yet.
      // Delay creating mappings until it's ready.
      return
    }
                     
    let groups = ["available", "away", "offline"]
    mappings = YapDatabaseViewMappings(groups: groups, view: "roster")

    mappings?.setIsDynamicSection(true,  forGroup: "available")
    mappings?.setIsDynamicSection(true,  forGroup: "away")
    mappings?.setIsDynamicSection(false, forGroup: "offline")
    
    // One-time initialization
    mappings?.update(with: transaction)
  }
}

 

Fixed Ranges

Fixed ranges are the equivalent of using a LIMIT & OFFSET in a SQL query.

For example: You have a view which sorts items by "sales rank". That is, the best selling items are at index zero, and the worst selling items are at the end. You want to display the top 20 best selling items in a tableView. Here's all you have to do:

func initializeMappings() {
  
  dbConnection.read {(transaction) in
    guard
      let _ = transaction.ext("salesRank") as? YapDatabaseViewTransaction
    else {
      // The underlying View isn't ready yet.
      // Delay creating mappings until it's ready.
      return
    }
                     
    let groups = ["books"]
    mappings = YapDatabaseViewMappings(groups: groups, view: "salesRank")

    let rangeOptions = YapDatabaseViewRangeOptions.fixedRange(withLength: 20, offset: 0, from: .beginning)
    mappings?setRangeOptions(rangeOptions, forGroup: "books")
    
    // One-time initialization
    mappings?.update(with: transaction)
  }
}

It's just as easy if you want the LAST 35 in a view:

let rangeOptions = YapDatabaseViewRangeOptions.fixedRange(withLength: 35, offset: 0, from: .end)
mappings?setRangeOptions(rangeOptions, forGroup: conversationId)

Mappings will automatically handle the rest. This includes edge cases, such as when there are fewer items than the specified length. But, more importantly, it includes live updates. That is, the boiler-plate code for animating tableView/collectionView updates will just work.

 

Flexible Ranges

A fixed range isn't always what you want. Sometimes you need something a little more flexible...

A flexible range is similar to a fixed range, but is allowed to grow and shrink automatically based on its configuration.

Consider Apple's Messages app:

When you go into a conversation (tap on a persons name), the messages app starts by displaying the most recent 50 messages (with the most recent at bottom). Although there might be thousands of old messages between you and the other person, only 50 are in the view to begin with. (This keeps the UI snappy.)

But as you send and/or receive messages within the view, the length will grow. And similarly, if you manually delete messages, the length will shrink.

You can achieve something like this in SQL using additional WHERE clauses. That is, you'd have to first figure out the timestamp for the message that's 50 back from the latest message. And then you'd write the SQL query with an additional "WHERE timestamp >= minTimestamp" clause.

A flexible range is similar to this. But easier to deal with. You can simply specify what you want (in terms of numbers), and let it handle the rest. To get the functionality of Apple's Messages app:

func initializeMappings() {
  
  dbConnection.read {(transaction) in
    guard
      let _ = transaction.ext("messages") as? YapDatabaseViewTransaction
    else {
      // The underlying View isn't ready yet.
      // Delay creating mappings until it's ready.
      return
    }
    
    // Setup and configure our mappings
    let groups = [conversationId]
    mappings = YapDatabaseViewMappings(groups: groups, view: "messages")

    let rangeOptions = YapDatabaseViewRangeOptions.flexibleRange(withLength: 50, offset: 0, from: .end)
    
    // Let's also set a max, so that if the conversation grows to some obnoxious length,
    // our UI doesn't get bogged down.
    rangeOptions.maxLength = 300    

    // We can set a min length too.
    // So if the user goes and manually deletes most of the messages,
    // we can automatically "move backwards" and display older messages.
    rangeOptions.minLength = 20

    mappings?.setRangeOptions(rangeOptions, forGroup: conversationId)
    
    // One-time initialization
    mappings?.update(with: transaction)
  }
}

There are a few interesting things to notice in the code sample.

First, the flexible range is "pinned" to the end. That is, it is configured to have offset=0 from the end.

Both fixed and flexible ranges allow you to pin the range to the beginning or end. Pinning a range to the end is convenient because it allows you to specify "what you want" without getting bogged down with the math.

Second, flexible ranges allow you to specify max and min lengths. If the range length grows to surpass the max length, then it will automatically start truncating items from the side opposite its pinned side. (That is, it will start acting similar to a fixed range.) And if the range length drops below the min length, then the range will attempt to automatically grow to have at least min length.

Again, you can specify "what you want" without having to code all the edge cases.

A note about offsets in flexible ranges:

Consider the following code:

let rangeOptions = YapDatabaseViewRangeOptions.flexibleRange(withLength: 50, offset: 25, from: .end)
mappings?.setRangeOptions(rangeOptions, forGroup: "books")

The range is pinned to the end, but it has an offset of 25. So if there are 100 items, the range displays items 25-75. This is as expected. But what happens if an item it added to the end, such that there are now 101 items?

Just as a flexible range can grow & shrink in length, its offset can grow and shrink. So a flexible range will keep the same items within its range by increasing the offset to 26. From a strict database perspective, this may seem a bit odd at first. But from a user interface perspective, its what you'd want to keep the UI consistent. The UI is displaying a certain subset of cells. We want it to continue displaying those cells even if items outside our range get added / deleted. In other words, the range stays put where you told it to be, which means the UI stays put where you told it to be.

 

Cell Drawing Dependencies

If you've ever encountered this problem before, this one will make you smile.

There are times when the drawing of cell B is dependent upon cell A. That is, in order to draw certain components of a cell, we need to know something about the neighboring cell.

Consider Apple's Messages app:

If more than a certain amount of time has elapsed between a message and the previous message, then a timestamp is drawn. The timestamp is actually drawn at the top of a cell. So cell-B would draw a timestamp at the top of its cell if cell-A represented a message that was sent/received say 3 hours prior to cell-B's message.

Another example can be found in Skype's app:

The first message from a particular user is displayed with the name and avatar. Subsequent messages from the same user (without other messages in between) are displayed with the same background color, but don't display the name or avatar. In this manner, multiple consecutive messages from the same user get displayed cleanly without taking up too much space.

This is where "cell drawing dependencies" can make your life easier.

You can actually tell the mappings object that you have such dependencies. And it will automatically emit proper row updates to make sure your UI stays consistent:

func initializeMappings() {
  
  dbConnection.read {(transaction) in
    guard
      let _ = transaction.ext("messages") as? YapDatabaseViewTransaction
    else {
      // The underlying View isn't ready yet.
      // Delay creating mappings until it's ready.
      return
    }
                     
    // Setup and configure our mappings
    let groups = [conversationId];
    mappings = YapDatabaseViewMappings(groups: groups, view: "messages")

    // Our timestamp calculations depend upon the previous cell
    mappings?.setCellDrawingDependencyForNeighboringCellWithOffset(-1, forGroup: conversationId)
    
    // One-time initialization
    mappings?.update(with: transaction)
  }
}

For example, say you have items in the table like this:

  • You there? (4 PM)
  • Hi (8 PM) [timestamp header]
  • You done coding yet?? (8:01 PM)

Then the user manually deletes the "Hi" message. And when you ask for the change-set:

let (sectionChanges, rowChanges) = viewConnection.getChanges(forNotifications: notifications, withMappings: mappings)

It will include both:

  • row delete for the "Hi" message
  • row update for the "You done coding yet??" message.

So your tableView will automatically update to become this (for free!):

  • You there? (5 PM)
  • You done coding yet?? (8:01 PM) [timestamp header]

 

Reverse

If you need to display items from a view, but in the opposite order, then the reverse option can help.

For example: You have a view which sorts items by "sales rank". That is, the best selling items are at index zero, and the worst selling items are at the end. You want to display the 20 worst selling items in a tableView. (Candidates for the clearance section to make room for new inventory.) BUT, you want the #1 worst selling item to be at the TOP of the tableView. This is actually the opposite order from how they appear in the "sales rank" view.

You many notice this example is similar to the one from the fixed ranges section.

Here's all you need to do:

func initializeMappings() {
  
  dbConnection.read {(transaction) in
    guard
      let _ = transaction.ext("SalesRank") as? YapDatabaseViewTransaction
    else {
      // The underlying View isn't ready yet.
      // Delay creating mappings until it's ready.
      return
    }
                     
    let groups = ["books"]
    mappings = YapDatabaseViewMappings(groups: groups, view: "SalesRank")

    mappings?.setIsReversed(true, forGroup: "books")

    // One-time initialization
    mappings?.update(with: transaction)
  }
}

Pretty easy. And it means you get to skip the tedious math & logic.

One of the nice things about the reverse option is that order matters. This allows you to configure your mappings in the way that makes the most sense to you. For example:

// The following code

let rangeOptions = YapDatabaseViewRangeOptions.fixedRange(withLength: 20, offset: 0, from: .end)
mappings?.setRangeOptions(rangeOptions, forGroup: "books") // <- 1st

mappings?.setIsReversed(true, forGroup: "books") // <- 2nd

// IS EQUIVALENT TO:

mappings?.setIsReversed(true, forGroup: "books") // <- 1st

let rangeOptions = YapDatabaseViewRangeOptions.fixedRange(withLength: 20, offset: 0, from: .beginning) // <- 2nd
mappings?.setRangeOptions(rangeOptions, forGroup: "books")

Once you set a group to be reversed, you are free to configure it (from that point forward) as if it was reversed. Both configuration techniques are equivalent. It's simply a matter of how you visualize it.

  1. I visualize getting the last 20 items from the view, and then reversing them.
  2. I visualize reversing the entire view, and then getting the first 20.

So you're free to configure mappings in the same manner that you visualize it.

Clone this wiki locally