Show device location using indoor positioning

View on GitHubSample viewer app

Show your device's real-time location while inside a building by using signals from indoor positioning beacons.

Show device location using indoor positioning

Use case

An indoor positioning system (IPS) allows you to locate yourself and others inside a building in real time. Similar to GPS, it puts a blue dot on indoor maps and can be used with other location services to help navigate to any point of interest or destination, as well as provide an easy way to identify and collect geospatial information at their location.

How to use the sample

When the device is within range of an IPS beacon, toggle "Show Location" to change the visibility of the location indicator in the map view. The system will ask for permission to use the device's location if the user has not yet used location services in this app. It will then start the location display with auto-pan mode set to navigation.

When there is no IPS beacons nearby, or other errors occur while initializing the indoors location data source, it will seamlessly fall back to the current device location as determined by GPS.

How it works

  1. Load an IPS-enabled map. This can be a web map hosted as a portal item in ArcGIS Online, an Enterprise Portal, or a mobile map package (.mmpk) created with ArcGIS Pro.
  2. Create an AGSIndoorsLocationDataSource object with the positioning feature table (stored with the map) and the pathways feature table after both tables are loaded.
  3. Handle location change events to respond to floor changes or read other metadata for locations.
  4. Assign the AGSIndoorsLocationDataSource object to the map view's location display.
  5. Enable and disable the map view's location display using start(completion:) and stop(). Device location will appear on the display as a blue dot and update as the user moves throughout the space.
  6. Use the autoPanMode property to change how the map behaves when location updates are received.

Relevant API

  • AGSArcGISFeatureTable
  • AGSFeatureTable
  • AGSIndoorsLocationDataSource
  • AGSLocationChangeHandlerDelegate
  • AGSLocationDisplay
  • AGSLocationDisplayAutoPanMode
  • AGSMap
  • AGSMapView

About the data

This sample uses an IPS-enabled web map that displays Building L on the Esri Redlands campus. Please note: you would only be able to use the indoor positioning functionalities when you are inside this building. Swap the web map to test with your own IPS setup.

Additional information

  • Location and Bluetooth permissions are required for this sample.
  • To learn more about IPS, read the Indoor positioning article on the ArcGIS Developer website.
  • To learn more about how to deploy the indoor positioning system, read the Deploy ArcGIS IPS article.

Tags

beacon, BLE, blue dot, Bluetooth, building, facility, GPS, indoor, IPS, location, map, mobile, navigation, site, transmitter

Sample Code

ShowDeviceLocationUsingIndoorPositioningViewController.swift
Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
// Copyright 2022 Esri
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//   https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import UIKit
import ArcGIS

class ShowDeviceLocationUsingIndoorPositioningViewController: UIViewController {
    // MARK: Storyboard views

    /// The map view managed by the view controller.
    @IBOutlet var mapView: AGSMapView! {
        didSet {
            mapView.map = makeMap()
        }
    }
    /// The label to display location data source info.
    @IBOutlet var sourceStatusLabel: UILabel!
    /// The label to display sensors info.
    @IBOutlet var sensorStatusLabel: UILabel!

    // MARK: Properties

    /// The measurement formatter for sensor accuracy.
    let measurementFormatter: MeasurementFormatter = {
        let formatter = MeasurementFormatter()
        formatter.unitStyle = .short
        formatter.unitOptions = .providedUnit
        return formatter
    }()

    /// The app-wide API key.
    let apiKey = AGSArcGISRuntimeEnvironment.apiKey
    /// A indoors location data source based on sensor data, including but not
    /// limited to radio, GPS, motion sensors.
    var indoorsLocationDataSource: AGSIndoorsLocationDataSource?

    /// The current floor level reported by the indoors location data source.
    var currentFloor: Int! {
        willSet(newFloor) {
            if newFloor != currentFloor {
                displayFeatures(onFloor: newFloor)
            }
        }
    }

    // MARK: Methods

    /// Load an IPS-enabled web map from a portal.
    func makeMap() -> AGSMap {
        // A floor-aware, IPS-enabled web map for floors of Esri Building L in Redlands.
        let map = AGSMap(item: AGSPortalItem(portal: .arcGISOnline(withLoginRequired: false), itemID: "8fa941613b4b4b2b8a34ad4cdc3e4bba"))
        map.load { [weak self] error in
            if let error = error {
                self?.presentAlert(error: error)
            } else {
                self?.findPositioningTable(map: map)
            }
        }
        return map
    }

    /// Find the IPS positioning table to set up the location data source.
    func findPositioningTable(map: AGSMap) {
        let tables = map.tables as! [AGSServiceFeatureTable]
        loadTables(tables) { [weak self] result in
            guard let self = self else { return }
            switch result {
            case .failure:
                self.presentAlert(error: SetupError.failedToLoadFeatureTables)
            case .success:
                if let featureTable = tables.first(where: { $0.tableName == "ips_positioning" }) {
                    self.setupIndoorsLocationDataSource(positioningTable: featureTable)
                } else {
                    self.presentAlert(error: SetupError.positioningTableNotFound)
                }
            }
        }
    }

