Use the Geometry Editor to edit a geometry and align it to existing geometries on a map.

Use case
A field worker can create new features by editing and snapping the vertices of a geometry to existing features on a map. In a water distribution network, service line features can be represented with the polyline geometry type. By snapping the vertices of a proposed service line to existing features in the network, an exact footprint can be identified to show the path of the service line and what features in the network it connects to. The feature layer containing the service lines can then be accurately modified to include the proposed line.
How to use the sample
To create a geometry, press the create button to choose the geometry type you want to create (i.e. points, multipoints, polyline, or polygon) and interactively tap and drag on the map view to create the geometry.
Snap settings can be configured by enabling or disabling snapping, feature snapping, geometry guides, and snap sources.
To interactively snap a vertex to a feature or graphic, ensure that snapping is enabled for the relevant snap source and move the mouse pointer or drag a vertex close to an existing feature or graphic. When the pointer is close to that existing geoelement, the edit position will be adjusted to coincide with (or snap to), edges and vertices of its geometry. Release the touch pointer to place the vertex at the snapped location.
To more clearly see how the vertex is snapped, long press to invoke the magnifier before starting to move the pointer.
How it works
- Create a
Mapfrom the portal item and add it to theMapView. - Set the map’s
loadSettings.featureTilingModetoenabledWithFullResolutionWhenSupported. - Create a
GeometryEditorand connect it to the map view. - Call
syncSourceSettings()after the map’s operational layers are loaded and the geometry editor is connected to the map view. - Set
SnapSettings.isEnabledand eachSnapSourceSettings.isEnabledtotruefor theSnapSourceof interest. - Toggle geometry guides using
SnapSettings.snapsToGeometryGuidesand feature snapping usingSnapSettings.snapsToFeatures. - Start the geometry editor with a
GeometryType.
Relevant API
- FeatureLayer
- Geometry
- GeometryEditor
- GeometryEditorStyle
- GraphicsOverlay
- MapView
- ReticleVertexTool
- SnapSettings
- SnapSource
- SnapSourceSettings
About the data
The Naperville water distribution network is based on ArcGIS Solutions for Water Utilities and provides a realistic depiction of a theoretical stormwater network.
Additional information
Snapping is used to maintain data integrity between different sources of data when editing, so it is important that each SnapSource provides full resolution geometries to be valid for snapping. This means that some of the default optimizations used to improve the efficiency of data transfer and display of polygon and polyline layers based on feature services are not appropriate for use with snapping.
To snap to polygon and polyline layers, the recommended approach is to set the FeatureLayer’s feature tiling mode to FeatureTilingMode.enabledWithFullResolutionWhenSupported and use the default ServiceFeatureTable feature request mode FeatureRequestMode.onInteractionCache. Local data sources, such as geodatabases, always provide full resolution geometries. Point and multipoint feature layers are also always full resolution.
Snapping can be used during interactive edits that move existing vertices using the VertexTool or ReticleVertexTool. When adding new vertices, snapping also works with a hover event (such as a mouse move without a mouse button press). Using the ReticleVertexTool to add and move vertices allows users of touch screen devices to clearly see the visual cues for snapping.
Geometry guides are enabled by default when snapping is enabled. These allow for snapping to a point coinciding with, parallel to, perpendicular to, or extending an existing geometry.
On supported platforms, haptic feedback on SnapState.snappedToFeature and SnapState.snappedToGeometryGuide is enabled by default when snapping is enabled. Custom haptic feedback can be configured by setting SnapSettings.hapticFeedbackIsEnabled to false and listening to the GeometryEditor.snapChanges stream to provide specific feedback depending on the SnapState.
When using SubtypeFeatureLayer objects as snap sources instead of FeatureLayer, child SubtypeSublayer objects are included as snap sources in the parent SnapSourceSettings.childSourceSettings collection in the same order as the SubtypeFeatureLayer.subtypeSublayers collection.
Tags
edit, feature, geometry editor, graphics, layers, map, snapping
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 SnapGeometryEditsView: View { /// The map to display in the view. @State private var map: Map = { let map = Map( item: PortalItem( portal: .arcGISOnline(connection: .anonymous), // A stripped down Naperville water distribution network web map. id: PortalItem.ID("b95fe18073bc4f7788f0375af2bb445e")! ) ) // Snapping is used to maintain data integrity between different sources // of data when editing, so full resolution is needed for valid snapping. map.loadSettings.featureTilingMode = .enabledWithFullResolutionWhenSupported return map }()
/// The model that is required by the geometry editor menu. @StateObject private var model = GeometryEditorModel()
/// The error shown in the error alert. @State private var error: (any Error)?
/// A Boolean value indicating whether all the snap sources on the map view are loaded. @State private var snapSourcesAreLoaded = false
/// A Boolean value indicating whether the snap settings are presented. @State private var showsSnapSettings = false
var body: some View { MapView(map: map, graphicsOverlays: [model.geometryOverlay]) .geometryEditor(model.geometryEditor) .onDrawStatusChanged { drawStatus in guard !snapSourcesAreLoaded else { return } snapSourcesAreLoaded = drawStatus == .completed } .toolbar { ToolbarItemGroup(placement: .bottomBar) { Spacer()
GeometryEditorMenu(model: model)
Spacer()
Button("Snap Settings") { do { try model.geometryEditor.snapSettings.syncSourceSettings() showsSnapSettings = true } catch { self.error = error } } .popover(isPresented: $showsSnapSettings) { NavigationStack { // Various snapping settings for a geometry editor. SnapSettingsView(model: model) } .presentationDetents([.fraction(0.6)]) .frame(idealWidth: 320, idealHeight: 380) } .disabled(!snapSourcesAreLoaded) } } .errorAlert(presentingError: $error) }}
#Preview { NavigationStack { SnapGeometryEditsView() }}// 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
extension SnapGeometryEditsView { struct SnapSettingsView: View { /// The action to dismiss the settings sheet. @Environment(\.dismiss) private var dismiss: DismissAction
/// The view model for the sample. @ObservedObject var model: GeometryEditorModel
/// A Boolean value indicating whether snapping is enabled /// for the geometry editor. @State private var snappingEnabled = false
/// A Boolean value indicating whether the geometry editor snaps to geometry guides. @State private var snapsToGeometryGuides = false
/// A Boolean value indicating whether the geometry editor snaps to features and graphics. @State private var snapsToFeatures = false
/// An array of snap source names and their source settings. @State private var snapSources: [(name: String, sourceSettings: SnapSourceSettings)] = []
/// An array of Boolean values for each snap source enabled states. @State private var snapSourceEnabledStates: [Bool] = []
var body: some View { Form { Section("Geometry Editor Snapping") { Toggle("Snapping", isOn: $snappingEnabled) .onChange(of: snappingEnabled) { model.geometryEditor.snapSettings.isEnabled = snappingEnabled }
Toggle("Geometry Guides", isOn: $snapsToGeometryGuides) .onChange(of: snapsToGeometryGuides) { model.geometryEditor.snapSettings.snapsToGeometryGuides = snapsToGeometryGuides } .disabled(!snappingEnabled)
Toggle("Feature Snapping", isOn: $snapsToFeatures) .onChange(of: snapsToFeatures) { model.geometryEditor.snapSettings.snapsToFeatures = snapsToFeatures } .disabled(!snappingEnabled) }
Section("Individual Source Snapping") { ForEach(0 ..< snapSources.count, id: \.self) { index in Toggle(snapSources[index].name, isOn: $snapSourceEnabledStates[index]) .onChange(of: snapSourceEnabledStates[index]) { snapSources[index].sourceSettings.isEnabled = snapSourceEnabledStates[index] } } } .disabled(!snappingEnabled || !snapsToFeatures) } .navigationTitle("Snap Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .onAppear { snappingEnabled = model.geometryEditor.snapSettings.isEnabled snapsToGeometryGuides = model.geometryEditor.snapSettings.snapsToGeometryGuides snapsToFeatures = model.geometryEditor.snapSettings.snapsToFeatures
// Creates an array from snap source layers or graphics overlays // with their name and source settings. snapSources = model.geometryEditor.snapSettings.sourceSettings.compactMap { sourceSettings in if let layer = sourceSettings.source as? FeatureLayer { return (layer.name, sourceSettings) } else if let graphicsOverlay = sourceSettings.source as? GraphicsOverlay { return (graphicsOverlay.id, sourceSettings) } else { return nil } }
// Initializes the enabled states from the snap sources. snapSourceEnabledStates = snapSources.map { _, sourceSettings in return sourceSettings.isEnabled } } } }}// 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 Combine
extension SnapGeometryEditsView { /// An object that acts as a view model for the geometry editor menu. @MainActor class GeometryEditorModel: ObservableObject { /// The geometry editor. let geometryEditor: GeometryEditor = { let geometryEditor = GeometryEditor() geometryEditor.snapSettings.isEnabled = true geometryEditor.snapSettings.snapsToGeometryGuides = true return geometryEditor }()
/// The geometry editor tool used to edit geometries for the most optimal /// snapping experience based on the device type. let adaptiveVertexTool: GeometryEditorTool = {#if targetEnvironment(macCatalyst) VertexTool()#else ReticleVertexTool()#endif }()
/// The graphics overlay used to save geometries to. let geometryOverlay: GraphicsOverlay = { let overlay = GraphicsOverlay(renderingMode: .dynamic) overlay.id = "Graphics Overlay" return overlay }()
/// A Boolean value indicating if the saved sketches can be cleared. @Published private(set) var canClearSavedSketches = false
/// A Boolean value indicating if the geometry editor has started. @Published private(set) var isStarted = false
/// A Boolean value indicating if the scale mode is uniform. @Published var isUniformScale = false { didSet { if let vertexTool = adaptiveVertexTool as? VertexTool { vertexTool.configuration.scaleMode = scaleMode } } }
/// The scale mode to be set on the geometry editor. private var scaleMode: GeometryEditorScaleMode { isUniformScale ? .uniform : .stretch }
init() { geometryEditor.tool = adaptiveVertexTool }
/// Saves the current geometry to the graphics overlay and stops editing. /// - Precondition: Geometry's sketch must be valid. func save() { precondition(geometryEditor.geometry?.sketchIsValid ?? false) let geometry = geometryEditor.geometry! let graphic = Graphic(geometry: geometry, symbol: symbol(for: geometry)) geometryOverlay.addGraphic(graphic) stop() canClearSavedSketches = true }
/// Clears all the saved sketches on the graphics overlay. func clearSavedSketches() { geometryOverlay.removeAllGraphics() canClearSavedSketches = false }
/// Stops editing with the geometry editor. func stop() { geometryEditor.stop() isStarted = false }
/// Returns the symbology for graphics saved to the graphics overlay. /// - Parameter geometry: The geometry of the graphic to be saved. /// - Returns: Either a marker or fill symbol depending on the type of provided geometry. private func symbol(for geometry: Geometry) -> Symbol { switch geometry { case is Point, is Multipoint: return SimpleMarkerSymbol(style: .circle, color: .blue, size: 20) case is Polyline: return SimpleLineSymbol(color: .blue, width: 2) case is ArcGIS.Polygon: return SimpleFillSymbol( color: .gray.withAlphaComponent(0.5), outline: SimpleLineSymbol(color: .blue, width: 2) ) default: fatalError("Unexpected geometry type") } }
/// Starts editing with the specified geometry type. /// - Parameters: /// - geometryType: The type of geometry to draw. func startEditing(withType geometryType: Geometry.Type) { geometryEditor.start(withType: geometryType) isStarted = true } }}// 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
extension SnapGeometryEditsView { /// A view that provides a menu for geometry editor functionality. struct GeometryEditorMenu: View { /// The model for the sample. @ObservedObject var model: GeometryEditorModel
/// The currently selected element. @State private var selectedElement: GeometryEditorElement?
/// The current geometry of the geometry editor. @State private var geometry: Geometry?
/// A Boolean value indicating if the geometry editor can perform an undo. private var canUndo: Bool { return model.geometryEditor.canUndo }
/// A Boolean value indicating if the geometry editor can perform a redo. private var canRedo: Bool { return model.geometryEditor.canRedo }
/// A Boolean value indicating if the geometry can be saved to a graphics overlay. private var canSave: Bool { return geometry?.sketchIsValid ?? false }
/// A Boolean value indicating if the geometry can be cleared from the geometry editor. private var canClearCurrentSketch: Bool { return geometry.map { !$0.isEmpty } ?? false }
/// A Boolean value indicating whether the selection can be deleted. /// /// In some instances deleting the selection may be invalid. /// One example would be the mid vertex of a line. private var deleteButtonIsDisabled: Bool { guard let selectedElement else { return true } return !selectedElement.canBeDeleted }
var body: some View { Menu { if !model.isStarted { // If the geometry editor is not started, show the main menu. mainMenuContent } else { // If the geometry editor is started, show the edit menu. editMenuContent .task { for await geometry in model.geometryEditor.$geometry { // Update geometry when there is an update. self.geometry = geometry } } .task { for await element in model.geometryEditor.$selectedElement { // Update selected element when there is an update. selectedElement = element } } } } label: { Label("Geometry Editor", systemImage: "pencil.tip.crop.circle") } }
/// The content of the main menu. private var mainMenuContent: some View { VStack { Button("New Point", systemImage: "smallcircle.filled.circle") { model.startEditing(withType: Point.self) }
Button("New Line", systemImage: "line.diagonal") { model.startEditing(withType: Polyline.self) }
Button("New Area", systemImage: "skew") { model.startEditing(withType: Polygon.self) }
Button("New Multipoint", systemImage: "hand.point.up.braille") { model.startEditing(withType: Multipoint.self) }
Divider()
Button("Clear Saved Sketches", systemImage: "trash", role: .destructive) { model.clearSavedSketches() } .disabled(!model.canClearSavedSketches) } }
/// The content of the editing menu. private var editMenuContent: some View { VStack { Button("Undo", systemImage: "arrow.uturn.backward") { model.geometryEditor.undo() } .disabled(!canUndo)
Button("Redo", systemImage: "arrow.uturn.forward") { model.geometryEditor.redo() } .disabled(!canRedo)
Button("Delete Selected Element", systemImage: "xmark.square.fill") { model.geometryEditor.deleteSelectedElement() } .disabled(deleteButtonIsDisabled)
if model.adaptiveVertexTool is VertexTool { Toggle("Uniform Scale", isOn: $model.isUniformScale) }
Button("Clear Current Sketch", systemImage: "trash", role: .destructive) { model.geometryEditor.clearGeometry() } .disabled(!canClearCurrentSketch)
Divider()
Button("Save Sketch", systemImage: "square.and.arrow.down") { model.save() } .disabled(!canSave)
Button("Cancel Sketch", systemImage: "xmark") { model.stop() } } } }}