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

Use case
Annotation is 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). 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 text on the map is annotation, 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
- Load the geodatabase. NOTE: Read/write geodatabases should normally come from a
GeodatabaseSyncTask, but this has been omitted here. - Create
FeatureLayers from geodatabase feature tables found in the geodatabase withGeodatabase.featureTable(named:). - Create
AnnotationLayers from geodatabase feature tables found in the geodatabase withGeodatabase.annotationTable(named:). - Add the
FeatureLayers andAnnotationLayers to the map’s operational layers. - Use a
GeoView.onSingleTapGesture(perform:)modifier to handles taps on theMapViewto 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
- AnnotationLayer
- Feature
- FeatureLayer
- Geodatabase
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 annotation linked to the point data in this sample is defined by arcade expression $feature.AD_ADDRESS + " " + $feature.ST_STR_NAM. The annotation 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
// Copyright 2024 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 SwiftUI
struct EditFeaturesWithFeatureLinkedAnnotationView: View { /// The view model for the sample. @StateObject private var model = Model()
/// The asynchronous action currently being preformed. @State private var selectedAction = AsyncAction.setUpMap
/// The current instruction being displayed at the top of the screen. @State private var instruction = Instruction.selectFeature
/// The point on the map where the user tapped. @State private var tapLocation: Point?
/// The building number of the selected feature. @State private var buildingNumber: Int32?
/// The street name of the selected feature. @State private var streetName: String = ""
/// A Boolean value indicating whether the edit address alert is presented. @State private var editAddressAlertIsPresented = false
/// A Boolean value indicating whether the move confirmation alert is presented. @State private var moveConfirmationAlertIsPresented = false
/// The error shown in the error alert. @State private var error: (any Error)?
var body: some View { MapViewReader { mapViewProxy in MapView(map: model.map) .onSingleTapGesture { screenPoint, mapPoint in if model.selectedFeature == nil { // Selects a feature at the tap location if there isn't one selected. selectedAction = .selectFeature(screenPoint: screenPoint) } else { // Shows the move confirmation alert if there is already a selected feature. tapLocation = mapPoint moveConfirmationAlertIsPresented = true } } .task(id: selectedAction) { do { // Performs the selected action. switch selectedAction { case .setUpMap: try await model.setUpMap() case .selectFeature(let screenPoint): let layerIdentifyResults = try await mapViewProxy.identifyLayers( screenPoint: screenPoint, tolerance: 10 ) model.selectFirstFeature(from: layerIdentifyResults)
if model.selectedFeature != nil { instruction = .moveFeature } case .setFeatureAddress(let buildingNumber, let streetName): try await model.setFeatureAddress( buildingNumber: buildingNumber, streetName: streetName ) instruction = .moveFeature case .updateFeatureGeometry(let mapPoint): try await model.updateFeatureGeometry(with: mapPoint) instruction = .selectFeature } } catch { self.error = error } } .errorAlert(presentingError: $error) .overlay(alignment: .top) { Text(instruction.message) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .center) .padding(8) .background(.regularMaterial, ignoresSafeAreaEdges: .horizontal) } } .onChange(of: model.selectedFeature == nil) { if model.selectedFeature?.geometry is Point, let featureAddress = model.selectedFeatureAddress { // Presents the alert to update the feature's address if the feature is a point. buildingNumber = featureAddress.buildingNumber streetName = featureAddress.streetName editAddressAlertIsPresented = true } else if let polyline = model.selectedFeature?.geometry as? Polyline, polyline.parts.contains(where: { $0.points.count > 2 }) { // Shows a message if the feature is a polyline with any part // containing more than one segment, i.e., a curve. instruction = .selectStraightPolyline model.clearSelectedFeature() } } .alert("Edit Address", isPresented: $editAddressAlertIsPresented) { TextField("Building Number", value: $buildingNumber, format: .number.grouping(.never)) .keyboardType(.numberPad) TextField("Street Name", text: $streetName) Button("Cancel", role: .cancel) { model.clearSelectedFeature() instruction = .selectFeature } Button("Done") { selectedAction = .setFeatureAddress( buildingNumber: buildingNumber!, streetName: streetName ) } .disabled(buildingNumber == nil || streetName.isEmpty) } message: { Text("Edit the feature's 'AD_ADDRESS' and 'ST_STR_NAM' attributes.") } .alert("Confirm Move", isPresented: $moveConfirmationAlertIsPresented) { Button("Cancel", role: .cancel) { model.clearSelectedFeature() instruction = .selectFeature } Button("Move") { selectedAction = .updateFeatureGeometry(mapPoint: tapLocation!) } } message: { Text("Are you sure you want to move the selected feature?") } }}
private extension EditFeaturesWithFeatureLinkedAnnotationView { /// An asynchronous action associated with the sample. enum AsyncAction: Equatable { /// Sets up the map for the sample. case setUpMap /// Selects a feature identified at a given point on the screen. case selectFeature(screenPoint: CGPoint) /// Sets the address attributes of the selected feature to given values. case setFeatureAddress(buildingNumber: Int32, streetName: String) /// Updates the selected feature's geometry using a given point on the map. case updateFeatureGeometry(mapPoint: Point) }
/// An instruction associated with the sample. enum Instruction { case selectFeature, selectStraightPolyline, moveFeature
/// The message for the instruction. var message: String { switch self { case .selectFeature: "Select a point or polyline to edit." case .selectStraightPolyline: "Select straight (single segment) polylines only." case .moveFeature: "Tap on the map to move the feature." } } }}// Copyright 2024 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 Foundation
extension EditFeaturesWithFeatureLinkedAnnotationView { /// The view model for the sample. @MainActor final class Model: ObservableObject { /// The feature currently selected by the user. @Published private(set) var selectedFeature: Feature?
/// A map with a light gray basemap. let map: Map = { let map = Map(basemapStyle: .arcGISLightGray)
// Initially centers the map in Loudoun County, VA, USA. map.initialViewpoint = Viewpoint(latitude: 39.0204, longitude: -77.4159, scale: 2256) return map }()
/// A URL to the temporary file containing the geodatabase. private let temporaryGeodatabaseURL = FileManager .createTemporaryDirectory() .appending(component: "LoudounAnno.geodatabase")
/// The building number and street name of the selected feature. var selectedFeatureAddress: (buildingNumber: Int32, streetName: String)? { if let buildingNumber = selectedFeature?.attributes[.addressFieldKey] as? Int32, let streetName = selectedFeature?.attributes[.streetNameFieldKey] as? String { return (buildingNumber, streetName) } else { return nil } }
deinit { // Removes the temporary geodatabase file and its directory. let temporaryDirectoryURL = temporaryGeodatabaseURL.deletingLastPathComponent() try? FileManager.default.removeItem(at: temporaryDirectoryURL) }
/// Adds feature and annotation layers created from geodatabase feature tables to the map. func setUpMap() async throws { // Creates a geodatabase from a file copied from the bundle. try FileManager.default.copyItem(at: .loudounAnnoGeodatabase, to: temporaryGeodatabaseURL) let geodatabase = Geodatabase(fileURL: temporaryGeodatabaseURL) try await geodatabase.load()
// Creates feature and annotation layers from tables in the geodatabase. let featureTableNames = ["ParcelLines_1", "Loudoun_Address_Points_1"] let featureLayers = featureTableNames .compactMap { geodatabase.featureTable(named: $0) } .map(FeatureLayer.init)
let annotationTableNames = ["ParcelLinesAnno_1", "Loudoun_Address_PointsAnno_1"] let annotationLayers = annotationTableNames .compactMap { geodatabase.annotationTable(named: $0) } .map(AnnotationLayer.init)
// Adds the layers to the map. map.addOperationalLayers(featureLayers) map.addOperationalLayers(annotationLayers) }
/// Selects the first feature in a given list of identify layer results. /// - Parameter results: The identify layer results. func selectFirstFeature(from results: [IdentifyLayerResult]) { clearSelectedFeature()
// Gets the first feature from the first feature layer in the results. guard let featureLayerResult = results.first(where: { $0.layerContent is FeatureLayer }), let featureLayer = featureLayerResult.layerContent as? FeatureLayer, let feature = featureLayerResult.geoElements.first as? ArcGISFeature else { return }
featureLayer.selectFeature(feature) selectedFeature = feature }
/// Sets the address attributes of the selected feature. /// - Parameters: /// - buildingNumber: The number of the building for the `AD_ADDRESS` field. /// - streetName: The name of street for the `ST_STR_NAM` field. func setFeatureAddress(buildingNumber: Int32, streetName: String) async throws { guard let selectedFeature else { return }
selectedFeature.setAttributeValue(buildingNumber, forKey: .addressFieldKey) selectedFeature.setAttributeValue(streetName, forKey: .streetNameFieldKey)
try await updateSelectedFeature() }
/// Updates the selected feature's geometry using a given map point. /// - Parameter mapPoint: The point on the map. func updateFeatureGeometry(with mapPoint: Point) async throws { if selectedFeature?.geometry is Point { // Sets the feature's geometry to the map point if the feature is a point. selectedFeature?.geometry = mapPoint } else if let polyline = selectedFeature?.geometry as? Polyline, let projectedPoint = GeometryEngine.project(mapPoint, into: polyline.spatialReference!), let nearestVertex = GeometryEngine.nearestVertex(in: polyline, to: projectedPoint) { // Replaces the nearest vertex with the map point if the feature is a polyline. let polylineBuilder = PolylineBuilder(polyline: polyline)
// Removes the nearest vertex from the polyline. polylineBuilder.parts[nearestVertex.partIndex!].points.remove( at: nearestVertex.pointIndex! )
// Adds the new point to the polyline and sets it to the selected feature's geometry. polylineBuilder.add(projectedPoint) selectedFeature?.geometry = polylineBuilder.toGeometry() }
try await updateSelectedFeature() clearSelectedFeature() }
/// Clears the selected feature. func clearSelectedFeature() { let featureLayer = selectedFeature?.table?.layer as? FeatureLayer featureLayer?.clearSelection() selectedFeature = nil }
/// Updates the selected feature in its feature table. private func updateSelectedFeature() async throws { guard let selectedFeature else { return } try await selectedFeature.table?.update(selectedFeature) } }}
private extension String { /// The key for the address attribute field. static var addressFieldKey: String { "AD_ADDRESS" } /// The key for the street name attribute field. static var streetNameFieldKey: String { "ST_STR_NAM" }}
private extension FileManager { /// Creates a temporary directory. /// - Returns: The URL of the created directory. static func createTemporaryDirectory() -> URL { try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: FileManager.default.temporaryDirectory, create: true ) }}
private extension URL { /// The URL to the local "Loudoun Anno" geodatabase file. static var loudounAnnoGeodatabase: URL { Bundle.main.url(forResource: "loudoun_anno", withExtension: "geodatabase")! }}