All videos. All books. One low price.

Get unlimited access to all video courses and books on this site with the new raywenderlich.com Ultimate Subscription. Plans start at just $19.99/month.

Home iOS & Swift Tutorials

Indoor Maps on iOS: Advanced MapKit

In this MapKit tutorial, you’ll learn how to use Indoor Maps to map the inside of buildings, switch between different stories and find your location inside the building.

5/5 7 Ratings

Version

  • Swift 5, iOS 14, Xcode 12

Apple introduced Indoor Maps to MapKit in iOS 13. This program provides tools to map the inside of physical structures, allowing users to navigate a building from the inside.

In this tutorial, you’ll learn how to use Indoor Maps to add the location of the RazeWare office to a map. In the process, you’ll learn to:

  • Parse Indoor Mapping Data Format (IMDF) data into models.
  • Draw geometry from GeoJSON on a map.
  • Style map geometry.
  • Switch between different levels of a structure.
  • Access indoor locations.

Are you ready for a full tour inside RazeWare HQ? Well, read on!

Note: This advanced-level tutorial assumes you’re comfortable building an iOS app in Xcode with Swift. This tutorial uses MapKit. If you’re unfamiliar with MapKit, read MapKit Tutorial: Getting Started first.

Getting Started

Download the starter project using the Download Materials button at the top or bottom of this tutorial. Open the starter project. Build and run to see what you’re working with.

RazeMap initial screen showing map of the United Kingdom

For the less observant among you, it’s a map. :]

Yoda, a map it is

The starter project already contains the user interface, some model files and the IMDF archive you’ll work with. Right now, it’s not an overly inspiring app; it certainly wouldn’t get past the Apple Genius in app review. But you’re going to change that!

Understanding Indoor Maps

Before you start writing code, it’s important to know what kind of data you’re working with. In this tutorial, you’ll use two standards: GeoJSON and IMDF.

What Is GeoJSON?

GeoJSON is a format for representing geographic data structures. As the name implies, GeoJSON is based on the JSON format. The Internet Engineering Task Force (IETF) released it in 2015 to standardize the way programmers model geographical data.

GeoJSON supports a range of different geometry types defined using latitude and longitude pairs. You can combine the lat-long pairs to form more complex structures. A GeoJSON object may represent a:

  • Geometry: A region of space.
  • Feature: A spatially-bound entity.
  • FeatureCollection: A list of Features.

Building geometry with lat-long pairs

Take a look at the building structure outlined in the map above. Each point in the geometry of the structure has a latitude and a longitude coordinate, the same way you’d have an x-y position in a Cartesian coordinate system. Each point is labeled from 1 to 6, representing the order in which they’re drawn. The column on the left shows the actual lat-long coordinates for each point.

The GeoJSON representation of this feature looks like this:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": {
        "stroke": "#101889",
        "stroke-width": 2,
        "stroke-opacity": 1,
        "fill": "#98a1e6",
        "fill-opacity": 0.5
      },
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [
            [
              -121.80389642715454,
              37.33966009140741
            ],
            [
              -121.80312395095825,
              37.338790026148
            ],
            [
              -121.80198669433592,
              37.33937007077421
            ],
            [
              -121.80222272872923,
              37.33960891137705
            ],
            [
              -121.80301666259766,
              37.33966009140741
            ],
            [
              -121.80331707000731,
              37.33998423078978
            ],
            [
              -121.80389642715454,
              37.33966009140741
            ]
          ]
        ]
      }
    }
  ]
}

You can recreate this exact structure yourself. Head over to geojson.io and paste the GeoJSON above into the editor on the right. Since each element in the array is a lat-long pair, the structure will appear in the same place as the image shown previously.

What Is IMDF?

Apple introduced the Indoor Mapping Data Format (IMDF) with iOS 13 as a new standard for modeling the inside of a structure. An IMDF archive contains a group of feature types that, when combined, can describe an indoor space including walls, doorways, stairs, levels and more. You usually create an IMDF archive by using third-party software to convert a detailed floor plan into IMDF.

