Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
34577d4
initial commit for sample
chriswebb09 Jun 25, 2026
9e082f4
update for code error
chriswebb09 Jun 26, 2026
b7966ea
update screenshot and envelope logic
chriswebb09 Jun 26, 2026
09e5be4
update readme
chriswebb09 Jun 26, 2026
01f5610
Apply suggestions from code review
chriswebb09 Jun 26, 2026
913310a
update readme
chriswebb09 Jun 26, 2026
907e213
remove trailing space
chriswebb09 Jun 26, 2026
cb6ef2a
fix max scale
chriswebb09 Jun 26, 2026
e431bb7
cleanup code
chriswebb09 Jun 26, 2026
a52c300
Apply suggestions from code review
chriswebb09 Jun 26, 2026
e34a2e9
add fallback
chriswebb09 Jun 26, 2026
f20b130
Apply suggestions from code review
chriswebb09 Jun 26, 2026
8f09cda
Apply suggestions from code review
chriswebb09 Jun 26, 2026
dfcb275
update url pattrn
chriswebb09 Jun 26, 2026
2074d04
add drag gesture
chriswebb09 Jun 26, 2026
0ad9a13
Apply suggestions from code review
chriswebb09 Jun 30, 2026
8de4ae1
Apply suggestions from code review
chriswebb09 Jun 30, 2026
f74da13
cleanup extra space
chriswebb09 Jun 30, 2026
c2aaf22
add in change for rectangle
chriswebb09 Jun 30, 2026
59124db
change error to struct
chriswebb09 Jun 30, 2026
64d2aaa
update screenshot
chriswebb09 Jun 30, 2026
8082850
remove viewbuilder
chriswebb09 Jun 30, 2026
2bbeb37
Apply suggestions from code review
chriswebb09 Jun 30, 2026
6ee7764
Apply suggestions from code review
chriswebb09 Jun 30, 2026
ff0f866
add in docstring
chriswebb09 Jun 30, 2026
a221c8a
Merge remote-tracking branch 'refs/remotes/origin/chrisw/download-ras…
chriswebb09 Jun 30, 2026
8935971
add in review suggestions
chriswebb09 Jun 30, 2026
cadfb54
remove extra space
chriswebb09 Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Samples.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@
95A5721B2C0FDD34006E8B48 /* ShowScaleBarView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 95A572182C0FDCC9006E8B48 /* ShowScaleBarView.swift */; };
95A86A5B2E1C50C2000BF570 /* ShowPortalUserInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95A86A5A2E1C50BF000BF570 /* ShowPortalUserInfoView.swift */; };
95A86A5D2E1C51A9000BF570 /* ShowPortalUserInfoView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 95A86A5A2E1C50BF000BF570 /* ShowPortalUserInfoView.swift */; };
95AC9EE12FEDE9AF00DB2C1A /* DownloadRasterTilesToLocalCacheView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95AC9EE02FEDE9A200DB2C1A /* DownloadRasterTilesToLocalCacheView.swift */; };
95AC9EE32FEDE9CC00DB2C1A /* DownloadRasterTilesToLocalCacheView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 95AC9EE02FEDE9A200DB2C1A /* DownloadRasterTilesToLocalCacheView.swift */; };
95ADF34F2C3CBAE800566FF6 /* EditFeatureAttachmentsView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 9547085B2C3C719800CA8579 /* EditFeatureAttachmentsView.Model.swift */; };
95B096A02E136365006AFA8F /* Dolmus3ds in Resources */ = {isa = PBXBuildFile; fileRef = 95B0969F2E136365006AFA8F /* Dolmus3ds */; settings = {ASSET_TAGS = (ShowExploratoryLineOfSightBetweenGeoelements, ); }; };
95D2EE0F2C334D1600683D53 /* ShowServiceAreaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95D2EE0E2C334D1600683D53 /* ShowServiceAreaView.swift */; };
Expand Down Expand Up @@ -741,6 +743,7 @@
script = (
"xcrun --sdk macosx swift \"${SRCROOT}/Scripts/GenerateSampleViewSourceCode.swift\" \"${SCRIPT_INPUT_FILE_0}\" \"${INPUT_FILE_PATH}\" \"${SCRIPT_OUTPUT_FILE_0}\" ",
"",
"",
);
Comment thread
chriswebb09 marked this conversation as resolved.
};
0083586F27FE3BCF00192A15 /* PBXBuildRule */ = {
Expand All @@ -759,6 +762,7 @@
script = (
"\"${SRCROOT}/Scripts/masquerade\" -i \"${INPUT_FILE_PATH}\" -o \"${SCRIPT_OUTPUT_FILE_0}\" -s \"${SCRIPT_INPUT_FILE_0}\" -f",
"",
"",
);
Comment thread
chriswebb09 marked this conversation as resolved.
};
/* End PBXBuildRule section */
Expand All @@ -777,6 +781,7 @@
dstPath = "";
dstSubfolder = Resources;
files = (
95AC9EE32FEDE9CC00DB2C1A /* DownloadRasterTilesToLocalCacheView.swift in Copy Source Code Files */,
95DF31822FD37D99005D06E7 /* UpdateBasemapForContrastAccessibilityView.swift in Copy Source Code Files */,
D7CDCBAC2F86C3CE00B6C6AE /* ShowLineOfSightAnalysisInMapView.swift in Copy Source Code Files */,
D73169B42F7D990600AB132D /* ShowInteractiveViewshedWithAnalysisOverlayView.swift in Copy Source Code Files */,
Expand Down Expand Up @@ -1290,6 +1295,7 @@
95813E382DF88FD000342CBF /* SetMapImageLayerSublayerVisibilityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetMapImageLayerSublayerVisibilityView.swift; sourceTree = "<group>"; };
95A572182C0FDCC9006E8B48 /* ShowScaleBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowScaleBarView.swift; sourceTree = "<group>"; };
95A86A5A2E1C50BF000BF570 /* ShowPortalUserInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowPortalUserInfoView.swift; sourceTree = "<group>"; };
95AC9EE02FEDE9A200DB2C1A /* DownloadRasterTilesToLocalCacheView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadRasterTilesToLocalCacheView.swift; sourceTree = "<group>"; };
95B0969F2E136365006AFA8F /* Dolmus3ds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Dolmus3ds; sourceTree = "<group>"; };
95D2EE0E2C334D1600683D53 /* ShowServiceAreaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowServiceAreaView.swift; sourceTree = "<group>"; };
95DEB9B52C127A92009BEC35 /* ShowViewshedCalculatedFromGeoprocessingTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowViewshedCalculatedFromGeoprocessingTaskView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1799,6 +1805,7 @@
D7010EBA2B05616900D43F55 /* Display scene from mobile scene package */,
D742E48E2B04132B00690098 /* Display web scene from portal item */,
E070A0A1286F3B3400F2B606 /* Download preplanned map area */,
95AC9EE22FEDE9BE00DB2C1A /* Download raster tiles to local cache */,
E004A6EE284E4B7A002A1FE6 /* Download vector tiles to local cache */,
D733CA182BED980D00FBDE4C /* Edit and sync features with feature service */,
9579FCEB2C3360CA00FC8A1D /* Edit feature attachments */,
Expand Down Expand Up @@ -3028,6 +3035,14 @@
path = "Show portal user info";
sourceTree = "<group>";
};
95AC9EE22FEDE9BE00DB2C1A /* Download raster tiles to local cache */ = {
isa = PBXGroup;
children = (
95AC9EE02FEDE9A200DB2C1A /* DownloadRasterTilesToLocalCacheView.swift */,
);
path = "Download raster tiles to local cache";
sourceTree = "<group>";
};
95D2EE102C334D1D00683D53 /* Show service area */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -4937,6 +4952,7 @@
1CAB8D4E2A3CEAB0002AA649 /* RunValveIsolationTraceView.swift in Sources */,
D7A737E02BABB9FE00B7C7FC /* AugmentRealityToShowHiddenInfrastructureView.swift in Sources */,
4D2ADC4329C26D05003B367F /* AddDynamicEntityLayerView.swift in Sources */,
95AC9EE12FEDE9AF00DB2C1A /* DownloadRasterTilesToLocalCacheView.swift in Sources */,
D70082EB2ACF900100E0C3C2 /* IdentifyKMLFeaturesView.swift in Sources */,
D7635FFB2B9277DC0044AB97 /* ConfigureClustersView.Model.swift in Sources */,
D7EAF35A2A1C023800D822C4 /* SetMinAndMaxScaleView.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
// Copyright 2026 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 ArcGIS
import SwiftUI

struct DownloadRasterTilesToLocalCacheView: View {
/// The view model for the sample.
@State private var model = Model()

/// The current scale of the map view, used as the export's minimum scale.
@State private var mapViewScale = 0.0

/// A Boolean value indicating whether the exported tiles preview is showing.
@State private var isShowingPreview = false

/// The error shown in the error alert.
@State private var error: Error?

/// The insets that define the export extent within the map view.
private let extentInsets = EdgeInsets(top: 20, leading: 20, bottom: 44, trailing: 20)

var body: some View {
MapViewReader { mapViewProxy in
GeometryReader { geometryProxy in
MapView(map: model.map)
.onScaleChanged { mapViewScale = $0 }
.overlay {
// Draws a red rectangle to emphasize the extent that
// will be exported.
Rectangle()
.stroke(.red, lineWidth: 2)
.padding(extentInsets)
}
.overlay(alignment: .center) {
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated
if let job = model.exportTileCacheJob {
exportProgressView(job: job)
}
}
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button("Export Tiles") {
Task {
await exportTiles(
mapViewProxy: mapViewProxy,
size: geometryProxy.size
)
}
}
.disabled(model.exportTileCacheJob != nil)
}
}
}
}
.sheet(isPresented: $isShowingPreview, onDismiss: model.removePreview) {
previewSheet
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated
}
.errorAlert(presentingError: $error)
}

/// A progress indicator and cancel button shown while tiles are exporting.
private func exportProgressView(job: ExportTileCacheJob) -> some View {
VStack(spacing: 16) {
Text("Exporting tiles…")
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated
// Observes the job's progress to update the bar automatically.
ProgressView(job.progress)
.progressViewStyle(.linear)
Button("Cancel", role: .destructive) {
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated
Task { await model.cancelExport() }
}
}
.padding()
.frame(maxWidth: 220)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated
}

/// A sheet that previews the exported tile cache in its own map.
@ViewBuilder private var previewSheet: some View {
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated
NavigationStack {
if let previewMap = model.previewMap {
MapView(map: previewMap)
.navigationTitle("Exported Tiles")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { isShowingPreview = false }
}
}
}
}
}

/// Exports the tiles within the red rectangle and shows the preview sheet.
/// - Parameters:
/// - mapViewProxy: The proxy used to convert screen points to locations.
/// - size: The size of the map view, used to locate the export extent.
private func exportTiles(mapViewProxy: MapViewProxy, size: CGSize) async {
// Converts the red rectangle's corners to a geographic envelope.
let rect = CGRect(
x: extentInsets.leading,
y: extentInsets.top,
width: size.width - extentInsets.leading - extentInsets.trailing,
height: size.height - extentInsets.top - extentInsets.bottom
)
guard
let min = mapViewProxy.location(fromScreenPoint: CGPoint(x: rect.minX, y: rect.maxY)),
let max = mapViewProxy.location(fromScreenPoint: CGPoint(x: rect.maxX, y: rect.minY))
else { return }

do {
try await model.exportTiles(
extent: Envelope(min: min, max: max),
currentScale: mapViewScale
)
isShowingPreview = true
} catch {
self.error = error
}
}
}

extension DownloadRasterTilesToLocalCacheView {
/// The model that stores the maps and runs the export task for this sample.
@MainActor
@Observable
final class Model {
/// A map of the world street map tiled layer.
let map: Map
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated

/// A map that previews the exported tile cache, if one exists.
private(set) var previewMap: Map?

/// The export job currently downloading the tile package, if any.
private(set) var exportTileCacheJob: ExportTileCacheJob?

/// The tiled layer that provides both the basemap and the export source.
private let tiledLayer = ArcGISTiledLayer(url: .worldOceanBase)

/// The task that exports tiles from the tiled layer's service.
private let exportTask = ExportTileCacheTask(url: .worldOceanBase)

/// A URL to the temporary directory storing the exported tile package.
private let temporaryDirectory = FileManager.createTemporaryDirectory()

init() {
// Creates a map with a basemap made from the tiled layer, and limits
// its minimum scale to avoid requesting a huge download.
map = Map(basemap: Basemap(baseLayer: tiledLayer))
map.minScale = 1e7
map.initialViewpoint = Viewpoint(
center: Point(x: -117, y: 34, spatialReference: .wgs84),
scale: 1e7
)
}

deinit {
// Removes the temporary directory and all of its content.
try? FileManager.default.removeItem(at: temporaryDirectory)
}

/// Exports the tiles within an extent to a local tile package and builds
/// a preview map from the result.
/// - Parameters:
/// - extent: The geographic area of interest to export.
/// - currentScale: The map's current scale, used as the minimum scale.
func exportTiles(extent: Envelope, currentScale: Double) async throws {
// Loads the task to access its service metadata.
try await exportTask.load()
guard
let mapServiceInfo = exportTask.mapServiceInfo,
mapServiceInfo.allowsExportTiles
else {
throw ExportError.notSupported
}
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated

// Uses the current scale as the min scale and the tiled layer's max
// scale as the max scale.
let maxScale = tiledLayer.maxScale ?? 0
let minScale = Swift.max(currentScale, maxScale)

// Builds the default parameters for the extent and scale range.
let parameters = try await exportTask.makeDefaultExportTileCacheParameters(
areaOfInterest: extent,
minScale: minScale,
maxScale: maxScale
)

// Uses the compact V2 format (.tpkx) when supported, otherwise the
// legacy compact format (.tpk).
let fileExtension = mapServiceInfo.allowsExportTileCacheCompactV2 ? "tpkx" : "tpk"
let downloadURL = temporaryDirectory
.appendingPathComponent("myTileCache", isDirectory: false)
.appendingPathExtension(fileExtension)

// Creates and starts the export job.
let job = exportTask.makeExportTileCacheJob(
parameters: parameters,
downloadFileURL: downloadURL
)
exportTileCacheJob = job
job.start()
defer { exportTileCacheJob = nil }

// Awaits the resulting tile cache and builds a preview map from it.
let tileCache = try await job.output
let previewLayer = ArcGISTiledLayer(tileCache: tileCache)
let previewMap = Map(basemap: Basemap(baseLayer: previewLayer))
previewMap.initialViewpoint = Viewpoint(boundingGeometry: extent)
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated
self.previewMap = previewMap
}

/// Cancels the running export job, if one exists.
func cancelExport() async {
await exportTileCacheJob?.cancel()
exportTileCacheJob = nil
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated
}

/// Releases the preview map and removes the exported tile package.
func removePreview() {
previewMap = nil
try? FileManager.default.removeItem(at: temporaryDirectory)
}
}
}
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated

private extension DownloadRasterTilesToLocalCacheView.Model {
/// An error indicating the service does not support exporting tiles.
enum ExportError: LocalizedError {
case notSupported

var errorDescription: String? {
switch self {
case .notSupported:
return "Exporting tiles is not supported for the service."
}
}
}
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated
}

private extension FileManager {
/// Creates a uniquely named temporary directory and returns its URL.
static func createTemporaryDirectory() -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(ProcessInfo().globallyUniqueString)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
}

private extension URL {
/// The URL of the World Ocean Base (for Export) tile service.
static var worldOceanBase: URL {
URL(string: "https://tiledbasemaps.arcgis.com/arcgis/rest/services/Ocean/World_Ocean_Base/MapServer")!
}
}
42 changes: 42 additions & 0 deletions Shared/Samples/Download raster tiles to local cache/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Download raster tiles to local cache

Download tiles to a local tile cache file stored on the device.

![Image of download raster tiles to local cache](DownloadRasterTilesToLocalCache.png)
Comment thread
Copilot marked this conversation as resolved.
Outdated
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated

## Use case

Field workers with limited network connectivity can use exported tiles as a basemap for use offline.

## How to use the sample

Pan and zoom into the desired area, making sure the area is within the red boundary. Click the 'Export tiles' button to start the process. On successful completion you will see a preview of the downloaded tile package.
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated

## How it works

1. Create a map and set its `minScale` to 10,000,000. Limiting the scale in this sample limits the potential size of the selection area, thereby keeping the exported tile package to a reasonable size.
2. Create an `ExportTileCacheTask`, passing in the URI of the tiled layer.
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated
3. Create default `ExportTileCacheParameters` for the task, specifying extent, minimum scale and maximum scale.
4. Use the parameters and a path to create an `ExportTileCacheJob` from the task.
5. Start the job, and when it completes successfully, get the resulting `TileCache`.
6. Use the tile cache to create an `ArcGISTiledLayer`, and display it in the map.

## Relevant API

* ArcGISTiledLayer
* ExportTileCacheJob
* ExportTileCacheParameters
* ExportTileCacheTask
* TileCache

## About the data

The sample uses a [World Ocean Base (for Export)](https://www.arcgis.com/home/item.html?id=5d85d897aee241f884158aa514954443) map service that features marine bathymetry. The service supports "estimate export tiles size" and "export tiles" operations.

## Additional information

ArcGIS tiled layers do not support reprojection, query, select, identify, or editing. See the [Layer types](https://developers.arcgis.com/net/layers/#layer-types) discussion in the developers guide to learn more about the characteristics of ArcGIS tiled layers. The map service behind a tiled layer may support [Export Tiles](https://developers.arcgis.com/rest/services-reference/enterprise/export-tiles-map-service/) operation. You can also specify the maximum tiles clients will be allowed to download for the service.
Comment thread
chriswebb09 marked this conversation as resolved.
Outdated

## Tags

cache, download, offline
Loading
Loading