Edit features with feature-linked annotation

View on GitHub
Sample viewer app

Edit feature attributes which are linked to annotations through an expression.

Image of edit features with feature-linked annotation

Use case

Annotations are useful for displaying text that you don't want to move or resize when the map is panned or zoomed (unlike labels which will move and resize). A Feature-linked annotation will update when a feature attribute referenced by the annotation expression is also updated. Additionally, the position of the annotation will transform to match any transformation to the linked feature's geometry.

How to use the sample

Pan and zoom the map to see that the texts on the map are annotations, not labels. Tap one of the address points to update the house number (AD_ADDRESS) and street name (ST_STR_NAM). Tap one of the dashed parcel polylines and tap another location to change its geometry.

NOTE: Selection is only enabled for points and straight (single segment) polylines. The feature-linked annotation will update accordingly.

How it works

  1. Load the geodatabase using AGSGeodatabase.load(completion:). NOTE: Read/write geodatabases should normally come from an AGSGeodatabaseSyncTask, but this has been omitted here.
  2. Create AGSFeatureLayers from the geodatabase's array of feature tables.
  3. Create AGSAnnotationLayers from the feature tables.
  4. Add the AGSFeatureLayers and AGSAnnotationLayers to the map's operational layers.
  5. Assign an instance of a class that conforms to AGSGeoViewTouchDelegate to the map view's touchDelegate property.
  6. Implement geoView(_:didTapAtScreenPoint:mapPoint:) to track taps on the map to either select address points or parcel polyline features. NOTE: Selection is only enabled for points and straight (single segment) polylines.

    • For the address points, an alert is opened to allow editing of the address number (AD_ADDRESS) and street name (ST_STR_NAM) attributes.
    • For the parcel lines, a second tap will change one of the polyline's vertices.

Both expressions were defined by the data author in ArcGIS Pro using the Arcade expression language.

Relevant API

  • AGSAnnotationLayer
  • AGSFeature
  • AGSFeatureLayer
  • AGSGeodatabase

Offline data

This sample uses data from ArcGIS Online. It is downloaded automatically.

About the data

This sample uses data derived from the Loudoun GeoHub.

The annotations linked to the point data in this sample is defined by arcade expression $feature.AD_ADDRESS + " " + $feature.ST_STR_NAM. The annotations linked to the parcel polyline data is defined by Round(Length(Geometry($feature), 'feet'), 2).

Tags

annotation, attributes, feature-linked annotation, features, fields

Sample Code