Note: Creating an IMDF archive is out of scope for this tutorial. If you want to learn more about it, here’s a great tutorial on Creating and Validating IMDF Datasets.

IMDF Feature Types

You already know that IMDF archives are essentially a collection of GeoJSON files that, when combined, describe an indoor space. But how exactly does this work? You’ll find out next.

In Xcode, open the Project navigator and expand IMDF/Data. Each GeoJSON file has a name that corresponds to its feature type described in the IMDF documentation.

For example, occupant.geojson is a collection of Occupant types, building.geojson is a collection of Building types and so on. It’s as simple as that. :]

The main types you’ll look at in this tutorial are:

  • Venue
  • Unit
  • Occupant
  • Amenity
  • Level

Now that you understand the tools you’ll be using, it’s time to put them to work!

Using the GeoJSON Format

Open occupant.geojson in the Project navigator. You’ll see that GeoJSON and IMDF really aren’t as scary as they sound.

Take a look at one of the occupants in the list:

{
    "feature_type": "occupant",
    "geometry": null,
    "id": "5ac4bf40-2dbf-4bdb-9c39-495c442b7e39",
    "properties": {
        "address_id": null,
        "alt_name": null,
        "anchor_id": "76336b53-81c9-4c93-8f81-a4c008d54ba1",
        "category": "office",
        "display_point": {
            "coordinates": [
                -121.889609,
                37.329678
              ],
            "type": "Point"
        },
        "hours": "Su-Sa 09:00-17:00",
        "name": {
            "en": "Ray's Office"
        },
        "phone": "+14087924512",
        "restriction": null,
        "website": null,
        "correlation_id": null
    },
    "type": "Feature"
}

This occupant describes Ray’s office. A valid occupant must define an id, the feature_type must have a value of occupant and it must have a geometry value of null.

An occupant doesn’t need its own geometry, as it uses the geometry from the anchor that anchor_id references inside properties.

Still in occupant.geojson, copy the anchor_id of the first occupant, then open anchor.geojson and search for the id. You’ll find the associated anchor feature inside.

All the other keys in properties are metadata describing the occupant. In this article, the only key you’ll use is the name but in a real-world situation, you could expand this to show your opening hours, phone number and website, if that information was relevant for your app.

Also, that’s not Ray’s real phone number, so don’t bother trying to call. :]

Note: You’ve covered some top level theory on GeoJSON and IMDF, but there’s a lot more to know. Apple’s documentation provides a detailed overview of IMDF and the different feature types. The official GeoJSON website has some good information, but it’s very technical.

A great way to learn is to use the tools at geojson.io to explore GeoJSON further. You can draw geometry directly on a map and have it output the associated JSON.

Next up, looking into how a GeoJSON file can be converted to an actual Swift model that you can work with in your code.

Modeling GeoJSON

Open IMDF/Models in the Project navigator. You’ll notice you already have a lot of the IMDF feature types you need to get started, but one is missing – the Venue type. You’ll add it now.

Create a new file in Models, name it Venue.swift and add the following:

class Venue: Feature<Venue.Properties> {
  struct Properties: Codable {
    let category: String
  }

  var levelsByOrdinal: [Int: [Level]] = [:]
}

This is all the code you need to define the Venue. It looks a little light, though, doesn’t it? Venue, along with most other model types in your project, inherits from Feature, a class that takes a generic type, Properties, type-constrained to Decodable.

Feature defines the id and geometry variables common across each IMDF feature. The Properties object inside the GeoJSON data can be any valid JSON object. Each feature defines its own Properties, as you can see in the Venue model.

Venue.Properties is passed in as the generic type-parameter. It conforms to Codable, so meets the requirement that Properties be Decodable.

Build and run. You won’t see any changes yet, but you’re one step closer to your indoor map. :]

RazeMap initial screen showing map of the United Kingdom

Decoding GeoJSON With MKGeoJSONDecoder