    /// Set up indoors location data source by first querying the
    /// IPS positioning table.
    func setupIndoorsLocationDataSource(positioningTable: AGSServiceFeatureTable) {
        // Find the table field name that matches "date created" pattern.
        func isDateCreated(field: AGSField) -> Bool {
            let name = field.name
            return name.caseInsensitiveCompare("DateCreated") == .orderedSame || name.caseInsensitiveCompare("DATE_CREATED") == .orderedSame
        }

        if let dateCreatedFieldName = positioningTable.fields.first(where: isDateCreated(field:))?.name {
            // Create the query parameters.
            let queryParameters = AGSQueryParameters()
            queryParameters.orderByFields = [AGSOrderBy(fieldName: dateCreatedFieldName, sortOrder: .descending)]
            queryParameters.maxFeatures = 1
            // "1=1" will give all the features from the table.
            queryParameters.whereClause = "1=1"

            // Query features from the table to ensure they support IPS.
            positioningTable.queryFeatures(with: queryParameters) { [weak self] result, error in
                guard let self = self else { return }
                if let result = result {
                    if let feature = result.featureEnumerator().nextObject(),
                       // The ID that identifies a row in the positioning table.
                       // It is possible to initialize ILDS without globalID,
                       // in which case the first row of the positioning table
                       // will be used.
                       let globalID = feature.attributes[positioningTable.globalIDField] as? UUID,
                       // The network pathways for routing between locations on
                       // the same level.
                       let pathwaysLayer = (self.mapView.map?.operationalLayers as? [AGSFeatureLayer])?.first(where: { $0.name == "Pathways" }),
                       let pathwaysTable = pathwaysLayer.featureTable as? AGSArcGISFeatureTable {
                        pathwaysLayer.isVisible = false
                        self.queryFeaturesDidFinish(positioningTable: positioningTable, pathwaysTable: pathwaysTable, globalID: globalID)
                    } else {
                        self.presentAlert(error: SetupError.mapDoesNotSupportIPS)
                    }
                } else if error != nil {
                    self.presentAlert(error: SetupError.failedToLoadIPS)
                }
            }
        } else {
            presentAlert(error: SetupError.dateCreatedFieldNotFound)
        }
    }

    /// Setting up `indoorsLocationDataSource` with positioning, pathways and
    /// positioning ID.
    /// - Parameters:
    ///   - positioningTable: The "ips\_positioning" `AGSServiceFeatureTable`
    ///   from an IPS-enabled map.
    ///   - pathwaysTable: An `ArcGISFeatureTable` that contains pathways as
    ///   per the ArcGIS Indoors Information Model. Setting this property
    ///   enables path snapping of locations provided by the location data source.
    ///   - globalID: An `UUID` which identifies a specific row in the
    ///   positioningTable that should be used for setting up IPS.
    func queryFeaturesDidFinish(positioningTable: AGSServiceFeatureTable, pathwaysTable: AGSArcGISFeatureTable, globalID: UUID) {
        let locationDataSource = AGSIndoorsLocationDataSource(positioningTable: positioningTable, pathwaysTable: pathwaysTable, positioningID: globalID)
        // The delegate which will receive location and status updates
        // from the data source.
        locationDataSource.locationChangeHandlerDelegate = self
        self.indoorsLocationDataSource = locationDataSource
        if let extent = pathwaysTable.extent {
            mapView.setViewpointGeometry(extent)
        }
        mapView.locationDisplay.dataSource = locationDataSource
        mapView.locationDisplay.autoPanMode = .navigation
        // Asynchronously start of the location display, which will in-turn
        // start `indoorsLocationDataSource` to receive IPS updates.
        mapView.locationDisplay.start { [weak self] (error) in
            guard let self = self, let error = error else { return }
            self.presentAlert(error: error)
        }
    }

    /// A helper method to load one feature table at a time. Stop loading if
    /// error occurs.
    /// - Parameters:
    ///   - tables: The feature tables to load.
    ///   - completion: The load result for a table.
    func loadTables<C>(_ tables: C, completion: @escaping (Result<Void, Error>) -> Void) where C: RandomAccessCollection, C.Element == AGSFeatureTable {
        guard let table = tables.last else {
            completion(.success(()))
            return
        }
        table.load { [weak self] error in
            if let error = error {
                completion(.failure(error))
            } else {
                self?.loadTables(tables.dropLast(), completion: completion)
            }
        }
    }