EditFeaturesWithFeatureLinkedAnnotationViewController.swift
                                                                                                                                                                                                                                                                                             
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
// Copyright © 2020 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
//
//   http://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 EditFeaturesWithFeatureLinkedAnnotationViewController: UIViewController {
    @IBOutlet weak var mapView: AGSMapView! {
        didSet {
            // Create the map with a light gray canvas basemap centered on Loudoun, Virginia.
            mapView.map = AGSMap(basemapStyle: .arcGISLightGrayBase)
            mapView.setViewpoint(AGSViewpoint(latitude: 39.0204, longitude: -77.4159, scale: 2256.994353))
            // Set the touch delegate.
            mapView.touchDelegate = self
        }
    }
    // The geodatabase used by this sample.
    let geodatabase: AGSGeodatabase!
    // The feature that has been selected.
    var selectedFeature: AGSFeature?
    // The returned cancelable after identifying the layers.
    var identifyOperation: AGSCancelable?

    required init?(coder: NSCoder) {
        // Create a URL leading to the resource.
        let geodatabaseURL = Bundle.main.url(forResource: "loudoun_anno", withExtension: "geodatabase")!
        do {
            // Create a temporary directory URL.
            let temporaryDirectoryURL = try FileManager.default.url(
                for: .itemReplacementDirectory,
                in: .userDomainMask,
                appropriateFor: geodatabaseURL,
                create: true
            )
            // Create a temporary URL where the geodatabase URL can be copied to.
            let temporaryGeodatabaseURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString)
            try FileManager.default.copyItem(at: geodatabaseURL, to: temporaryGeodatabaseURL)
            // Create the geodatabase with the URL.
            geodatabase = AGSGeodatabase(fileURL: temporaryGeodatabaseURL)
        } catch {
            print("Error setting up geodatabase: \(error)")
            geodatabase = nil
        }

        super.init(coder: coder)

        // Load the geodatabase.
        geodatabase?.load { [weak self] (error) in
            let result: Result<Void, Error>
            if let error = error {
                result = .failure(error)
            } else {
                result = .success(())
            }
            self?.geodatabaseDidLoad(with: result)
        }
    }

    deinit {
        if let geodatabase = geodatabase {
            geodatabase.close()
            try? FileManager.default.removeItem(at: geodatabase.fileURL)
        }
    }

    // Called in response to the geodatabase load operation completing.
    func geodatabaseDidLoad(with result: Result<Void, Error>) {
        switch result {
        case .success:
            guard let map = self.mapView.map else { return }
            // Create feature layers from tables in the geodatabase.
            let featureTableNames = ["ParcelLines_1", "Loudoun_Address_Points_1"]
            let featureTables = featureTableNames.compactMap { self.geodatabase.geodatabaseFeatureTable(withName: $0) }
            let featureLayers = featureTables.map(AGSFeatureLayer.init)
            // Add the feature layers to the map.
            map.operationalLayers.addObjects(from: featureLayers)
            // Create annotation layers from tables in the geodatabase.
            let annotationTableNames = ["ParcelLinesAnno_1", "Loudoun_Address_PointsAnno_1"]
            let annotationTables = annotationTableNames.compactMap { self.geodatabase.geodatabaseAnnotationTable(withTableName: $0) }
            let annotationLayers = annotationTables.map(AGSAnnotationLayer.init)
            // Add the annotation layers to the map.
            map.operationalLayers.addObjects(from: annotationLayers)
        case .failure(let error):
            self.presentAlert(error: error)
        }
    }

    func selectFeature(at point: CGPoint) {
        // Clear any previously selected features.
        clearSelection()

        // Identify across all layers.
        identifyOperation = mapView.identifyLayers(atScreenPoint: point, tolerance: 10.0, returnPopupsOnly: false) { [weak self] (results: [AGSIdentifyLayerResult]?, error: Error?) in
            guard let self = self else { return }
            if let error = error {
                self.clearSelection()
                self.presentAlert(error: error)
            } else if let results = results {
                for result in results {
                    guard let featureLayer = result.layerContent as? AGSFeatureLayer, let selectedFeature = result.geoElements.first as? AGSFeature else { continue }
                    switch selectedFeature.geometry?.geometryType {
                    case .point:
                        // If the selected feature is a point, prompt the edit attributes alert.
                        self.showEditableAttributes(selectedFeature: selectedFeature)
                    case .polyline:
                        let polyline = selectedFeature.geometry as! AGSPolyline
                        // If the selected feature is a polyline with any part containing more than one segment (i.e. a curve), present an alert.
                        if polyline.parts.array().contains(where: { $0.pointCount > 2 }) {
                            self.presentAlert(title: "Make a different selection", message: "Select straight (single segment) polylines only.")
                            return
                        }
                    default:
                        return
                    }
                    // Select the feature.
                    featureLayer.select(selectedFeature)
                    // Set the feature globally.
                    self.selectedFeature = selectedFeature
                    return
                }
            }
        }
    }

    // Create an alert dialog with edit texts to allow editing of the given feature's 'AD_ADDRESS' and 'ST_STR_NAM' attributes.
    func showEditableAttributes(selectedFeature: AGSFeature) {
        // Create an object to observe changes from the text fields.
        var textFieldObserver: NSObjectProtocol!
        // Create an alert controller and customize the title and message.
        let alert = UIAlertController(title: "Edit Feature Attributes", message: "Edit the 'AD_ADDRESS' and 'ST_STR_NAM' attributes.", preferredStyle: .alert)
        // Add a "Done" option to complete the editing process and close the alert.
        let doneAction = UIAlertAction(title: "Done", style: .default) { _ in
            let addressTextField = alert.textFields?[0]
            let streetTextField = alert.textFields?[1]
            // Set the new values for the attributes and update the selected feature.
            selectedFeature.attributes["AD_ADDRESS"] = Int((addressTextField?.text)!)
            selectedFeature.attributes["ST_STR_NAM"] = streetTextField?.text
            selectedFeature.featureTable?.update(selectedFeature)
            // Remove the observer after editing is complete.
            if let textFieldObserver = textFieldObserver {
                NotificationCenter.default.removeObserver(textFieldObserver)
            }
        }
        alert.addAction(doneAction)
        // Add an observer to ensure the user does not input an empty string.
        textFieldObserver = NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: nil, queue: .main, using: { [unowned alert, unowned doneAction] _ in
            // Enable the done button if both textfields are not empty.
            doneAction.isEnabled = (alert.textFields?.allSatisfy { $0.text?.isEmpty == false })!
        })
        // Add a text field to prompt the user to input the street address.
        alert.addTextField { (textField) in
            // Prompt a number pad for the address.
            textField.keyboardType = .numberPad
            // Populate the text fields with the current value.
            textField.text = (selectedFeature.attributes["AD_ADDRESS"] as! NSNumber).stringValue
        }
        // Add a text field to prompt the user to input the street name.
        alert.addTextField { (textField) in
            // Prompt a keyboard to enter the street name.
            textField.keyboardType = .default
            textField.text = selectedFeature.attributes["ST_STR_NAM"] as? String
        }
        // Add a "Cancel" option and clear the selection if cancel is selected.
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
            self.clearSelection()
            // Remove the observer after editing is complete.
            if let textFieldObserver = textFieldObserver {
                NotificationCenter.default.removeObserver(textFieldObserver)
            }
        })
        // Present the alert.
        present(alert, animated: true)
    }

    // Move the currently selected point feature to the given map point, by updating the selected feature's geometry and feature table.
    func moveSelectedFeature(to mapPoint: AGSPoint) {
        guard let selectedFeature = selectedFeature else { return }
        // Create an alert to confirm that the user wants to update the geometry.
        let alert = UIAlertController(title: "Confirm Update", message: "Are you sure you want to move the selected feature?", preferredStyle: .alert)
        // Clear the selection and selected feature if "No" is selected.
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
            self.clearSelection()
        })
        alert.addAction(UIAlertAction(title: "Move", style: .default) { _ in
            // Set the selected feature's geometry to the new map point.
            selectedFeature.geometry = mapPoint
            // Update the selected feature's feature table.
            selectedFeature.featureTable?.update(selectedFeature) { [weak self] (error: Error?) in
                guard let self = self else { return }
                if let error = error {
                    self.presentAlert(error: error)
                } else {
                    // Clear selection of polyline.
                    self.clearSelection()
                }
            }
        })
        // Present the alert.
        present(alert, animated: true)
    }

    // Move the last of the vertex point of the currently selected polyline to the given map point, by updating the selected feature's geometry and feature table.
    func moveLastVertexOfSelectedFeature(to mapPoint: AGSPoint) {
        guard let selectedFeature = self.selectedFeature else { return }
        // Create an alert to confirm that the user wants to update the geometry.
        let alert = UIAlertController(title: "Confirm Update", message: "Are you sure you want to move the selected feature?", preferredStyle: .alert)
        // Clear the selection and selected feature if "No" is selected.
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
            self.clearSelection()
        })
        alert.addAction(UIAlertAction(title: "Move", style: .default) { _ in
            // Get the selected feature's geometry as a polyline nearest to the map point.
            guard let polyline = selectedFeature.geometry as? AGSPolyline,
                let selectedMapPoint = AGSGeometryEngine.projectGeometry(mapPoint, to: polyline.spatialReference!) as? AGSPoint,
                let nearestVertex = AGSGeometryEngine.nearestVertex(in: polyline, to: selectedMapPoint) else { return }
            let polylineBuilder = AGSPolylineBuilder(polyline: polyline)
            // Get the part of the polyline nearest to the map point.
            polylineBuilder.parts[nearestVertex.partIndex].removePoint(at: nearestVertex.partIndex)
            // Add the map point as the new point on the polyline.
            polylineBuilder.parts[nearestVertex.partIndex].addPoint(AGSGeometryEngine.projectGeometry(mapPoint, to: polylineBuilder.parts[nearestVertex.partIndex].spatialReference!) as! AGSPoint)
            // Set the selected feature's geometry to the new polyline.
            selectedFeature.geometry = polylineBuilder.toGeometry()
            // Update the selected feature's feature table.
            selectedFeature.featureTable?.update(selectedFeature) { [weak self] (error: Error?) in
                guard let self = self else { return }
                if let error = error {
                    self.presentAlert(error: error)
                } else {
                    // Clear selection of polyline.
                    self.clearSelection()
                }
            }
        })
        present(alert, animated: true)
    }

    // Clear selection from all feature layers.
    func clearSelection() {
        selectedFeature = nil
        mapView.map?.operationalLayers.forEach { layer in
            if let featureLayer = layer as? AGSFeatureLayer {
                featureLayer.clearSelection()
            }
        }
    }

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

// MARK: - AGSGeoViewTouchDelegate
// Select the nearest feature or move the point or polyline vertex to the given screen point.
extension EditFeaturesWithFeatureLinkedAnnotationViewController: AGSGeoViewTouchDelegate {
    func geoView(_ geoView: AGSGeoView, didTapAtScreenPoint screenPoint: CGPoint, mapPoint: AGSPoint) {
        if let identifyOperation = identifyOperation {
            identifyOperation.cancel()
            self.identifyOperation = nil
        }
        // If a feature has been selected, determine what the type of geometry and move it accordingly.
        if let selectedFeature = selectedFeature {
            if selectedFeature.geometry?.geometryType == .polyline {
                moveLastVertexOfSelectedFeature(to: mapPoint)
            } else {
                moveSelectedFeature(to: mapPoint)
            }
        } else {
            // If a feature hasn't been selected.
            selectFeature(at: screenPoint)
        }
    }
}

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