It wouldn’t be very nice of Apple to create this new data format without providing a way of reading it. In this section, you’ll decode GeoJSON data using MKGeoJSONDecoder. MKGeoJSONDecoder provides a way of decoding GeoJSON into MapKit types using decode(_:).

Open IMDF/IMDFDecoder.swift. You already have code that decodes some types, but you need to add a decode(_:) method to tie it all together in a single Venue object.

At the top of IMDFDecoder.swift, add the following method:

func decode(_ imdfDirectory: URL) throws -> Venue {
  // 1
  let archive = Archive(directory: imdfDirectory)

  // 2
  let venues = try decodeFeatures(Venue.self, from: .venue, in: archive)
  let levels = try decodeFeatures(Level.self, from: .level, in: archive)
  let units = try decodeFeatures(Unit.self, from: .unit, in: archive)
  let openings = try decodeFeatures(Opening.self, from: .opening, in: archive)
  let amenities = try decodeFeatures(Amenity.self, from: .amenity, in: archive)

  // 3
  if venues.isEmpty {
    throw IMDFError.invalidData
  }
  let venue = venues[0]
  venue.levelsByOrdinal = Dictionary(grouping: levels) { level in
    level.properties.ordinal
  }

  // 4
  let unitsByLevel = Dictionary(grouping: units) { unit in
    unit.properties.levelId
  }

  let openingsByLevel = Dictionary(grouping: openings) { opening in
    opening.properties.levelId
  }

  // 5
  for level in levels {
    if let unitsInLevel = unitsByLevel[level.id] {
      level.units = unitsInLevel
    }
    if let openingsInLevel = openingsByLevel[level.id] {
      level.openings = openingsInLevel
    }
  }

  // 6
  let unitsById = units.reduce(into: [UUID: Unit]()) { result, unit in
    result[unit.id] = unit
  }

  // 7
  for amenity in amenities {
    guard let pointGeometry = amenity.geometry[0] as? MKPointAnnotation 
      else { throw IMDFError.invalidData }

    if let name = amenity.properties.name?.bestLocalizedValue {
      amenity.title = name
      amenity.subtitle = amenity.properties.category.capitalized
    } else {
      amenity.title = amenity.properties.category.capitalized
    }

    for unitID in amenity.properties.unitIds {
      let unit = unitsById[unitID]
      unit?.amenities.append(amenity)
    }

    amenity.coordinate = pointGeometry.coordinate
  }

  // 8
  try decodeOccupants(units: units, in: archive)

  return venue
}

Quite a large beast, but as you’ll see it’s not too complicated. In short, you decode an IMDF file into a venue with information about the levels, units, openings and amenities inside of it.

Going over the code, here’s what’s happening:

  1. archive is a container used to access the complete IMDF archive. It requires the URL of the IMDF archive in the project, and can access each GeoJSON file individually. Here, you create an instance of Archive.
  2. Using decodeFeatures(_:from:in:), you decode the feature types into models.
  3. If venues is empty, throw an error. Otherwise, group the levels by the ordinal property in Level and assign the resulting Dictionary to levelsByOrdinal in Venue.
  4. Create two new Dictionary objects by grouping units and openings by their levelId.
  5. For each level, add the units and openings using the two Dictionary objects you created in step four.
  6. Group units by ID.
  7. For each Amenity, set the title, subtitle and coordinate. For any associated Unit, add the Amenity to the amenities array.
  8. Once you’ve decoded everything else, call decodeOccupants(units:in:) to decode the occupants.

Decoding the Archive

Now that you have your decoding method up and running, you’ll need to call it from the main view controller.

Open MapViewController.swift. Under the declaration of var mapView, add the following code:

let decoder = IMDFDecoder()
var venue: Venue?

Here you prepare an IMDFDecoder as well as a property to store a decoded venue in.

Next, under setupMapView(), add the following new method:

func loadRazeHQIndoorMapData() {
  guard let resourceURL = Bundle.main.resourceURL else { return }

  let imdfDirectory = resourceURL.appendingPathComponent("Data")
  do {
    venue = try decoder.decode(imdfDirectory)
  } catch let error {
    print(error)
  }
}

