Take a map offline using a preplanned map area.

Use case
Generating offline maps on demand for a specific area can be time consuming for users and a processing load on the server. If areas of interest are known ahead of time, a web map author can pre-create packages for these areas. This way, the generation only needs to happen once, making the workflow more efficient for users and servers.
An archeology team could define preplanned map areas for dig sites which can be taken offline for field use. To see the difference, compare this sample to the “Generate offline map” sample.
How to use the sample
Select a preplanned map area by tapping the “Select Map” button and selecting one of the showing available areas. Tapping a cell initiates a download, and shows download progress in the interim. Once downloaded, the preplanned map is displayed in the map view. If a preplanned map is reselected later, the locally cached data is loaded immediately.
How it works
- Create a
Mapinstance from aPortalItem. - Create an
OfflineMapTaskinstance from the portal item. - Load the offline map task’s
PreplannedMapAreas. - To download a selected map area, create the default parameters with the offline map task’s
makeDefaultDownloadPreplannedOfflineMapParameters(preplannedMapArea:)method, specifying the selected map area. - Set the parameter’s update mode.
- Create a
DownloadPreplannedOfflineMapJobinstance with the offline map task’smakeDownloadPreplannedOfflineMapJob(parameters:downloadDirectory:)method, passing in the parameters and a download directory as arguments. - Start the job and await its output.
- Get the offline
Mapfrom the output when the job successfully finishes. - Update the map with the offline map.
Relevant API
- DownloadPreplannedOfflineMapResult
- OfflineMapTask
- OfflineMapTask.makeDefaultDownloadPreplannedOfflineMapParameters(preplannedMapArea:)
- OfflineMapTask.makeDownloadPreplannedOfflineMapJob(parameters:downloadDirectory:)
- PreplannedMapArea
About the data
The Naperville water network map is based on ArcGIS Solutions for Stormwater and provides a realistic depiction of a theoretical stormwater network.
Additional information
DownloadPreplannedOfflineMapParameters.updateMode can be used to set the way the preplanned map area receives updates in several ways:
noUpdates: No updates will be performed. This mode is intended for when a static snapshot of the data is required, and it does not create a replica. This is the mode used for this sample.syncWithFeatureServices: Changes, including local edits, will be synced directly with the underlying feature services. This is the default update mode.downloadScheduledUpdates: Scheduled, read-only updates will be downloaded from the online map area and applied to the local mobile geodatabases.downloadScheduledUpdatesAndUploadNewFeatures: An advanced workflow where scheduled, read-only updates will be downloaded from the online map area and applied to the local mobile geodatabases, and new features added on the client side can also be synchronized with the server.
For more information about offline workflows, see Offline maps, scenes, and data on the Esri Developer website.
Tags
map area, offline, pre-planned, preplanned
Sample code
// Copyright 2022 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 DownloadPreplannedMapAreaView: View { /// A Boolean value indicating whether to select a map. @State private var isShowingSelectMapView = false
/// A Boolean value indicating whether to show delete alert. @State private var isShowingDeleteAlert = false
/// The view model for this sample. @StateObject private var model = Model()
var body: some View { MapView(map: model.currentMap, viewpoint: model.currentMap.initialViewpoint?.expanded()) .overlay(alignment: .top) { mapNameOverlay } .toolbar { ToolbarItemGroup(placement: .bottomBar) { Spacer()
Button("Select Map") { isShowingSelectMapView.toggle() } .popover(isPresented: $isShowingSelectMapView) { MapPicker(model: model) .presentationDetents([.fraction(0.5)]) .frame(idealWidth: 320, idealHeight: 380) }
Spacer()
Button { isShowingDeleteAlert = true } label: { Image(systemName: "trash") } .disabled(!model.canRemoveDownloadedMaps) .alert("Delete All Offline Areas", isPresented: $isShowingDeleteAlert) { Button("Delete", role: .destructive) { model.removeDownloadedMaps() } } message: { Text("Are you sure you want to delete all downloaded preplanned map areas?") } } } .task { // Makes the offline map models when the view is first shown. await model.makeOfflineMapModels() } }
private var mapNameOverlay: some View { Text(model.currentMap.item?.title ?? "Unknown Map") .font(.footnote) .frame(maxWidth: .infinity) .padding(8) .background(.thinMaterial, ignoresSafeAreaEdges: .horizontal) }}
private extension Viewpoint { /// Expands the viewpoint's geometry. /// - Returns: A viewpoint with it's geometry expanded by 50%. func expanded() -> Viewpoint { let builder = EnvelopeBuilder(envelope: self.targetGeometry.extent) builder.expand(by: 0.5) let zoomEnvelope = builder.toGeometry() return Viewpoint(boundingGeometry: zoomEnvelope) }}
#Preview { NavigationStack { DownloadPreplannedMapAreaView() }}// Copyright 2022 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 DownloadPreplannedMapAreaView { struct MapPicker: View { /// The action to dismiss the sheet. @Environment(\.dismiss) private var dismiss
/// The view model for the download preplanned map area view. @ObservedObject var model: Model
var body: some View { NavigationStack { List { Section { Picker("Web Maps (Online)", selection: $model.selectedMap) { Text("Web Map (Online)") .tag(SelectedMap.onlineWebMap) } .labelsHidden() .pickerStyle(.inline) }
Section { switch model.offlineMapModels { case .success(let models): Picker("Preplanned Map Areas", selection: $model.selectedMap) { ForEach(models) { model in PreplannedMapAreaSelectionView(model: model) } } .labelsHidden() .pickerStyle(.inline) case .failure(let error): // Error getting the offline map models. Text(error.localizedDescription) case .none: // Getting the offline map models is still in progress. ProgressView().frame(maxWidth: .infinity) } } header: { Text("Preplanned Map Areas") } footer: { Text( """ Tap to download a preplanned map area for offline use. Once downloaded, \ tap it again to select and open it. """ ) } } .navigationTitle("Select Map") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } } } }
/// A view that displays preplanned map areas available for download. private struct PreplannedMapAreaSelectionView: View { /// The model that drives this view. @ObservedObject var model: OfflineMapModel
var body: some View { HStack { if model.isDownloading, let job = model.job { ProgressView(job.progress) .progressViewStyle(.gauge) Text(model.preplannedMapArea.portalItem.title) } else { switch model.result { case .success: EmptyView() case .failure: Image(systemName: "exclamationmark.circle") .foregroundStyle(.red) case .none: Image(systemName: "tray.and.arrow.down") .foregroundStyle(Color.accentColor) }
VStack(alignment: .leading, spacing: 4) { Text(model.preplannedMapArea.portalItem.title) .foregroundStyle(titleColor(for: model.result))
// If failed then show tap to retry text. if case .failure = model.result { Text("Error occurred. Tap to retry.") .font(.caption2) } } } } .tag(SelectedMap.offlineMap(model)) }
/// The color of the title for a given result. private func titleColor(for result: Result<MobileMapPackage, any Error>?) -> Color { switch model.result { case .success: return .primary case .failure, .none: return .secondary } } }}
/// A circular gauge progress view style.private struct GaugeProgressViewStyle: ProgressViewStyle { private var strokeStyle: StrokeStyle { .init(lineWidth: 3, lineCap: .round) }
func makeBody(configuration: Configuration) -> some View { if let fractionCompleted = configuration.fractionCompleted { ZStack { Circle() .stroke(Color(.systemGray5), style: strokeStyle) Circle() .trim(from: 0, to: fractionCompleted) .stroke(.gray, style: strokeStyle) .rotationEffect(.degrees(-90)) } .fixedSize() } }}
private extension ProgressViewStyle where Self == GaugeProgressViewStyle { /// A progress view that visually indicates its progress with a gauge. static var gauge: Self { .init() }}// Copyright 2022 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 Combineimport Foundation
extension DownloadPreplannedMapAreaView { @MainActor class Model: ObservableObject { /// The currently selected map. @Published var selectedMap: SelectedMap = .onlineWebMap { didSet { selectedMapDidChange(from: oldValue) } }
/// The offline map of the downloaded preplanned map area. private var offlineMap: Map?
/// The map used in the map view. var currentMap: Map { offlineMap ?? onlineMap }
/// A portal item displaying the Naperville, IL water network. private let portalItem = PortalItem.naperville()
/// The online map of the Naperville water network. private let onlineMap: Map
/// The offline map task. private let offlineMapTask: OfflineMapTask
/// A URL to a temporary directory where the downloaded map packages are stored. private let temporaryDirectory: URL
/// The offline map information. @Published private(set) var offlineMapModels: Result<[OfflineMapModel], any Error>?
/// All the offline map models or an empty array in the case of an error. private var allOfflineMapModels: [OfflineMapModel] { guard case .success(let models) = offlineMapModels else { return [] } return models }
/// A Boolean value indicating if the offline content can be deleted. @Published private(set) var canRemoveDownloadedMaps = false
init() { // Creates temp directory. temporaryDirectory = FileManager.createTemporaryDirectory()
// Initializes the online map and offline map task. onlineMap = Map(item: portalItem) offlineMapTask = OfflineMapTask(portalItem: portalItem) }
/// Gets the preplanned map areas from the offline map task and creates the /// offline map models. func makeOfflineMapModels() async { do { let models = try await offlineMapTask.preplannedMapAreas .sorted(using: KeyPathComparator(\.portalItem.title)) .compactMap { OfflineMapModel( preplannedMapArea: $0, offlineMapTask: offlineMapTask, temporaryDirectory: temporaryDirectory ) } offlineMapModels = .success(models) } catch { offlineMapModels = .failure(error) } }
deinit { // Removes the temporary directory. try? FileManager.default.removeItem(at: temporaryDirectory) }
/// Handles a selection of a map. /// If the selected map is an offline map and it has not yet been taken offline, then /// it will start downloading. Otherwise the selected map will be used as the displayed map. private func selectedMapDidChange(from oldValue: SelectedMap) { switch selectedMap { case .onlineWebMap: offlineMap = nil case .offlineMap(let info): if info.canDownload { // If we have not yet downloaded or started downloading, then kick off a // download and reset selection to previous selection since we have to download // the offline map. selectedMap = oldValue Task { [weak self] in await info.download() self?.updateCanRemoveDownloadedMaps() } } else if case .success(let mmpk) = info.result { // If we have already downloaded, then open the map in the mmpk. offlineMap = mmpk.maps.first } else { // If we have a failure, then keep the online map selected. selectedMap = oldValue } } }
/// Updates the `canRemoveDownloadedMaps` state. private func updateCanRemoveDownloadedMaps() { canRemoveDownloadedMaps = allOfflineMapModels.contains(where: \.downloadDidSucceed) }
/// Cancels all current jobs. private func cancelAllJobs() async { await withTaskGroup(of: Void.self) { group in for model in allOfflineMapModels { if model.isDownloading { group.addTask { @MainActor @Sendable in await model.cancelDownloading() } } } } }
/// Removes all downloaded maps. func removeDownloadedMaps() { // Sets the current map to the online web map. selectedMap = .onlineWebMap
// Updates state. canRemoveDownloadedMaps = false
Task { // Cancels all current download jobs. await cancelAllJobs()
// Removes each download. allOfflineMapModels.forEach { $0.removeDownloadedContent() } } } }}
extension DownloadPreplannedMapAreaView { /// A type that specifies the currently selected map. enum SelectedMap: Hashable { /// The online version of the map. case onlineWebMap /// One of the offline maps. case offlineMap(OfflineMapModel) }}
extension DownloadPreplannedMapAreaView { /// An object that encapsulates state about an offline map. class OfflineMapModel: ObservableObject, Identifiable { /// The preplanned map area. let preplannedMapArea: PreplannedMapArea
/// The task to use to take the area offline. let offlineMapTask: OfflineMapTask
/// The directory where the mmpk will be stored. let mmpkDirectory: URL
/// The currently running download job. @Published private(set) var job: DownloadPreplannedOfflineMapJob?
/// The result of the download job. @Published private(set) var result: Result<MobileMapPackage, any Error>?
init?(preplannedMapArea: PreplannedMapArea, offlineMapTask: OfflineMapTask, temporaryDirectory: URL) { self.preplannedMapArea = preplannedMapArea self.offlineMapTask = offlineMapTask
if let itemID = preplannedMapArea.portalItem.id { self.mmpkDirectory = temporaryDirectory .appendingPathComponent(itemID.rawValue) .appendingPathExtension("mmpk") } else { return nil } }
deinit { Task { [job] in // Cancel any outstanding job. await job?.cancel() } } }}
extension DownloadPreplannedMapAreaView.OfflineMapModel: Equatable { static func == ( lhs: DownloadPreplannedMapAreaView.OfflineMapModel, rhs: DownloadPreplannedMapAreaView.OfflineMapModel ) -> Bool { lhs === rhs }}
extension DownloadPreplannedMapAreaView.OfflineMapModel: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) }}
extension DownloadPreplannedMapAreaView.OfflineMapModel { /// A Boolean value indicating whether the map is being taken offline. var isDownloading: Bool { job != nil }}
@MainActorprivate extension DownloadPreplannedMapAreaView.OfflineMapModel { /// Downloads the given preplanned map area. /// - Parameter preplannedMapArea: The preplanned map area to be downloaded. /// - Precondition: `canDownload` func download() async { precondition(canDownload)
let parameters: DownloadPreplannedOfflineMapParameters
do { // Creates the parameters for the download preplanned offline map job. parameters = try await makeParameters(area: preplannedMapArea) } catch { // If creating the parameters fails, set the failure. self.result = .failure(error) return }
// Creates the download preplanned offline map job. let job = offlineMapTask.makeDownloadPreplannedOfflineMapJob( parameters: parameters, downloadDirectory: mmpkDirectory )
self.job = job
// Starts the job. job.start()
// Awaits the output of the job and assigns the result. result = await job.result.map(\.mobileMapPackage)
// Sets the job to nil self.job = nil }
/// A Boolean value indicating whether the offline map can be downloaded. /// This returns `false` if the map was already downloaded successfully or is in the process /// of being downloaded. var canDownload: Bool { !(isDownloading || downloadDidSucceed) }
/// A Boolean value indicating whether the download succeeded. var downloadDidSucceed: Bool { if case .success = result { return true } else { return false } }
/// Creates the parameters for a download preplanned offline map job. /// - Parameter preplannedMapArea: The preplanned map area to create parameters for. /// - Returns: A `DownloadPreplannedOfflineMapParameters` if there are no errors. func makeParameters(area: PreplannedMapArea) async throws -> DownloadPreplannedOfflineMapParameters { // Creates the default parameters. let parameters = try await offlineMapTask.makeDefaultDownloadPreplannedOfflineMapParameters(preplannedMapArea: area) // Sets the update mode to no updates as the offline map is display-only. parameters.updateMode = .noUpdates return parameters }
/// Cancels current download. func cancelDownloading() async { guard let job else { return } await job.cancel() self.job = nil }
/// Removes the downloaded offline map (mmpk) from disk. func removeDownloadedContent() { result = nil try? FileManager.default.removeItem(at: mmpkDirectory) }}
private extension FileManager { /// Creates a temporary directory and 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 PortalItem { static func naperville() -> PortalItem { PortalItem( portal: .arcGISOnline(connection: .anonymous), id: PortalItem.ID("acc027394bc84c2fb04d1ed317aac674")! ) }}