    /// Display features on a certain floor level using definition expression.
    /// - Parameter floor: The floor level of the features to be displayed.
    func displayFeatures(onFloor floor: Int) {
        (mapView.map!.operationalLayers as! [AGSLayer]).forEach { layer in
            if layer.name == "Details" || layer.name == "Units" || layer.name == "Levels" {
                if let featureLayer = layer as? AGSFeatureLayer {
                    featureLayer.definitionExpression = "VERTICAL_ORDER = \(floor)"
                }
            }
        }
    }

    // MARK: UIViewController

    override func viewDidLoad() {
        super.viewDidLoad()
        // Add the source code button item to the right of navigation bar.
        (navigationItem.rightBarButtonItem as? SourceCodeBarButtonItem)?.filenames = ["ShowDeviceLocationUsingIndoorPositioningViewController"]
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // Start location display when the indoors location data source exists,
        // and it isn't started when the view appears.
        if indoorsLocationDataSource != nil && !mapView.locationDisplay.started {
            mapView.locationDisplay.start { [weak self] (error) in
                guard let self = self, let error = error else { return }
                self.presentAlert(error: error)
            }
        }
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // Stop location display, which in turn stop the data source.
        mapView.locationDisplay.stop()
    }

    deinit {
        // Reset the API key.
        AGSArcGISRuntimeEnvironment.apiKey = apiKey
    }
}

// MARK: - AGSLocationChangeHandlerDelegate

extension ShowDeviceLocationUsingIndoorPositioningViewController: AGSLocationChangeHandlerDelegate {
    func locationDataSource(_ locationDataSource: AGSLocationDataSource, locationDidChange location: AGSLocation) {
        // The floor level provided by the indoors beacons.
        let floorText: String
        if let floor = location.additionalSourceProperties[.floor] as? Int {
            currentFloor = floor
            floorText = String(format: "Floor level: %d", floor)
        } else {
            floorText = "Floor not available"
        }

        // The horizontal accuracy of the positioning signal from the sensors.
        let horizontalAccuracy = measurementFormatter.string(from: Measurement(value: location.horizontalAccuracy, unit: UnitLength.meters))

        // Possible sources: GNSS, AppleIPS, BLE, WIFI, CELL, IP.
        let positionSource = location.additionalSourceProperties[.positionSource] as? String ?? "NA"
        let sensorCount: String = {
            switch positionSource {
            case "GNSS":
                let satelliteCount = location.additionalSourceProperties[.satelliteCount] as? Int ?? 0
                return String(format: "%d satellite(s)", satelliteCount)
            default:
                let transmitterCount = location.additionalSourceProperties[.transmitterCount] as? Int ?? 0
                return String(format: "%d beacon(s)", transmitterCount)
            }
        }()

        DispatchQueue.main.async { [weak self] in
            guard let self = self else { return }
            self.sourceStatusLabel.text = String(format: "%@, Position source: %@", floorText, positionSource)
            self.sensorStatusLabel.text = String(format: "%@, Horizontal accuracy: %@", sensorCount, horizontalAccuracy)
        }
    }

    func locationDataSource(_ locationDataSource: AGSLocationDataSource, statusDidChange status: AGSLocationDataSourceStatus) {
        switch status {
        case .starting, .started, .stopping, .stopped:
            // - starting: it happens immediately after user starts the location
            // data source. It takes a while to completely start the ILDS.
            // - started: it happens once ILDS successfully started.
            // - stopping, stopped: ILDS may stop due to internal error, e.g.
            // user revoked the location permission in system settings. We don't
            // handle these error here.
            break
        case .failedToStart:
            // - failedToStart: This happens if user provides a wrong UUID, or
            // the positioning table has no entries, etc.
            DispatchQueue.main.async { [weak self] in
                if let error = locationDataSource.error {
                    self?.presentAlert(error: error)
                } else {
                    self?.presentAlert(title: "Fail to start ILDS", message: "ILDS failed to start due to an unknown error.")
                }
            }
        @unknown default:
            fatalError("Unknown location data source status.")
        }
    }
}

// MARK: - SetupError

extension ShowDeviceLocationUsingIndoorPositioningViewController {
    private enum SetupError: LocalizedError {
        case dateCreatedFieldNotFound, failedToLoadFeatureTables, failedToLoadIPS, mapDoesNotSupportIPS, positioningTableNotFound

        var errorDescription: String? {
            switch self {
            case .dateCreatedFieldNotFound:
                return "DateCreated filed is either missing or has a wrong name."
            case .failedToLoadFeatureTables:
                return "Failed to load feature tables."
            case .failedToLoadIPS:
                return "Failed to load IPS."
            case .mapDoesNotSupportIPS:
                return "Map does not support IPS."
            case .positioningTableNotFound:
                return "Positioning table not found."
            }
        }
    }
}

Your browser is no longer supported. Please upgrade your browser for the best experience. See our browser deprecation post for more details.