This method loads the Data directory from the app’s resource bundle and decodes it into venue using decode(_:), which you created in the previous step.

Now, you need a way to highlight your venue on the map by zooming to its location. After loadRazeHQIndoorMapData(), add the following code to create a new method called showDefaultMapRect():

func showDefaultMapRect() {
  guard
    let venue = venue,
    let venueOverlay = venue.geometry[0] as? MKOverlay
    else { return }

  mapView.setVisibleMapRect(
    venueOverlay.boundingMapRect,
    edgePadding: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20),
    animated: false
  )
}

This method calls setVisibleMapRect(_:edgePadding:animated:) on mapView using the boundingMapRect from the venue geometry.

boundingMapRect contains the smallest visible map rectangle of the geometry. The edgePadding parameter of setVisibleMapRect(_:edgePadding:animated:) is the additional space around the map rectangle in screen points.

Finally, you need to call these two new methods. At the bottom of viewDidLoad(), add.

loadRazeHQIndoorMapData()
showDefaultMapRect()

Build and run to see the result.

RazeMap zoomed in on the RazeWare office

Welcome to RazeWare HQ! At least, its location. There’s still no visible structure, but as the map is zoomed in, you must have a valid Venue.

Rendering Geometry on a Map

Now that you have a native model type to work with, it’s time to do some cartography!

MapKit uses MKOverlay objects in conjunction with an MKOverlayRenderer to draw overlays on a map.

MKOverlay is a protocol that describes an overlay’s geometry. MapKit contains various concrete classes that adopt this protocol to define shapes such as rectangles, circles and polygons.

MKOverlayRenderer is an object that draws the visual representation of an MKOverlay.

Adding Overlays

Open MapViewController.swift and scroll down to extension MapViewController: MKMapViewDelegate. You’ll notice that you already have some methods defined here.

MapKit calls mapView(_:rendererFor:) -> MKOverlayRenderer whenever it needs to draw an overlay on the map view. This method is responsible for passing the appropriate MKOverlayRenderer back to MapKit.

Right now, it’s not doing much, but you’ll change that next.

Replace the entire method with the following code:

func mapView(
  _ mapView: MKMapView, 
  rendererFor overlay: MKOverlay
) -> MKOverlayRenderer {
  guard
    let shape = overlay as? (MKShape & MKGeoJSONObject),
    let feature = currentLevelFeatures
      .first( where: { $0.geometry.contains( where: { $0 == shape }) })
    else { return MKOverlayRenderer(overlay: overlay) }

  let renderer: MKOverlayPathRenderer
  switch overlay {
  case is MKMultiPolygon:
    renderer = MKMultiPolygonRenderer(overlay: overlay)
  case is MKPolygon:
    renderer = MKPolygonRenderer(overlay: overlay)
  case is MKMultiPolyline:
    renderer = MKMultiPolylineRenderer(overlay: overlay)
  case is MKPolyline:
    renderer = MKPolylineRenderer(overlay: overlay)
  default:
    return MKOverlayRenderer(overlay: overlay)
  }

  feature.configure(overlayRenderer: renderer)

  return renderer
}

When addOverlays(_:) is called on the delegating MKMapView, this method is called. The overlay provided to addOverlays(_:) is passed in.

As you learned previously, MKOverlay is a protocol that concrete classes adopt to create basic shapes. So in the code above you check to see which type it is and create the appropriate MKOverlayRenderer subclass depending on the result.

Finally, before returning the renderer created, you call configure(overlayRenderer:) on the feature. This is a method in StylableFeature.

You’ll see exactly what StylableFeature is and does later in the tutorial.

But first up, you must learn how to add annotations to the map alongside overlays.

Adding Annotations

You add annotations in a similar way to overlays. MKMapViewDelegate has a delegate method, mapView(_:viewFor:) -> MKAnnotationView?, which is called each time addAnnotations(_:) is called on the delegating MKMapView.

