Create a notification every time a given location data source has entered and/or exited a set of features or graphics.

Use case
Geotriggers can be used to notify users when they have entered or exited a geofence by monitoring a given set of features or graphics. They could be used to display contextual information to museum visitors about nearby exhibits, notify hikers when they have wandered off their desired trail, notify dispatchers when service workers arrive at a scene, or more.
How to use the sample
Observe a virtual walking tour of the Santa Barbara Botanic Garden. Information about the user’s current Garden Section, as well as information about nearby points of interest within 10 meters will display or be removed from the UI when the user enters or exits the buffer of each feature.
How it works
- Create a
GeotriggerFeedwith aLocationDataSourceobject (in this case, aSimulatedLocationDataSource). - Create a
FeatureFenceParametersclass from aServiceFeatureTable, a buffer distance at which to monitor each feature, an Arcade Expression, and a name for the specific geotrigger. - Create a
FenceGeotriggerwith the geotrigger feed, aFenceGeotrigger.RuleType, and the fence parameters. - Create a
GeotriggerMonitorwith the fence geotrigger and callGeotriggerMonitor.start()to begin listening for events that meet the fence rule type. - When the
notificationsemit, capture theGeotriggerNotificationInfo. - For more information about the feature that triggered the notification, cast the
GeotriggerNotificationInfoto aFenceGeotriggerNotificationInfoand callFenceGeotriggerNotificationInfo.fenceGeoElement. - Depending on the
FenceGeotriggerNotificationInfo.fenceNotificationTypedisplay or hide information on the UI from theGeoElement’s attributes.
Relevant API
- ArcadeExpression
- FeatureFenceParameters
- FenceGeotrigger
- FenceGeotrigger.RuleType
- FenceGeotriggerNotificationInfo
- GeoElement
- Geotrigger
- GeotriggerFeed
- GeotriggerMonitor
- GeotriggerNotificationInfo
- ServiceFeatureTable
- SimulatedLocationDataSource
About the data
This sample uses the Santa Barbara Botanic Garden Geotriggers Sample ArcGIS Online Web Map which includes a georeferenced map of the garden as well as select polygon and point features to denote garden sections and points of interest. Description text and attachment images in the feature layers were provided by the Santa Barbara Botanic Garden and more information can be found on the Garden Sections & Displays portion of their website. All assets are used with permission from the Santa Barbara Botanic Garden. For more information, visit the Santa Barbara Botanic Garden website.
Tags
alert, arcade, fence, geofence, geotrigger, location, navigation, notification, notify, routing, trigger
Sample code
// Copyright 2023 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 ArcGISimport ArcGISToolkitimport SwiftUI
struct SetUpLocationDrivenGeotriggersView: View { /// The view model for the sample. @StateObject private var model = Model()
/// The error shown in the error alert. @State private var error: (any Error)?
/// A Boolean value indicating whether to show the popup. @State private var isShowingPopup = false
/// A string for the fence geotrigger notification status. @State private var fenceGeotriggerText = ""
/// A string for the display name of the currently nearby feature. @State private var nearbyFeaturesText = ""
/// Starts the geotrigger monitors and handles posted notifications. /// - Parameter geotriggerMonitors: The geotrigger monitors to start. private func startGeotriggerMonitors(_ geotriggerMonitors: [GeotriggerMonitor]) async throws { await withThrowingTaskGroup(of: Void.self) { group in for monitor in geotriggerMonitors { group.addTask { @MainActor @Sendable in for await newNotification in monitor.notifications where newNotification is FenceGeotriggerNotificationInfo { model.handleGeotriggerNotification(newNotification as! FenceGeotriggerNotificationInfo) } }
group.addTask { try await monitor.start() } } } }
var body: some View { MapView(map: model.map) .locationDisplay(model.locationDisplay) .task { do { // Load the map and its operational layers. try await model.map.load()
// Create the geotrigger monitors. let monitors = model.makeGeotriggerMonitors()
// Start geotrigger monitoring. if !monitors.isEmpty { try await startGeotriggerMonitors(monitors) } } catch { self.error = error } } .task(id: model.fenceGeotriggerStatus) { // Set fence geotrigger text. fenceGeotriggerText = model.fenceGeotriggerStatus.label
// Set nearby features text. let features = model.nearbyFeatures if features.isEmpty { nearbyFeaturesText = "No nearby features." } else { nearbyFeaturesText = String(format: "Nearby: %@", ListFormatter.localizedString(byJoining: features.keys.sorted())) } } .overlay(alignment: .top) { // Status text overlay. VStack { Text(fenceGeotriggerText) .frame(maxWidth: .infinity, alignment: .leading)
Text(nearbyFeaturesText) .foregroundStyle(.orange) .frame(maxWidth: .infinity, alignment: .leading) } .padding(8) .background(.thinMaterial, ignoresSafeAreaEdges: .horizontal) } .toolbar { ToolbarItemGroup(placement: .bottomBar) { Button("Current Section") { model.setSectionPopup() isShowingPopup = true } .disabled(!model.hasCurrentSection)
Button("Point of Interest") { model.setPOIPopup() isShowingPopup = true } .disabled(!model.hasPointOfInterest) } } .sheet(isPresented: $isShowingPopup) { PopupView(root: model.popup!, isPresented: $isShowingPopup) } .task(id: isShowingPopup) { if isShowingPopup { // Stop location updates when the popup is showing. await model.locationDisplay.dataSource.stop() } else { // Start location updates when no popup is showing. try? await model.locationDisplay.dataSource.start() } } .errorAlert(presentingError: $error) }}
extension SetUpLocationDrivenGeotriggersView { /// The status of a fence geotrigger monitor. enum FenceGeotriggerStatus: Equatable { case notSet case entered(featureName: String) case exited(featureName: String)
/// A human-readable label for the geotrigger status. var label: String { switch self { case .notSet: return "Fence geotrigger info will be shown here." case .entered(featureName: let featureName): return "Entered the geofence of \(featureName)" case .exited(featureName: let featureName): return "Exited the geofence of \(featureName)" } } }}
#Preview { NavigationStack { SetUpLocationDrivenGeotriggersView() }}// Copyright 2023 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 ArcGISimport Combineimport Foundation
extension SetUpLocationDrivenGeotriggersView { /// The view model for the sample. @MainActor class Model: ObservableObject { // MARK: Properties
/// A Boolean value indicating whether there is a current section. @Published private(set) var hasCurrentSection = false
/// A Boolean value indicating whether there is a point-of-interest. @Published private(set) var hasPointOfInterest = false
/// A dictionary for nearby features. @Published private(set) var nearbyFeatures: [String: ArcGISFeature] = [:]
/// The popup to show the feature information. @Published private(set) var popup: Popup?
/// The fence geotrigger notification info status. @Published private(set) var fenceGeotriggerStatus: FenceGeotriggerStatus = .notSet
/// A map of the Santa Barbara Botanic Garden. let map = Map(item: PortalItem( portal: .arcGISOnline(connection: .anonymous), id: .santaBarbaraBotanicGardenMap ))
/// The location display for the map view. let locationDisplay: LocationDisplay
/// The route simulated location data source. private let locationDataSource = SimulatedLocationDataSource(polyline: .walkingTour())
/// The name of the current garden section feature. Will be `nil` if there /// is not a current garden section. private var currentSectionName: String? { featureNamesInFenceGeotrigger[.sectionFenceGeotrigger]?.last }
/// The names of nearby point-of-interest features. private var nearbyPOINames: [String] { featureNamesInFenceGeotrigger[.poiFenceGeotrigger] ?? [] }
/// A dictionary for the feature names in each fence geotrigger. /// - Key: The name of a fence geotrigger. /// - Value: An array of names of features within the fence. private var featureNamesInFenceGeotrigger: [String: [String]] = [:] { didSet { hasCurrentSection = currentSectionName != nil hasPointOfInterest = !nearbyPOINames.isEmpty } }
// MARK: Methods
init() { // Create location display. locationDisplay = Self.makeLocationDisplay(dataSource: locationDataSource) }
/// Creates fence geotrigger monitors from the map's operational layers. func makeGeotriggerMonitors() -> [GeotriggerMonitor] { // Get the service feature tables from the map's operational layers. guard let operationalLayers = map.operationalLayers as? [FeatureLayer], let gardenSectionsLayer = operationalLayers.first(where: { $0.item?.id == .gardenSectionsLayer }), let gardenPOIsLayer = operationalLayers.first(where: { $0.item?.id == .gardenPOIsLayer }), let gardenSectionsTable = gardenSectionsLayer.featureTable as? ServiceFeatureTable, let gardenPOIsTable = gardenPOIsLayer.featureTable as? ServiceFeatureTable else { return [] }
// Create geotriggers for each of the service feature tables. let geotriggerFeed = LocationGeotriggerFeed(locationDataSource: locationDataSource) let gardenSectionsGeotriggerMonitor = makeFenceGeotriggerMonitor( feed: geotriggerFeed, featureTable: gardenSectionsTable, bufferDistance: 0.0, fenceGeotriggerName: .sectionFenceGeotrigger ) let gardenPOIsGeotriggerMonitor = makeFenceGeotriggerMonitor( feed: geotriggerFeed, featureTable: gardenPOIsTable, bufferDistance: 10.0, fenceGeotriggerName: .poiFenceGeotrigger )
return [gardenSectionsGeotriggerMonitor, gardenPOIsGeotriggerMonitor] }
/// Creates a fence geotrigger monitor. /// - Parameters: /// - feed: The `GeotriggerFeed` that is monitored for changes. /// - featureTable: The `ServiceFeatureTable` that contains the features /// to use in the `FeatureFenceParameters`. /// - bufferDistance: The `Double` buffer distance in meters to apply to /// the features when checking if an `FenceGeotrigger` condition is met. /// - fenceGeotriggerName: The name for the `FenceGeotrigger`. /// - Returns: A new `GeotriggerMonitor` object. private func makeFenceGeotriggerMonitor( feed: GeotriggerFeed, featureTable: ServiceFeatureTable, bufferDistance: Double, fenceGeotriggerName: String ) -> GeotriggerMonitor { let fenceParameters = FeatureFenceParameters( featureTable: featureTable, bufferDistance: bufferDistance )
// The Arcade expression in the fence geotrigger returns the value for // the "name" field of the feature that triggered the monitor. let fenceGeotrigger = FenceGeotrigger( feed: feed, ruleType: .enterOrExit, fenceParameters: fenceParameters, messageExpression: ArcadeExpression(expression: "$fenceFeature.name"), name: fenceGeotriggerName )
// Create and return geotrigger. return GeotriggerMonitor(geotrigger: fenceGeotrigger) }
/// Handles a notification posted by a geotrigger monitor when a fence geotrigger /// condition has been met. /// - Parameter fenceNotificationInfo: The information about the geotrigger monitor /// and the geofence that was triggered. func handleGeotriggerNotification(_ fenceNotificationInfo: FenceGeotriggerNotificationInfo) { // The feature name from the Arcade expression. let notificationType = fenceNotificationInfo.fenceNotificationType let featureName = fenceNotificationInfo.message let fenceFeature = fenceNotificationInfo.fenceGeoElement as! ArcGISFeature let geotriggerName = fenceNotificationInfo.geotriggerMonitor.geotrigger.name
// Handle notification types. switch notificationType { case .entered: // The user entered a geofence: add the feature for future querying. featureNamesInFenceGeotrigger[geotriggerName, default: []].append(featureName) nearbyFeatures[featureName] = fenceFeature
// Update the status to notify the view to update. fenceGeotriggerStatus = .entered(featureName: featureName) case .exited: // The user left the geofence: remove the feature from the dicts. // Enter and exit events follow a first-in-last-out order, so the // array is used as a stack. if let poppedFeatureName = featureNamesInFenceGeotrigger[geotriggerName]?.popLast() { nearbyFeatures.removeValue(forKey: poppedFeatureName) } fenceGeotriggerStatus = .exited(featureName: featureName) @unknown default: fatalError("Unexpected fence notification type.") } }
/// Sets the popup to show the information of the current garden section. func setSectionPopup() { guard let currentSectionName, let sectionFeature = nearbyFeatures[currentSectionName] else { return } popup = Popup(geoElement: sectionFeature) }
/// Sets the popup to show the information of the first nearby POI. func setPOIPopup() { guard let name = nearbyPOINames.first, let poiFeature = nearbyFeatures[name] else { return } popup = Popup(geoElement: poiFeature) }
/// Creates a location display from a location data source. /// - Parameter dataSource: The `LocationDataSource` to create the location display from. /// - Returns: A new `LocationDisplay` object. private static func makeLocationDisplay(dataSource: LocationDataSource) -> LocationDisplay { let locationDisplay = LocationDisplay(dataSource: dataSource) locationDisplay.autoPanMode = .recenter locationDisplay.initialZoomScale = 1000 return locationDisplay } }}
// MARK: Extensions
private extension String { /// The section fence geotrigger name. static let sectionFenceGeotrigger = "Section Fence Geotrigger"
/// The point-of-interest geotrigger name. static let poiFenceGeotrigger = "POI Fence Geotrigger"}
private extension Item.ID { /// The portal item ID of a Santa Barbara Botanic Garden web map. static var santaBarbaraBotanicGardenMap: Self { Self("6ab0e91dc39e478cae4f408e1a36a308")! }
/// The layer ID of Santa Barbara Botanic Garden Sections layer. static var gardenSectionsLayer: Self { Self("1ba816341ea04243832136379b8951d9")! }
/// The layer ID of Santa Barbara Botanic Garden Points of Interest layer. static var gardenPOIsLayer: Self { Self("7c6280c290c34ae8aeb6b5c4ec841167")! }}
private extension Polyline { /// A path in Santa Barbara Botanic Garden. static func walkingTour() -> Polyline { let walkingTourPolylineJSON = Data( #"{"paths":[[[-119.709881177746,34.4570041646846],[-119.709875813328,34.4570152227745],[-119.709869107805,34.4570240692453],[-119.709859720074,34.4570351273326],[-119.709853014551,34.4570539260775],[-119.709847650133,34.4570760422426],[-119.709848991238,34.4570926293626],[-119.70985569676,34.4571103222869],[-119.709873131119,34.4571202745552],[-119.709889224373,34.4571302268223],[-119.709902635418,34.4571357558591],[-119.709910682045,34.4571600836165],[-119.709910682045,34.4571744591062],[-119.709902635418,34.4571833055602],[-119.709889224373,34.4571910462067],[-119.70988251885,34.4571965752394],[-119.70988251885,34.4572032100782],[-119.709889224373,34.4572175855605],[-119.709898612104,34.4572264320099],[-119.709912023149,34.4572341726524],[-119.709901294313,34.4572419132941],[-119.709895929895,34.4572507597409],[-119.709897271,34.4572596061868],[-119.709902635418,34.4572728758539],[-119.709902635418,34.4572828281028],[-119.70990934094,34.457294991961],[-119.709912023149,34.4573038384022],[-119.709886542164,34.4573115790375],[-119.709861061178,34.4573248486963],[-119.709843626819,34.4573414357669],[-119.709836921297,34.4573668692686],[-119.709843626819,34.4573934085666],[-119.709827533565,34.4574055724087],[-119.709791323744,34.4574188420525],[-119.709749749504,34.4574332174977],[-119.709709516369,34.4574431697275],[-119.709734997354,34.4574807670294],[-119.709748062646,34.4575248306656],[-119.709757450378,34.4575635337324],[-119.709770861423,34.457600025179],[-119.709785613572,34.4576387282109],[-119.70980573014,34.4576730080242],[-119.709815117871,34.4577117110223],[-119.709821823394,34.4577504140025],[-119.709821823394,34.4577869053674],[-119.709821823394,34.4578256083127],[-119.70981780008,34.4578609938471],[-119.709819141185,34.457906331541],[-119.70981460448,34.4579890675855],[-119.709818627793,34.4580675790658],[-119.70982667442,34.4581118108532],[-119.709832038838,34.4581471962662],[-119.709834721047,34.4581947453913],[-119.709836062152,34.4582323423548],[-119.709834721047,34.4582787856393],[-119.709805216748,34.4583429215611],[-119.709759619195,34.4584026342716],[-119.709700610597,34.4584612411497],[-119.709645400048,34.4585103926263],[-119.709566274882,34.4585457778704],[-119.709493855239,34.4585944325566],[-119.709458986522,34.458622077252],[-119.709424117805,34.4586198656767],[-119.709386566878,34.4586110193749],[-119.70935438037,34.4586110193749],[-119.709339628221,34.4586231830396],[-119.709324876071,34.4586585682359],[-119.709306100608,34.4586862129101],[-119.709269890786,34.4587171749343],[-119.709244409801,34.4587238096523],[-119.709229657651,34.4587293385835],[-119.709212223293,34.4587459253751],[-119.70919076562,34.4587945799446],[-119.709174672366,34.4588503400161],[-119.709157238008,34.4589288506865],[-119.709153214694,34.4589951976744],[-119.709155896903,34.4590449578807],[-119.70916394353,34.4590958238387],[-119.709186742307,34.4591323146156],[-119.709218928815,34.4591621706939],[-119.709237704278,34.4591831805204],[-119.709241727592,34.4592252001575],[-119.709238133851,34.459258658624],[-119.709219358388,34.4592796684262],[-119.709207288447,34.4592962551085],[-119.709208629552,34.4593084186733],[-119.709271661463,34.4593791884701],[-119.709310553494,34.4594267368937],[-119.709330670062,34.4594510639836],[-119.709353468838,34.4595008239182],[-119.70936285657,34.4595362087426],[-119.709423221989,34.4595943612845],[-119.709455408497,34.4596297460692],[-119.709487595005,34.459665130839],[-119.709507711573,34.4596817174446],[-119.709523804827,34.4596861405389],[-119.709557332439,34.4596894578594],[-119.709586836739,34.4596894578594],[-119.709593542261,34.4596772943501],[-119.709590860052,34.4596496500041],[-119.709572084589,34.4595777746615],[-119.709566720171,34.4595313320996],[-119.709578790112,34.4595136396883],[-119.709590860052,34.4594992646013],[-119.709627069874,34.4594882068404],[-119.709675349636,34.4595567649343],[-119.709735699339,34.4596197941001],[-119.709775932474,34.4596795058974],[-119.709802754564,34.4597126790997],[-119.709832258863,34.4597359003334],[-119.70986712758,34.4597171021923],[-119.70986980979,34.4596839289918],[-119.709865786476,34.4596308518435],[-119.709876515312,34.4595788804365],[-119.70988187973,34.4595346494263],[-119.709879197521,34.4594926299449],[-119.709852375431,34.4594539277723],[-119.709806777878,34.4593953215911],[-119.709767885847,34.4593212344729],[-119.709720947189,34.4592195028005],[-119.709708720088,34.4591478732967],[-119.709710061193,34.4591058536206],[-119.709707378984,34.459079314867],[-119.709652393699,34.4590262373344],[-119.709617524982,34.4589499383221],[-119.709626771268,34.4588695962162],[-119.709683097658,34.4588032491285],[-119.709730036316,34.4587391135603],[-119.709759540615,34.4586993052518],[-119.7097850216,34.4586650258598],[-119.710059948024,34.4587744987075],[-119.710104204473,34.4587932970608],[-119.710128443889,34.4587649224307],[-119.71019415801,34.4587096331253],[-119.710273283176,34.4586731421637],[-119.71031619852,34.4586532379961],[-119.710367160491,34.4586023717685],[-119.710392641477,34.4585747270665],[-119.710432874612,34.4585083797445],[-119.710471766643,34.4584453497398],[-119.710505294256,34.4584165991955],[-119.710575487456,34.4583689119728],[-119.710705574593,34.4583136224052],[-119.710780676446,34.4582627559707],[-119.710839685044,34.4582030431601],[-119.710895995717,34.4581546042213],[-119.710948298793,34.458095997128],[-119.710988531928,34.4580263320391],[-119.711015354018,34.4579511379096],[-119.711011330705,34.4579102234284],[-119.710985849719,34.4578847900921],[-119.710946957688,34.4578715205223],[-119.710779319625,34.4578847900921],[-119.71073908649,34.4578847900921],[-119.7107122644,34.4578604625458],[-119.71069751225,34.4578295002039],[-119.710674713473,34.4578095958352],[-119.710642526965,34.4577996436491],[-119.710614363771,34.457830606002],[-119.71057547174,34.4578571451526],[-119.710539261918,34.4578737321174],[-119.71049500547,34.4578858958895],[-119.710452090125,34.4578836842947],[-119.710413198095,34.4578748379149],[-119.710375647168,34.4578527219614],[-119.710336755138,34.4578350291944],[-119.710299204211,34.4578107016336],[-119.71027506433,34.4577885856631],[-119.710269605616,34.4577886418169],[-119.710240101317,34.4577510446536],[-119.710198527077,34.4576758502763],[-119.710154270628,34.4575984442288],[-119.710112696389,34.4575265671206],[-119.710044300059,34.4574381029023],[-119.709978842634,34.4573543166616],[-119.709966772693,34.4573410470074],[-119.709972137111,34.4573200367174],[-119.70997481932,34.4573023438375],[-119.709973478216,34.4572846509538],[-119.709970796007,34.457259217427],[-119.709931903976,34.4572348896984],[-119.709914469617,34.4572337838924],[-119.709893011945,34.4572171968005],[-119.709884965318,34.4571972922858],[-119.709902232039,34.4571821133624],[-119.70991161977,34.457167737874],[-119.709907596457,34.4571389868898],[-119.709919666397,34.4571235055865],[-119.709922348606,34.4571047068572],[-119.709918325293,34.4570836965077],[-119.709919666397,34.4570648977695],[-119.70992637192,34.4570516280694],[-119.709933077442,34.4570339351326],[-119.709935759651,34.4570151363832],[-119.709927713024,34.4570062899114],[-119.70991161977,34.4570018666751],[-119.709883456576,34.4570040782933]]],"spatialReference":{"wkid":4326,"latestWkid":4326}}"#.utf8 ) let routePolyline = try? Polyline.fromJSON(walkingTourPolylineJSON)
// Densify the polyline to control the simulation speed. let densifiedRoute = GeometryEngine.geodeticDensify( routePolyline!, maxSegmentLength: 5.0, lengthUnit: .meters, curveType: .geodesic ) as! Polyline return densifiedRoute }}