Indoor store navigation for iOS: floor map, AR camera guidance, and multi-stop trips.
This package is binary-only (XCFramework). Engine source is not included.
| Platform | iOS 17+ (iPhone / iPad) |
| Xcode | 15+ |
| Integration | Swift Package Manager |
| UI | SwiftUI (IndoorNavigationView, IndoorSurveyView) |
| Repository | https://github.com/Indoorly/Indoorly-iOS |
| Current version | 1.1.1 |
- Install
- Permissions (Info.plist)
- Where to configure the SDK
- Tokens
- Bootstrap (scopes & live settings)
- Visitor app — end-to-end
- Presenting navigation
- Customization
- Pin a venue & nearby radius
- Discover & resolve stores by GPS
- Presence (live in-store count)
- Callbacks
- Custom screens with IndoorDirectory
- Staff app — register / extend a store
- Device GPS & capabilities
- Advanced setup (without the facade)
- Telemetry
- UIKit / AppDelegate apps
- API reference (public surface)
- Common issues
- Support
- File → Add Package Dependencies…
- Paste:
https://github.com/Indoorly/Indoorly-iOS.git - Choose a version (git tag, e.g.
1.1.1) - Add the IndoorSDK product to your app target
dependencies: [
.package(url: "https://github.com/Indoorly/Indoorly-iOS.git", from: "1.1.1"),
],Then add .product(name: "IndoorSDK", package: "Indoorly-iOS") to your target.
Note: Do not add this package to a target that already links a local IndoorSDK source package — SPM allows the product name only once.
Add these keys to your app target’s Info tab (or Info.plist):
| Key | Required when |
|---|---|
NSCameraUsageDescription |
Navigation with AR camera, or walking survey |
NSMotionUsageDescription |
Device heading while navigating |
NSLocationWhenInUseUsageDescription |
Filtering nearby stores with GPS (IndoorLocation / venues(near:)) |
Example strings:
NSCameraUsageDescription = "Camera is used to guide you through the store."
NSMotionUsageDescription = "Motion is used to keep your heading on the map."
NSLocationWhenInUseUsageDescription = "Location is used to show nearby stores."
Call configuration once at launch, before any navigation or directory call.
| App style | Where to call Indoorly.initialize |
|---|---|
SwiftUI @main |
App.init() (recommended) |
UIKit + AppDelegate |
application(_:didFinishLaunchingWithOptions:) |
UIKit + SceneDelegate only |
First scene connect, before presenting Indoorly UI |
| Unit tests | setUp / test entry — use a test token if needed |
Also at launch (optional, still before UI):
Indoorly.appearance(...)Indoorly.venueID(...)Indoorly.enableAddMoreStores(...)Indoorly.telemetry(...)await Indoorly.bootstrap()— recommended when the token may span several stores
Do not call initialize again from every screen. Views only present navigationView / surveyView / directory().
The production API URL is baked into the SDK. Host apps do not set it (optional serviceURL: exists only for local API debugging).
Issued in the Indoorly admin panel (Comercios → detalle del comercio / Uso SDK). Tokens belong to the
business, not to a single venue. You can also create scoped visitor pk_ tokens that list
only 1…N venues (Tokens SDK con alcance).
| Kind | Prefix | Use in host app |
|---|---|---|
| API Token (visitors) | pk_… |
Public / consumer apps (Indoorly.bootstrap / directory().venues()) |
| Admin Token | sk_… |
Staff-only apps that register or extend stores |
// Visitor app — principal or scoped pk_ of the business
Indoorly.initialize(apiToken: "pk_…")
Indoorly.enableAddMoreStores(false)
// Staff app (register / extend a store by walking)
Indoorly.initialize(apiToken: "sk_…")
Indoorly.enableAddMoreStores(true)If bootstrap returns several venues, show a store picker (or set Indoorly.venueID("…") to pin one).
Never ship an sk_… token inside a public App Store build.
Call once after initialize to learn what the token can see and which panel settings apply.
No SDK redeploy is needed when the panel changes venue scope, GPS pins, discovery mode, or presence intervals.
Indoorly.initialize(apiToken: "pk_…")
let boot = try await Indoorly.bootstrap()
// boot.businessName, boot.role ("client" | "admin")
// boot.allVenues == false → boot.venues is the allow-list from the panel
// boot.settings.presenceHeartbeatSeconds / presenceTtlSeconds
// boot.settings.requireNearVenueMeters // soft geofence hint; nil = off
if boot.venues.count == 1 {
Indoorly.venueID(boot.venues[0].id)
} else if boot.venues.count > 1 {
// Show your store picker, then Indoorly.venueID(chosen.id)
}| Field | Meaning |
|---|---|
allVenues |
true → every store of the business; false → only venues |
venues[].discoveryMode |
.always or .nearby (GPS required for nearby) |
venues[].latitude / .longitude |
Store GPS pin from the panel |
settings.presenceHeartbeatSeconds |
How often navigation heartbeats while open |
settings.presenceTtlSeconds |
How long a heartbeat counts as “in store” |
settings.requireNearVenueMeters |
Optional soft geofence radius for host logic |
Equivalent: try await Indoorly.directory().bootstrap().
import IndoorSDK
import SwiftUI
@main
struct MyApp: App {
init() {
Indoorly.initialize(apiToken: "pk_…") // from admin panel (principal or scoped)
Indoorly.enableAddMoreStores(false)
Indoorly.telemetry(true) // optional; default is on
Indoorly.appearance(
IndoorAppearance(
accentColor: Color(red: 0.0, green: 0.45, blue: 0.25),
layout: .withHostBackButton // room for your Back button
)
)
// Optional: lock to one store, or call bootstrap() and pick from boot.venues
// Indoorly.venueID("walmart-demo")
}
var body: some Scene {
WindowGroup {
NavigationStack {
HomeView()
}
}
}
}import IndoorSDK
import SwiftUI
struct HomeView: View {
@State private var showNavigation = false
@State private var loadError: String?
var body: some View {
VStack(spacing: 24) {
Text("My Store App")
.font(.largeTitle.bold())
Button("Start indoor navigation") {
showNavigation = true
}
.buttonStyle(.borderedProminent)
if let loadError {
Text(loadError).foregroundStyle(.red)
}
}
.fullScreenCover(isPresented: $showNavigation) {
NavigationHostView(isPresented: $showNavigation)
}
}
}Indoorly.navigationView() throws if the token was never set. Catch that when building the view, or use a small host wrapper:
import IndoorSDK
import SwiftUI
struct NavigationHostView: View {
@Binding var isPresented: Bool
/// Destination IDs from your backend / deep link / shopping list.
var stops: [String] = []
var body: some View {
ZStack(alignment: .topLeading) {
navigationContent
Button {
isPresented = false
} label: {
Image(systemName: "chevron.left")
.font(.body.weight(.semibold))
.frame(width: 44, height: 44)
.background(.ultraThinMaterial, in: Circle())
}
.padding(.leading, 14)
.padding(.top, 6)
}
}
@ViewBuilder
private var navigationContent: some View {
if let view = try? Indoorly.navigationView(stops: stops) {
view
.onArrival { place in
print("Arrived:", place.id, place.name, place.category)
}
.onVisitFinished { summary in
print(
"Done venue=\(summary.venueID)",
"stops=\(summary.visited.count)",
"meters=\(summary.walkedMeters)",
"seconds=\(summary.duration)"
)
isPresented = false
}
} else {
ContentUnavailableView(
"Indoorly not configured",
systemImage: "exclamationmark.triangle",
description: Text("Call Indoorly.initialize(apiToken:) at app launch.")
)
}
}
}Use IndoorAppearance(layout: .withHostBackButton) (or a custom IndoorLayout with topLeadingReserved) so SDK banners do not sit under your back control.
.fullScreenCover(isPresented: $showNavigation) {
NavigationHostView(isPresented: $showNavigation, stops: ["dairy", "bakery"])
}NavigationLink("Navigate") {
NavigationHostView(isPresented: .constant(true), stops: [])
.toolbar(.hidden, for: .navigationBar) // SDK draws its own chrome
}Prefer fullScreenCover for AR navigation so system bars do not fight the camera UI.
// Empty list → visitor picks destinations inside the SDK
try Indoorly.navigationView(stops: [])
// Preload a multi-stop trip (place IDs from the panel / IndoorDirectory)
try Indoorly.navigationView(stops: ["dairy", "bakery", "pharmacy"])Place IDs are the destination identifiers configured for that venue (not display names).
Call Indoorly.appearance(...) at launch (or before presenting navigation). Dynamic Type and light/dark mode follow the system.
let brand = IndoorAppearance(
accentColor: Color(red: 0.0, green: 0.45, blue: 0.25), // buttons, lists, chips
arrivalColor: Color(red: 0.13, green: 0.75, blue: 0.38), // arrival / beacon
trailColor: Color(red: 0.13, green: 0.75, blue: 0.38), // walked trail on the map
categoryIcons: [
"dairy": "cup.and.saucer.fill",
"pharmacy": "cross.case.fill",
"bakery": "birthday.cake.fill",
],
layout: .withHostBackButton, // or .standard / custom IndoorLayout
showsArrivalIlluminate: true, // Find My–style green wash on arrival
playsArrivalHaptic: true // success vibration at each stop
)
Indoorly.appearance(brand)A place’s own icon (from the panel) wins over categoryIcons. Set showsArrivalIlluminate / playsArrivalHaptic to false for a quieter arrival while the stop list still advances.
Controls banners, mini-map, and space for host chrome over the SDK.
let layout = IndoorLayout(
topLeadingReserved: 56, // room for host back button
topPadding: 6,
horizontalPadding: 14,
chromeSpacing: 10,
showsManeuverBanner: true,
showsTrackingBanner: true,
showsMiniMap: true,
miniMapSize: 128,
miniMapLeadingPadding: 16,
miniMapBottomPadding: 16
)
Indoorly.appearance(IndoorAppearance(layout: layout))
// Shortcut when you only need space for a circular back control:
Indoorly.appearance(IndoorAppearance(layout: .withHostBackButton))| Property | Effect |
|---|---|
topLeadingReserved |
Keeps banners clear of top-leading host chrome |
topPadding / horizontalPadding |
Insets under the safe area |
chromeSpacing |
Gap between maneuver banner, tracking banner, controls |
showsManeuverBanner |
Turn / distance instruction |
showsTrackingBanner |
AR localization status |
showsMiniMap |
Mini-map while the camera is active |
miniMapSize / paddings |
Mini-map size and placement |
// Show only this store (skip venue picker when the token has several venues)
Indoorly.venueID("walmart-demo")
// Clear pin → list all venues for the token
Indoorly.venueID(nil)
// Optional GPS preference used by host “nearby” flows (0 = no preference)
Indoorly.nearbyRadiusMeters(2_000)Multi-floor venues (stairs / elevators configured in the panel map builder) are handled inside navigation: the map can browse floors and routes transfer via connectors automatically. Host apps do not need extra setup.
Each venue has a panel discovery mode:
| Mode | Behavior |
|---|---|
always |
Listed for the token even without GPS (distance may be nil) |
nearby |
Included only when the visitor is within radiusMeters and the store has a GPS pin |
import CoreLocation
let directory = try Indoorly.directory()
let coordinate = /* from IndoorLocation or CLLocationManager */
// Annotate all token venues with distance (stores without GPS stay listed)
let ranked = try await directory.venues(annotatedNear: coordinate)
// Discovery list (server /v1/venues/nearby, with client-side fallback)
let nearby = try await directory.venues(near: coordinate, radiusMeters: 2_000)
// Pick the single nearest store (throws if none / ambiguous)
do {
let venue = try await directory.resolve(
near: coordinate,
radiusMeters: 2_000,
ambiguityMeters: 40 // two stores within 40 m → ambiguousVenue
)
Indoorly.venueID(venue.id)
} catch let error as IndoorError where error.isVenueSelectionError {
// .unknownVenue → none in range; .ambiguousVenue → show a picker
}IndoorVenueInfo.distanceMeters is filled when GPS is available. Place coordinates (IndoorPlaceInfo.x / .y) remain floor-plan meters, not GPS.
While IndoorNavigationView is open, the SDK heartbeats automatically using
bootstrap.settings.presenceHeartbeatSeconds (panel Uso SDK). The admin panel
shows live active visitors per store.
For custom host screens (without opening navigation):
let directory = try Indoorly.directory()
// Report that this session is at the store
let snap = try await directory.reportPresence(
venueID: "walmart-demo",
sessionID: UUID().uuidString, // stable per app session
coordinate: location.coordinate,
accuracyMeters: location.accuracyMeters.map { Double($0) }
)
print("Active visitors:", snap.activeUsers)
// Read the current count
let live = try await directory.presence(venueID: "walmart-demo")TTL and heartbeat interval come from IndoorBootstrap.settings — change them in the panel; the next bootstrap / navigation start picks them up.
try Indoorly.navigationView(stops: ["pharmacy"])
.onArrival { place in
// Called each time the visitor reaches a stop
// place.id, place.name, place.category, place.x, place.y (floor-plan meters)
}
.onVisitFinished { summary in
// Called when the visitor ends the visit
// summary.venueID, summary.visited, summary.walkedMeters, summary.duration
}Wire analytics, receipts, or dismissal of your fullScreenCover inside these handlers.
Tokens for the SDK (pk_… / sk_…) are issued in the Indoorly admin panel (Uso SDK / Comercios). Panel CRM APIs (users, analytics feed, search) are separate from this mobile SDK.
Use the directory when you build your own venue / destination UI and only open the SDK for walking.
import IndoorSDK
import CoreLocation
import SwiftUI
@MainActor
final class StoreBrowserModel: ObservableObject {
@Published var venues: [IndoorVenueInfo] = []
@Published var places: [IndoorPlaceInfo] = []
@Published var errorMessage: String?
func loadVenues() async {
do {
let boot = try await Indoorly.bootstrap()
venues = boot.venues
// boot.settings → presenceHeartbeatSeconds, requireNearVenueMeters, …
} catch {
errorMessage = String(describing: error)
}
}
func loadNearby(coordinate: CLLocationCoordinate2D) async {
do {
let directory = try Indoorly.directory()
venues = try await directory.venues(near: coordinate, radiusMeters: 2_000)
} catch {
errorMessage = String(describing: error)
}
}
func loadPlaces(venueID: String) async {
do {
let directory = try Indoorly.directory()
places = try await directory.places(venueID: venueID)
} catch {
errorMessage = String(describing: error)
}
}
}
struct StoreBrowserView: View {
@StateObject private var model = StoreBrowserModel()
@State private var showNavigation = false
@State private var selectedStops: [String] = []
var body: some View {
List {
ForEach(model.venues) { venue in
Button(venue.name) {
Indoorly.venueID(venue.id)
showNavigation = true
}
}
}
.task { await model.loadVenues() }
.fullScreenCover(isPresented: $showNavigation) {
NavigationHostView(isPresented: $showNavigation, stops: selectedStops)
}
}
}IndoorPlaceInfo.x / .y are floor-plan meters, not GPS.
Requires an sk_… token:
let directory = try Indoorly.directory()
try await directory.addPlace(
venueID: "walmart-demo",
name: "Wine",
category: "liquor" // or pass x: / y: in floor-plan meters
)
try await directory.updatePlace(venueID: "walmart-demo", placeID: "dairy", name: "Dairy")
try await directory.removePlace(venueID: "walmart-demo", placeID: "dairy")
try await directory.updateVenue(
venueID: "walmart-demo",
name: "Walmart Downtown",
latitude: 19.43,
longitude: -99.13
)Issue pk_… / sk_… in the Indoorly admin panel (Tokens SDK / Comercios). Panel CRM routes (users, search, analytics) are not part of this mobile package.
Staff builds must use sk_… and enableAddMoreStores(true).
@main
struct StaffApp: App {
init() {
Indoorly.initialize(apiToken: "sk_…")
Indoorly.enableAddMoreStores(true)
Indoorly.appearance(IndoorAppearance(layout: .withHostBackButton))
}
var body: some Scene {
WindowGroup {
StaffHomeView()
}
}
}
struct StaffHomeView: View {
@State private var showSurvey = false
var body: some View {
Button("Register store by walking") { showSurvey = true }
.fullScreenCover(isPresented: $showSurvey) {
SurveyHostView(isPresented: $showSurvey)
}
}
}
struct SurveyHostView: View {
@Binding var isPresented: Bool
var body: some View {
ZStack(alignment: .topLeading) {
if let survey = try? Indoorly.surveyView(venue: .new(name: "Downtown Store")) {
survey
.onFinish { venue in
print("Saved", venue.id, venue.name)
isPresented = false
}
.onCancel {
isPresented = false
}
} else {
ContentUnavailableView(
"Survey unavailable",
systemImage: "lock",
description: Text("Needs sk_… token and enableAddMoreStores(true).")
)
}
Button { isPresented = false } label: {
Image(systemName: "xmark")
.frame(width: 44, height: 44)
.background(.ultraThinMaterial, in: Circle())
}
.padding()
}
}
}Venue modes:
// Create a new store (id derived from name when omitted)
.surveyView(venue: .new(name: "Downtown Store"))
.surveyView(venue: .new(name: "Downtown Store", id: "downtown-01"))
// Extend a store that was surveyed before (relocalize + add to world map)
.surveyView(venue: .existing(id: "downtown-01"))import IndoorSDK
import CoreLocation
let location = IndoorLocation()
location.requestWhenInUse()
location.start()
// Observe:
// location.coordinate
// location.authorization // .notDetermined / .denied / .authorizedWhenInUse / …
location.stop()let caps = IndoorDeviceCapabilities.current
// caps.supportsWorldTracking
// caps.hasLiDAR
// caps.supportsSceneDepthLiDAR does not unlock extra features in this version. Navigation uses a 2D floor graph plus ARWorldMap relocalization when the venue was surveyed with a world map.
On Simulator (or devices without world tracking), the map preview still works; the AR camera path will not.
When you need per-screen configuration instead of the global Indoorly settings:
let config = IndoorConfiguration(
serviceURL: Indoorly.productionServiceURL,
clientToken: "pk_…",
venueID: "walmart-demo",
appearance: IndoorAppearance(layout: .withHostBackButton),
hostOptions: IndoorHostOptions(allowsSurvey: false)
)
IndoorNavigationView(configuration: config, stops: ["pharmacy"])
.onArrival { _ in }
.onVisitFinished { _ in }Survey without the facade:
let surveyConfig = IndoorSurveyConfiguration(
serviceURL: Indoorly.productionServiceURL,
adminToken: "sk_…",
venue: .new(name: "Downtown Store"),
appearance: .standard
)
IndoorSurveyView(configuration: surveyConfig)
.onFinish { _ in }
.onCancel { }By default the SDK reports sessions, arrivals, and failures to Indoorly (analytics panel).
Indoorly.telemetry(false) // opt out — call at launch with initializeimport UIKit
import IndoorSDK
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
Indoorly.initialize(apiToken: "pk_…")
Indoorly.enableAddMoreStores(false)
Indoorly.appearance(IndoorAppearance(layout: .withHostBackButton))
return true
}
}import SwiftUI
import IndoorSDK
import UIKit
final class HomeViewController: UIViewController {
@IBAction func startNavigation() {
guard let nav = try? Indoorly.navigationView(stops: []) else { return }
let host = UIHostingController(
rootView: nav
.onArrival { place in print(place.name) }
.onVisitFinished { _ in self.dismiss(animated: true) }
)
host.modalPresentationStyle = .fullScreen
present(host, animated: true)
}
}| API | Role |
|---|---|
Indoorly.initialize(apiToken:serviceURL:) |
Required once at launch |
Indoorly.bootstrap() |
Scoped venues + GPS + presence settings from the panel |
Indoorly.appearance(_:) |
Brand colors, icons, layout, arrival illuminate / haptic |
Indoorly.venueID(_:) |
Pin or clear venue |
Indoorly.enableAddMoreStores(_:) |
Allow survey UI (needs sk_…) |
Indoorly.telemetry(_:) |
Opt in/out of usage events |
Indoorly.nearbyRadiusMeters(_:) |
GPS radius preference for host flows |
Indoorly.navigationView(stops:) |
Full navigation UI (auto presence heartbeats) |
Indoorly.surveyView(venue:) |
Walking survey / register UI |
Indoorly.directory() |
Venues, discovery, resolve, presence, places |
Indoorly.configuration(venueID:) |
Build IndoorConfiguration from facade |
IndoorDirectory.bootstrap() |
Same as Indoorly.bootstrap() |
IndoorDirectory.venues(annotatedNear:) |
All scoped venues + distance |
IndoorDirectory.venues(near:radiusMeters:) |
Discovery list (respects .always / .nearby) |
IndoorDirectory.resolve(near:radiusMeters:ambiguityMeters:) |
Single nearest store |
IndoorDirectory.reportPresence / presence |
Live in-store count |
IndoorNavigationView |
Navigation UI (advanced init) |
IndoorSurveyView |
Survey UI (advanced init) |
IndoorAppearance / IndoorLayout |
Visual customization |
IndoorBootstrap / IndoorSDKSettings |
Bootstrap snapshot + panel settings |
IndoorDiscoveryMode |
.always / .nearby |
IndoorPresenceInfo |
Live visitor count |
IndoorPlaceInfo / IndoorVenueInfo / IndoorVisitSummary |
Models |
IndoorLocation |
When-in-use GPS helper (coordinate, accuracyMeters) |
IndoorDeviceCapabilities |
AR / LiDAR probes |
IndoorError |
unauthorized, adminTokenRequired, unknownVenue, ambiguousVenue, … |
Readable state after configure: Indoorly.isConfigured, apiToken, configuredVenueID, configuredAppearance, addMoreStoresEnabled, etc.
| Situation | Check |
|---|---|
Xcode shows nothing after Indoorly. |
Update package to 1.1.1+, Clean Build Folder, rebuild |
| 401 / unauthorized | Empty token, rotated token, or sk_ used by mistake in a visitor app |
Indoorly.navigationView throws / nil |
initialize was never called, or token string is empty |
| No venues | Business has no stores, or the pk_… is scoped to an empty allow-list — check bootstrap |
ambiguousVenue on resolve |
Two stores within ambiguityMeters; show a picker |
| Nearby list empty | Store set to discoveryMode: nearby without GPS, or visitor outside radius |
| Camera does not open | Simulator or device without ARWorldTracking; map preview still works |
| Register / survey UI missing | Needs sk_… and enableAddMoreStores(true) |
| Banners under your Back button | Use IndoorLayout.withHostBackButton or raise topLeadingReserved |
| SPM product conflict | Same target cannot link both local IndoorSDK source and this binary package |
- Tokens, venues, destinations, analytics: your organization’s Indoorly admin panel
- Package versions: git tags on this repository (SPM)
- Release assets: Releases (XCFramework zip only — no source)