Just as with overlays, this method isn’t called until you add an annotation to the map. Right now, that’s not happening — but it’s about time you changed that. :]

Add the following method below showDefaultMapRect():

private func showFeatures(for ordinal: Int) {
  guard venue != nil else {
    return
  }

  // 1
  currentLevelFeatures.removeAll()
  mapView.removeOverlays(currentLevelOverlays)
  mapView.removeAnnotations(currentLevelAnnotations)
  currentLevelAnnotations.removeAll()
  currentLevelOverlays.removeAll()

  // 2
  if let levels = venue?.levelsByOrdinal[ordinal] {
    for level in levels {
      currentLevelFeatures.append(level)
      currentLevelFeatures += level.units
      currentLevelFeatures += level.openings

      let occupants = level.units.flatMap { unit in
        unit.occupants
      }

      let amenities = level.units.flatMap { unit in
        unit.amenities
      }

      currentLevelAnnotations += occupants
      currentLevelAnnotations += amenities
    }
  }

  // 3
  let currentLevelGeometry = currentLevelFeatures.flatMap { 
    feature in
    feature.geometry
  }

  currentLevelOverlays = currentLevelGeometry.compactMap { 
    mkOverlay in
    mkOverlay as? MKOverlay
  }

  // 4
  mapView.addOverlays(currentLevelOverlays)
  mapView.addAnnotations(currentLevelAnnotations)
}

The main responsibility of this method is to tear down existing overlays and annotations and draw new ones in their place. It takes a single parameter, ordinal, which specifies which level in the venue to get the geometry for.

Here’s a breakdown:

  1. Clear any existing annotations and overlays from the map and remove associated objects from the local cache.
  2. For the selected level, get associated features and store them inside the local arrays you just emptied.
  3. Create two arrays. The first contains the geometry from the units and occupants of the selected level. Then, using compactMap(_:), you create an array of MKOverlay objects.
  4. Call addOverlays(_:) and addAnnotations(_:) on mapView to add the annotations and overlays to the map.

To see this code in action, at the bottom of viewDidLoad(), add:

showFeatures(for: 1)

Build and run. Try clicking the annotations on the map to look at the features present.

You’re a real cartographer now! :]

RazeWare office geometry drawn on a map

Now, your map is functional and shows the RazeWare office as you intended. However, it doesn’t look as nice as it should. In the next step, you’ll add your own style to the map.

Styling Map Geometry

You’ve handled a lot of the styling for the geometry already, but you still see two default map pins. You need to replace those with something more stylish.

In this section, you’ll learn what StyleableFeature does and how to use it to style the Occupant model type.

StylableFeature is a protocol provided by the sample project — not MapKit — that defines two methods which conforming types should adopt to provide custom styles for a feature.

Open IMDF/StylableFeatures.swift to view the contents.

protocol StylableFeature {
  var geometry: [MKShape & MKGeoJSONObject] { get }
  func configure(overlayRenderer: MKOverlayPathRenderer)
  func configure(annotationView: MKAnnotationView)
}

extension StylableFeature {
  func configure(overlayRenderer: MKOverlayPathRenderer) {}
  func configure(annotationView: MKAnnotationView) {}
}

You’ll notice that both configure methods have a default empty implementation because they’re not required. You’ll see why in a moment. The only other requirement is that a conforming object provide an array of geometry objects that inherit from MKShape and conform to MKGeoJSONObject.

Open IMDF/Models/Occupant.swift and add the following extension to the bottom of the file, after the closing brace:

extension Occupant {
  private enum StylableCategory: String {
    case play
    case office
  }
}

StylableCategory defines the two types used in occupant.geojson.

Open occupant.geojson and take a look at the category key for both objects. You’ll notice they contain play and office. These types are completely arbitrary and can be anything the author of the GeoJSON chooses. You can create as many of these categories as you like.

Continuing in Occupant.swift, add this next extension to the bottom of the file, after the closing brace:

