Synchronize offline edits with a feature service.

Use case
A survey worker who works in an area without an internet connection could take a geodatabase of survey features offline at their office, make edits and add new features to the offline geodatabase in the field, and sync the updates with the online feature service after returning to the office.
How to use the sample
Pan and zoom to position the red rectangle around the area you want to take offline. Tap “Generate Geodatabase” to take the area offline. When complete, the map will update to only show the offline area. To edit features, tap on a feature to select it and tap again anywhere else on the map to move the selected feature to the tapped location. To sync the edits with the feature service, tap the “Sync Geodatabase” button.
How it works
- Create a
GeodatabaseSyncTaskfrom a URL to a feature service. - Create the default
GenerateGeodatabaseParametersusingGeodatabaseSyncTask.makeDefaultGenerateGeodatabaseParameters(extent:), passing in anEnvelopeextent. - Create a
GenerateGeodatabaseJobusingGeodatabaseSyncTask.makeGenerateGeodatabaseJob(parameters:downloadFileURL:), passing in the parameters and a path to where the geodatabase should be downloaded locally. - Start the job and get the result
Geodatabase. - To enable editing, load the geodatabase and get its feature tables. Create feature layers from the feature tables and add them to the map’s operational layers collection.
- Create the default
SyncGeodatabaseParametersusingGeodatabaseSyncTask.makeDefaultSyncGeodatabaseParameters(geodatabase:syncDirection:). - Create a
SyncGeodatabaseJobfromGeodatabaseSyncTaskusingmakeSyncGeodatabaseJob(parameters:geodatabase:)passing in the parameters and geodatabase as arguments. - Start the sync job to synchronize the edits.
Relevant API
- FeatureLayer
- FeatureTable
- GenerateGeodatabaseJob
- GenerateGeodatabaseParameters
- GeodatabaseSyncTask
- SyncGeodatabaseJob
- SyncGeodatabaseParameters
- SyncLayerOption
Offline data
This sample uses a San Francisco offline basemap tile package.
About the data
The basemap uses an offline tile package of San Francisco. The online feature service has features with wildfire information.
Tags
feature service, geodatabase, offline, synchronize
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 EditAndSyncFeaturesWithFeatureServiceView: 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 text describing the status of the sample. @State private var statusText = ""
/// The error shown in the error alert. @State private var error: (any Error)?
var body: some View { GeometryReader { geometryProxy in MapViewReader { mapViewProxy in MapView(map: model.map) .interactionModes( // Disables the map when a geodatabase has been generated. model.geodatabase == nil ? [.all] : [] ) .onSingleTapGesture { screenPoint, mapPoint in guard model.geodatabase != nil else { return }
selectedAction = model.selectedFeature == nil ? .selectFeature(screenPoint: screenPoint) : .moveSelectedFeature(mapPoint: mapPoint) } .task(id: selectedAction) { // Performs the selected action. guard let action = selectedAction else { return }
do { switch action { case .setUpMap: statusText = "Loading feature layers…" try await model.setUpMap() statusText = action.completionMessage case .generateGeodatabase: // Creates an envelope from the area of interest. let viewRect = geometryProxy.frame(in: .local).inset( by: UIEdgeInsets( top: 20, left: geometryProxy.safeAreaInsets.leading + 20, bottom: 44, right: -geometryProxy.safeAreaInsets.trailing + 20 ) ) guard let extent = mapViewProxy.envelope( fromViewRect: viewRect ) else { return }
// Generates the geodatabase using the envelope. try await model.generateGeodatabase(extent: extent) statusText = action.completionMessage case .selectFeature(let screenPoint): // Identifies and selects a feature at the tapped screen point. let identifyLayerResults = try await mapViewProxy.identifyLayers( screenPoint: screenPoint, tolerance: 22, maximumResultsPerLayer: 1 )
model.selectFeature(identifyLayerResults: identifyLayerResults) if model.selectedFeature != nil { statusText = action.completionMessage } case .moveSelectedFeature(mapPoint: let mapPoint): try await model.moveSelectedFeature(point: mapPoint) statusText = action.completionMessage case .sync: try await model.syncGeodatabase() statusText = action.completionMessage case .cancelJob: await model.cancelJob() statusText = action.completionMessage case .reset: await model.reset() await mapViewProxy.setViewpoint(model.map.initialViewpoint!) selectedAction = .setUpMap return } } catch { self.error = error }
selectedAction = nil } .errorAlert(presentingError: $error) } } .overlay(alignment: .top) { VStack { Text(statusText) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .center) .padding(8) .background(.regularMaterial, ignoresSafeAreaEdges: .horizontal)
// The red rectangle representing the extent of data to include in the geodatabase. Rectangle() .stroke(.red, lineWidth: 2) .padding(EdgeInsets(top: 20, leading: 20, bottom: 44, trailing: 20)) .opacity(model.geodatabase == nil ? 1 : 0) } } .toolbar { ToolbarItemGroup(placement: .bottomBar) { Button("Reset") { selectedAction = .reset } .disabled(model.geodatabase == nil)
if model.geodatabase == nil { Button("Generate Geodatabase") { selectedAction = .generateGeodatabase } } else { Button("Sync Geodatabase") { selectedAction = .sync } .disabled(!(model.geodatabase?.hasLocalEdits ?? false)) } } } .disabled(selectedAction != nil) .overlay(alignment: .center) { // Shows a progress view when there is a job currently running. if let progress = model.currentJob?.progress { VStack { Text(selectedAction == .generateGeodatabase ? "Creating geodatabase…" : "Syncing geodatabase…" ) .padding(.bottom)
ProgressView(progress) .frame(maxWidth: 180)
Button("Cancel") { selectedAction = .cancelJob } .disabled(selectedAction == .cancelJob) } .padding() .background(.ultraThickMaterial) .clipShape(.rect(cornerRadius: 10)) .shadow(radius: 50) } } .onDisappear { // Cancels any running jobs when the sample is exited. Task { await model.cancelJob() } } }}
/// An asynchronous action associated with the sample.private enum AsyncAction: Equatable { /// Sets up the map for the sample. case setUpMap /// Generates a geodatabase from the current area of interest. case generateGeodatabase /// Identifies and selects a feature identified at a given screen point. case selectFeature(screenPoint: CGPoint) /// Moves the selected feature to a given map point. case moveSelectedFeature(mapPoint: Point) /// Synchronizes the geodatabase and the feature service. case sync /// Cancels the current job. case cancelJob /// Resets the sample. case reset
/// The message to display when the action successfully completes. var completionMessage: String { switch self { case .setUpMap: "Tap the generate button to take the area offline." case .generateGeodatabase: "Tap on a feature to edit." case .selectFeature: "Tap on the map to move the feature." case .moveSelectedFeature: "Tap the sync button to sync the edits." case .sync: "Geodatabase sync successful." case .cancelJob: "Job canceled." default: "Unknown" } }}// 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 EditAndSyncFeaturesWithFeatureServiceView { /// The view model for the sample. @MainActor final class Model: ObservableObject { // MARK: Properties
/// The generated local geodatabase to edit. @Published private(set) var geodatabase: Geodatabase?
/// The job being performed. @Published private(set) var currentJob: Job?
/// A map with a San Fransisco streets basemap. let map: Map = { let tiledLayer = ArcGISTiledLayer(url: .sanFranciscoStreetsTilePackage) let basemap = Basemap(baseLayer: tiledLayer) return Map(basemap: basemap) }()
/// The feature currently selected by the user. private(set) var selectedFeature: Feature?
/// The task for generating and synchronizing the geodatabase with the feature service. private let geodatabaseSyncTask = GeodatabaseSyncTask(url: .wildfireSyncFeatureServer)
/// A URL to the temporary file containing the geodatabase. private let temporaryGeodatabaseURL = FileManager .createTemporaryDirectory() .appending(component: "WildfireSync.geodatabase")
deinit { // Removes the temporary geodatabase file and its directory. let temporaryDirectoryURL = temporaryGeodatabaseURL.deletingLastPathComponent() try? FileManager.default.removeItem(at: temporaryDirectoryURL) }
// MARK: Methods
/// Adds feature layers from the feature service to the map. func setUpMap() async throws { try await geodatabaseSyncTask.load()
// Creates feature tables from the feature service. guard let layerInfos = geodatabaseSyncTask.featureServiceInfo?.layerInfos else { return } let featureTableURLs = layerInfos.compactMap { layerInfo -> URL? in guard let id = layerInfo.id else { return nil } return .wildfireSyncFeatureServer.appendingPathComponent("\(id)") } let featureTables = featureTableURLs.map(ServiceFeatureTable.init(url:))
await addPointFeatureLayers(featureTables: featureTables) }
/// Generates a geodatabase from the feature service. /// - Parameter extent: The extent of the data to be included in the generated geodatabase. func generateGeodatabase(extent: Envelope) async throws { // Creates the parameters for generating the geodatabase. let parameters = try await geodatabaseSyncTask.makeDefaultGenerateGeodatabaseParameters( extent: extent ) parameters.returnsAttachments = false
// Creates the job to generate the geodatabase. let generateGeodatabaseJob = geodatabaseSyncTask.makeGenerateGeodatabaseJob( parameters: parameters, downloadFileURL: temporaryGeodatabaseURL )
// Generates the geodatabase. try await runJob(generateGeodatabaseJob) { geodatabase = try await generateGeodatabaseJob.output } try await geodatabase?.load()
// Adds the geodatabase's feature tables to the map. map.removeAllOperationalLayers() await addPointFeatureLayers(featureTables: geodatabase!.featureTables) }
/// Synchronizes the geodatabase and feature service. func syncGeodatabase() async throws { guard let geodatabase else { return }
// Creates the default parameters for synchronizing the geodatabase. let syncParameters = try await geodatabaseSyncTask.makeDefaultSyncGeodatabaseParameters( geodatabase: geodatabase )
// Creates the sync job using the parameters. let syncGeodatabaseJob = geodatabaseSyncTask.makeSyncGeodatabaseJob( parameters: syncParameters, geodatabase: geodatabase )
// Synchronizes the geodatabase with the feature service. try await runJob(syncGeodatabaseJob) { _ = try await syncGeodatabaseJob.output } }
/// Selects the first feature in a list of identify layer results. /// - Parameter identifyLayerResults: The results containing the feature. func selectFeature(identifyLayerResults: [IdentifyLayerResult]) { guard let feature = identifyLayerResults.first?.geoElements.first as? Feature, let featureLayer = feature.table?.layer as? FeatureLayer else { return }
featureLayer.selectFeature(feature) selectedFeature = feature }
/// Moves the selected feature to a given map point. /// - Parameter point: The point on the map. func moveSelectedFeature(point: Point) async throws { guard let selectedFeature else { return }
selectedFeature.geometry = point try await selectedFeature.table?.update(selectedFeature)
clearSelection() }
/// Resets the sample. func reset() async { await cancelJob() geodatabase = nil clearSelection() map.removeAllOperationalLayers() }
/// Cancels the current job. func cancelJob() async { await currentJob?.cancel() currentJob = nil }
/// Runs a given job. /// - Parameters: /// - job: The job to run. /// - onStartAction: The action to run after the job is started. private func runJob(_ job: Job, onStartAction: () async throws -> Void) async throws { currentJob = job defer { currentJob = nil }
job.start() try await onStartAction() }
/// Adds to feature layers created from given feature tables with a `Point` geometry type to the map. /// - Parameter featureTables: The feature tables. private func addPointFeatureLayers(featureTables: [FeatureTable]) async { await featureTables.load()
// Creates feature layers from the tables that have a `Point` geometry type. let pointFeatureLayers = featureTables .filter { $0.geometryType == Point.self } .map(FeatureLayer.init(featureTable:))
map.addOperationalLayers(pointFeatureLayers) }
/// Clears the selected feature. private func clearSelection() { let featureLayer = selectedFeature?.table?.layer as? FeatureLayer featureLayer?.clearSelection()
selectedFeature = nil } }}
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 streets tile package of San Francisco, CA, USA. static var sanFranciscoStreetsTilePackage: URL { Bundle.main.url(forResource: "SanFrancisco", withExtension: "tpkx")! } /// The URL to the Wildfire Sync feature server. static var wildfireSyncFeatureServer: URL { URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Sync/WildfireSync/FeatureServer")! }}