Take a web map offline with additional options for each layer.

Use case
When taking a web map offline, you may adjust the data (such as layers or tiles) that is downloaded by using custom parameter overrides. This can be used to reduce the extent of the map or the download size of the offline map. It can also be used to highlight specific data by removing irrelevant data. Additionally, this workflow allows you to take features offline that don’t have a geometry - for example, features whose attributes have been populated in the office, but still need a site survey for their geometry.
How to use the sample
Modify the overrides parameters:
- Use the sliders to adjust the the minimum and maximum scale levels and buffer radius to be taken offline for the streets basemap.
- Toggle the switches for the feature operational layers you want to include in the offline map.
- Use the min hydrant flow rate slider to only download features with a flow rate higher than this value.
- Turn on the “Water Pipes” switch if you want to crop the water pipe features to the extent of the map.
After you have set up the overrides to your liking, tap “Start” to start the download. A progress bar will display. Tap “Cancel” if you want to stop the download. When the download is complete, the view will display the offline map. Pan around to see that it is cropped to the download area’s extent.
How it works
- Load a web map from a
PortalItem. Authenticate with the portal if required. - Create an
OfflineMapTaskwith the map. - Generate default task parameters using the extent area you want to download with the
OfflineMapTask.makeDefaultGenerateOfflineMapParameters(withAreaOfInterest:)method. - Generate additional “override” parameters using the default parameters with the
OfflineMapTask.makeGenerateOfflineMapParameterOverrides(parameters:)method. - For the basemap:
- Get the parameters
OfflineMapParametersKeyfor the basemap layer. - Get the
ExportTileCacheParametersfor the basemap layer fromGenerateOfflineMapParameterOverrides exportTileCacheParameters[key]with the key above. - Set the level IDs you want to download by setting the
levelIDsproperty ofExportTileCacheParameters. - To buffer the extent, set a buffered geometry to the
areaOfInterestproperty ofExportTileCacheParameters, where the buffered geometry can be calculated with theGeometryEngine.
- Get the parameters
- To remove operational layers from the download:
- Create an
OfflineMapParametersKeywith the operational layer. - Use the key to obtain the relevant
GenerateGeodatabaseParametersfrom thegenerateGeodatabaseParametersproperty ofGenerateOfflineMapParameterOverrides. - Remove the
GenerateLayerOptionfrom thelayerOptionswhere the option’s ID matches theserviceLayerID.
- Create an
- To filter the features downloaded in an operational layer:
- Get the layer options for the operational layer using the directions in step 6.
- Loop through the layer options. If the option layerID matches the layer’s ID, set the filter’s
whereClauseproperty.
- To not crop a layer’s features to the extent of the offline map (default is true):
- Set
useGeometryproperty ofGenerateLayerOptionto false.
- Set
- Create a
GenerateOfflineMapJobwithOfflineMapTask.makeGenerateOfflineMapJob(parameters:downloadDirectory:overrides:). Start the job withGenerateOfflineMapJob.start(). - When
GenerateOfflineMapJog.outputis done, get a reference to the offline map withoutput.offlineMap.
Relevant API
- ExportTileCacheParameters
- GenerateGeodatabaseParameters
- GenerateLayerOption
- GenerateOfflineMapJob
- GenerateOfflineMapParameterOverrides
- GenerateOfflineMapParameters
- GenerateOfflineMapResult
- OfflineMapParametersKey
- OfflineMapTask
Additional information
For applications where you just need to take all layers offline, use the standard workflow (using only GenerateOfflineMapParameters). For a simple example of how you take a map offline, please consult the “Generate offline map” sample.
Tags
adjust, download, extent, filter, LOD, offline, override, parameters, reduce, scale range, setting
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 GenerateOfflineMapWithCustomParametersView: View { /// A Boolean value indicating whether the job is generating an offline map. @State private var isGeneratingOfflineMap = false
/// A Boolean value indicating whether the job is cancelling. @State private var isCancellingJob = false
/// The error shown in the error alert. @State private var error: (any Error)?
/// The view model for this sample. @StateObject private var model = Model()
/// A Boolean value indicating whether to set the custom parameters. @State private var isShowingSetCustomParameters = false
var body: some View { GeometryReader { geometry in MapViewReader { mapView in MapView(map: model.offlineMap ?? model.onlineMap) .interactionModes(isGeneratingOfflineMap ? [] : [.pan, .zoom]) .errorAlert(presentingError: $error) .task { do { try await model.initializeOfflineMapTask() } catch { self.error = error } } .onDisappear { Task { await model.cancelJob() } } .overlay { Rectangle() .stroke(.red, lineWidth: 2) .padding(EdgeInsets(top: 20, leading: 20, bottom: 44, trailing: 20)) .opacity(model.offlineMap == nil ? 1 : 0) } .overlay { if isGeneratingOfflineMap, let progress = model.generateOfflineMapJob?.progress { VStack(spacing: 16) { ProgressView(progress) .progressViewStyle(.linear) .frame(maxWidth: 200)
Button("Cancel") { isCancellingJob = true } .disabled(isCancellingJob) .task(id: isCancellingJob) { guard isCancellingJob else { return } // Cancels the job. await model.cancelJob() // Sets cancelling the job and generating an // offline map to false. isCancellingJob = false isGeneratingOfflineMap = false } } .padding() .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 15)) .shadow(radius: 3) } } .overlay(alignment: .top) { Text("Offline map generated.") .frame(maxWidth: .infinity, alignment: .center) .padding(8) .background(.thinMaterial, ignoresSafeAreaEdges: .horizontal) .opacity(model.offlineMap != nil ? 1 : 0) } .toolbar { ToolbarItem(placement: .bottomBar) { Button("Generate Offline Map") { // Creates a rectangle from the area of interest. let viewRect = geometry.frame(in: .local).inset( by: UIEdgeInsets( top: 20, left: geometry.safeAreaInsets.leading + 20, bottom: 44, right: -geometry.safeAreaInsets.trailing + 20 ) ) // Creates an envelope from the rectangle. model.extent = mapView.envelope(fromViewRect: viewRect) isShowingSetCustomParameters.toggle() } .disabled(model.isGenerateDisabled || isGeneratingOfflineMap) .popover(isPresented: $isShowingSetCustomParameters) { CustomParameters( model: model, isGeneratingOfflineMap: $isGeneratingOfflineMap ) .frame(idealWidth: 350, idealHeight: 750) } .task(id: isGeneratingOfflineMap) { guard isGeneratingOfflineMap else { return }
do { // Generates an offline map. try await model.generateOfflineMap() } catch { self.error = error }
// Sets generating an offline map to false. isGeneratingOfflineMap = false } } } } } }}
#Preview { NavigationStack { GenerateOfflineMapWithCustomParametersView() }}// 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 GenerateOfflineMapWithCustomParametersView { /// The model used to store the geo model and other expensive objects /// used in this view. @MainActor class Model: ObservableObject { /// The offline map that is generated. @Published private(set) var offlineMap: Map!
/// A Boolean value indicating whether the generate button is disabled. @Published private(set) var isGenerateDisabled = true
/// The generate offline map job. @Published private(set) var generateOfflineMapJob: GenerateOfflineMapJob!
/// The offline map task. private var offlineMapTask: OfflineMapTask!
/// A URL to a temporary directory where the offline map files are stored. private let temporaryDirectory = createTemporaryDirectory()
/// The area of interest. var extent: Envelope?
/// The parameters used to take the map offline. private var offlineMapParameters: GenerateOfflineMapParameters?
/// The parameter overrides used to take the map offline. private var offlineMapParameterOverrides: GenerateOfflineMapParameterOverrides?
/// The online map that is loaded from a portal item. let onlineMap: Map = { // A portal item displaying the Naperville, IL water network. let napervillePortalItem = PortalItem( portal: .arcGISOnline(connection: .anonymous), id: .napervilleWaterNetwork )
// Creates map with portal item. let map = Map(item: napervillePortalItem)
return map }()
deinit { // Removes the temporary directory. try? FileManager.default.removeItem(at: temporaryDirectory) }
/// Initializes the offline map task. func initializeOfflineMapTask() async throws { // Waits for the online map to load. try await onlineMap.load() offlineMapTask = OfflineMapTask(onlineMap: onlineMap) isGenerateDisabled = false }
/// Creates the generate offline map parameters. /// - Parameter areaOfInterest: The area of interest to create the parameters for. /// - Returns: A `GenerateOfflineMapParameters` if there are no errors. private func makeGenerateOfflineMapParameters( areaOfInterest: Envelope ) async throws -> GenerateOfflineMapParameters { // Returns the default parameters for the offline map task. return try await offlineMapTask.makeDefaultGenerateOfflineMapParameters( areaOfInterest: areaOfInterest ) }
/// Creates the generate offline map parameter overrides. /// - Parameter parameters: The generate offline map parameters. /// - Returns: A `GenerateOfflineMapParameterOverrides` if there are no errors. private func makeGenerateParameterOverrides( parameters: GenerateOfflineMapParameters ) async throws -> GenerateOfflineMapParameterOverrides { // Returns the overrides. return try await offlineMapTask.makeGenerateOfflineMapParameterOverrides( parameters: parameters ) }
/// Sets up the model by getting the generate offline map parameters and parameter /// overrides. func setUpParametersAndOverrides() async throws { guard let extent else { return } offlineMapParameters = try await makeGenerateOfflineMapParameters(areaOfInterest: extent) if let offlineMapParameters { offlineMapParameterOverrides = try await makeGenerateParameterOverrides( parameters: offlineMapParameters ) } }
/// Generates the offline map. func generateOfflineMap() async throws { // Disables the generate offline map button. isGenerateDisabled = true
guard let parameters = offlineMapParameters, let overrides = offlineMapParameterOverrides, let extent = parameters.areaOfInterest?.extent else { return }
// Creates the generate offline map job based on the parameters and overrides. generateOfflineMapJob = offlineMapTask.makeGenerateOfflineMapJob( parameters: parameters, downloadDirectory: temporaryDirectory, overrides: overrides )
// Starts the job. generateOfflineMapJob.start()
defer { generateOfflineMapJob = nil isGenerateDisabled = offlineMap != nil }
// Awaits the output of the job. let output = try await generateOfflineMapJob.output // Sets the offline map to the output's offline map. offlineMap = output.offlineMap // Sets the initial viewpoint of the offline map. offlineMap.initialViewpoint = Viewpoint(boundingGeometry: extent.expanded(by: 0.8)) }
/// Cancels the generate offline map job. func cancelJob() async { await generateOfflineMapJob?.cancel() generateOfflineMapJob = nil }
/// Creates a temporary directory. private static func createTemporaryDirectory() -> URL { try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: FileManager.default.temporaryDirectory, create: true ) }
// MARK: - Basemap helpers private func getExportTileCacheParametersForBasemapLayer() -> ExportTileCacheParameters? { if let basemapLayer = onlineMap.basemap?.baseLayers.first as? Layer, let key = OfflineMapParametersKey(layer: basemapLayer), let offlineMapParameterOverrides { return offlineMapParameterOverrides.exportTileCacheParameters[key] } else { return nil } }
/// Sets the scale level range so that only the levels between the min and max inclusive, /// are downloaded. Note that lower values are zoomed further out, /// i.e. 0 has the least detail, but one tile covers the entire Earth. /// Parameters: /// - minScaleLevel: The minimum scale level to download for the basemap. /// - maxScaleLevel: The maximum scale level to download for the basemap. func restrictBasemapScaleLevelRangeTo(minScaleLevel: Double, maxScaleLevel: Double) { guard let tileCacheParameters = getExportTileCacheParametersForBasemapLayer(), // Ensure that the lower bound of the range is not greater than the upper bound. minScaleLevel <= maxScaleLevel else { return }
let scaleLevelIDs = Array(Int(minScaleLevel)...Int(maxScaleLevel)) // Override the default level IDs. tileCacheParameters.removeAllLevelIDs() tileCacheParameters.addLevelIDs(scaleLevelIDs) }
/// Adds extra padding to the extent envelope to fetch a larger area, in meters. /// - Parameter basemapExtentBufferDistance: The distance to extend the area of interest. func bufferBasemapAreaOfInterest(by basemapExtentBufferDistance: Double) { guard let tileCacheParameters = getExportTileCacheParametersForBasemapLayer(), // The area initially specified for download when the default parameters object // was created. let areaOfInterest = tileCacheParameters.areaOfInterest else { return }
// Assuming the distance is positive, expand the downloaded area by the given amount. let bufferedArea = GeometryEngine.buffer( around: areaOfInterest, distance: basemapExtentBufferDistance ) // Override the default area of interest. tileCacheParameters.areaOfInterest = bufferedArea }
// MARK: - Layer helpers /// Retrieves the operational layer in the map with the given name, if it exists. private func operationalMapLayer(named name: String) -> Layer? { return onlineMap.operationalLayers.first { $0.name == name } }
/// The service ID retrieved from the layer's `ArcGISFeatureLayerInfo`, if it is a feature layer. /// Needed for use in conjunction with the `layerID` of `GenerateLayerOption`. /// This is not the same as the `layerID` property of `Layer`. private func serviceLayerID(for layer: Layer) -> Int? { if let featureLayer = layer as? FeatureLayer, let featureTable = featureLayer.featureTable as? ArcGISFeatureTable, let featureLayerInfo = featureTable.layerInfo { return featureLayerInfo.serviceLayerID } return nil }
/// Filters the hydrants that will be shown in the map by flow rate. Only hydrants the have /// a higher flow rate than the minimum flow rate will be shown. /// - Parameter minHydrantFlowRate: The minimum flow rate for hydrants shown on the map. func filterHydrantFlowRate(to minHydrantFlowRate: Double) { for option in getGenerateGeodatabaseParametersLayerOptions(forLayerNamed: "Hydrant") { // Set the SQL where clause for this layer's options, filtering features based on // the FLOW field values. option.whereClause = "FLOW >= \(minHydrantFlowRate)" } }
/// Excludes the layer with the specified name from the offline map. /// - Parameter name: The name of the layer to be excluded. func excludeLayer(named name: String) { if let layer = operationalMapLayer(named: name), let serviceLayerID = serviceLayerID(for: layer), let parameters = getGenerateGeodatabaseParameters(forLayer: layer), let layerOption = parameters.layerOptions.first(where: { $0.layerID == serviceLayerID }) { // Remove the options for this layer from the parameters. parameters.removeLayerOption(layerOption) } }
/// Sets the layer options to crop the water pipes according the specified Boolean value. /// - Parameter cropWaterPipesToExtent: A Boolean value indicating if the water pipes should /// be cropped to the area of interest. func evaluatePipeLayersExtentCropping(for cropWaterPipesToExtent: Bool) { // If the switch is off. if !cropWaterPipesToExtent { // Two layers contain pipes, so loop through both. for pipeLayerName in ["Main", "Lateral"] { for option in getGenerateGeodatabaseParametersLayerOptions( forLayerNamed: pipeLayerName ) { // Turn off the geometry extent evaluation so that the entire layer is downloaded. option.usesGeometry = false } } } }
// MARK: - GenerateGeodatabaseParameters helpers /// Retrieves this layer's parameters from the `generateGeodatabaseParameters` dictionary. private func getGenerateGeodatabaseParameters( forLayer layer: Layer ) -> GenerateGeodatabaseParameters? { // The parameters key for this layer. if let key = OfflineMapParametersKey(layer: layer), let offlineMapParameterOverrides { return offlineMapParameterOverrides.generateGeodatabaseParameters[key] } else { return nil } }
/// Retrieves the layer's options from the layer's parameter in the /// `generateGeodatabaseParameters` dictionary. private func getGenerateGeodatabaseParametersLayerOptions( forLayerNamed name: String ) -> [GenerateLayerOption] { if let layer = operationalMapLayer(named: name), let serviceLayerID = serviceLayerID(for: layer), let parameters = getGenerateGeodatabaseParameters(forLayer: layer) { // The layers options may correspond to multiple layers, so filter based on the ID // of the target layer. return parameters.layerOptions.filter { $0.layerID == serviceLayerID } } return [] } }}
private extension Envelope { /// Expands the envelope by a given factor. /// - Parameter factor: The amount to expand the envelope by. /// - Returns: An envelope expanded by the specified factor. func expanded(by factor: Double) -> Envelope { let builder = EnvelopeBuilder(envelope: self) builder.expand(by: factor) return builder.toGeometry() }}
private extension PortalItem.ID { /// The portal item ID of the Naperville water network web map to be displayed on the map. static var napervilleWaterNetwork: Self { Self("acc027394bc84c2fb04d1ed317aac674")! }}// 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 SwiftUI
extension GenerateOfflineMapWithCustomParametersView { struct CustomParameters: View { /// The action to dismiss the sheet. @Environment(\.dismiss) private var dismiss
/// The view model for the download offline map area view. @ObservedObject var model: Model
/// A Boolean value indicating whether the job is generating an offline map. @Binding var isGeneratingOfflineMap: Bool
/// The error shown in the error alert. @State private var error: (any Error)?
/// The min scale level for the output. Note that lower values are zoomed further out, /// i.e. 0 has the least detail, but one tile covers the entire Earth. @State private var minScaleLevel = 0.0
/// The max scale level for the output. Note that higher values are zoomed further in, /// i.e. 23 has the most detail, but each tile covers a tiny area. @State private var maxScaleLevel = 23.0
/// The range for scale level values. private let scaleLevelRange = 0.0...23.0
/// The extra padding added to the extent envelope to fetch a larger area, in meters. @State private var basemapExtentBufferDistance = 0.0
/// The range for buffering the basemap extent. private let basemapExtentBufferRange = 0.0...100.0
/// A Boolean value indicating if the system valves layer should be included in /// the download. @State private var includeSystemValves = true
/// A Boolean value indicating if the service connections layer should be included /// in the download. @State private var includeServiceConnections = true
/// The minimum flow rate by which to filter features in the Hydrants layer, /// in gallons per minute. @State private var minHydrantFlowRate = 0.0
/// The hydrant flow rate range. private let hydrantFlowRateRange = 0.0...1500.0
/// A Boolean value indicating if the pipe layers should be restricted to /// the extent frame. @State private var shouldCropWaterPipesToExtent = true
var body: some View { NavigationStack { Form { Section("Adjust Basemap") { VStack { LabeledContent( "Min Scale Level", value: minScaleLevel, format: .number.precision(.fractionLength(0)) ) Slider(value: $minScaleLevel, in: scaleLevelRange, step: 1) }
VStack { LabeledContent( "Max Scale Level", value: maxScaleLevel, format: .number.precision(.fractionLength(0)) ) Slider(value: $maxScaleLevel, in: scaleLevelRange, step: 1.0) }
VStack { LabeledContent( "Extent Buffer Distance", value: basemapExtentBufferDistance, format: .number.precision(.fractionLength(0)) ) Slider(value: $basemapExtentBufferDistance, in: basemapExtentBufferRange) } }
Section("Include Layers") { Toggle("System Valves", isOn: $includeSystemValves) Toggle("Service Connections", isOn: $includeServiceConnections) }
Section("Filter Feature Layer") { VStack { LabeledContent( "Min Hydrant Flow Rate", value: minHydrantFlowRate, format: .number.precision(.fractionLength(0)) ) Slider(value: $minHydrantFlowRate, in: hydrantFlowRateRange, step: 1.0) } }
Section("Crop Layer to Extent") { Toggle("Water Pipes", isOn: $shouldCropWaterPipesToExtent) } } .navigationTitle("Generate Offline Map") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Start") { dismiss() setParameterOverridesFromUI() isGeneratingOfflineMap = true } } ToolbarItem(placement: .cancellationAction) { Button("Cancel", role: .cancel) { dismiss() } } } .errorAlert(presentingError: $error) .task { do { // Gets the GenerateOfflineMapParameters and the // GenerateOfflineMapParameterOverrides. try await model.setUpParametersAndOverrides() } catch { self.error = error } } } }
/// Updates the `GenerateOfflineMapParameterOverrides` object with the user-set values. private func setParameterOverridesFromUI() { model.restrictBasemapScaleLevelRangeTo(minScaleLevel: minScaleLevel, maxScaleLevel: maxScaleLevel) model.bufferBasemapAreaOfInterest(by: basemapExtentBufferDistance) if !includeSystemValves { model.excludeLayer(named: "System Valve") } if !includeServiceConnections { model.excludeLayer(named: "Service Connection") } model.filterHydrantFlowRate(to: minHydrantFlowRate) model.evaluatePipeLayersExtentCropping(for: shouldCropWaterPipesToExtent) } }}