extension Occupant: StylableFeature {
  func configure(annotationView: MKAnnotationView) {
    if let category = StylableCategory(rawValue: properties.category) {
      switch category {
      case .play:
        annotationView.backgroundColor = UIColor(named: "PlayFill")
      case .office:
        annotationView.backgroundColor = UIColor(named: "OfficeFill")
      }
    }

    annotationView.displayPriority = .defaultHigh
  }
}

Now, Occupant conforms to StylableFeature and implements configure(annotationView: MKAnnotationView). This method checks that category is a valid StylableCategory and returns a color based on the value. If one overlay collides with another, the overlay with the lowest displayPriority is hidden.

Build and run. You’ll see that those hideous default map pins have disappeared. In their place are two map points with custom colors. :]

RazeWare office geometry drawn on a map with custom map points

Selecting Levels

You’ve come across Level a few times now. It describes one level, or story, of a building and can have its own set of features. Take a shopping mall for example: They usually have multiple stories with different shops on each floor. Using Level, you could describe which shops are on each floor.

A segmented control is already in place for the interface, and it’s already connected to segmentedControlValueChanged(_ sender:). You also have the ability to redraw geometry based on a Level with showFeatures(for:). You’re on a roll! :]

Open MapViewController.swift and add the following code to the body of segmentedControlValueChanged(_ sender:):

showFeatures(for: sender.selectedSegmentIndex)

Build and run. When you change the level on the segmented control, the map overlays redraw the provided level.

Switching between Dungeon and First Floor levels on the indoor maps

In the final section, you’ll learn how to use indoor locations to see where you are inside the office.

Using Location With Indoor Maps

You may be wondering, “How can GPS possibly work indoors!?” The simple answer is: It doesn’t. At least not well.

Indoor positioning doesn’t actually use GPS at all. Apple uses sensors inside an iOS device to determine your indoor location based on direction, travel speed and radio frequency “fingerprints”.

Fixed Wi-Fi access points emit traceable frequency patterns inside a building. These unique signals are collected using Apple’s Indoor Survey app. With the Indoor Survey app open, you walk around your venue collecting the frequencies and signals as you go. This maps your building from the inside.

Don’t worry, you don’t have to fly out to California and map out the office in this article — maybe next time. :]

Add the following variable to the top of MapViewController.swift, above let decoder = IMDFDecoder():

let locationManager = CLLocationManager()

Now, add the following method above segmentedControlValueChanged(_ sender:):

func startListeningForLocation() {
  locationManager.requestWhenInUseAuthorization()
}

Lastly, add the following code to the bottom of viewDidLoad():

startListeningForLocation()

With the code above, you start listening for location changes when the view controller loads.

For this to work, you need to enable location services in your simulator and add a custom location. With the simulator in the foreground, navigate to Features ▸ Location ▸ Custom Location. In the dialog that appears, enter the following:

  • 37.329422 for Latitude.
  • -121.887861 for Longitude.

Entering a custom location into the simulator

Build and run. The simulator will ask for location permission — make sure you grant it!

RazeMap showing user location on indoor maps within the RazeWare office

I should have known you’d go straight to the Games Room, slacker. :]

Where To Go From Here?

You can download the completed version of the project using the Download Materials button at the top or bottom of this tutorial.

You now know the basics of IMDF, but there’s a lot more to learn. Try using the GeoJSON in this tutorial to build your own map geometries. Try adding floors, changing names and customizing colors.

A good place to start creating your own GeoJSON is at geojson.io. Try drawing shapes on the map and using them inside the IMDF archive in this project.

If you want to take your MapKit knowledge to the next level, try our pro course: MapKit and CoreLocation.

Make sure you also check out the two talks on indoor maps from WWDC19: What’s New in MapKit and MapKitJS and Adding Indoor Maps to your App and Website.

If you have any questions about the above article, or anything else relating to indoor mapping, please join the discussion in the forum below!

Average Rating

5/5

Add a rating for this content

7 ratings

More like this

Contributors

Comments