Skip to content

Commit 285a023

Browse files
authored
Merge pull request #526 from Esri/Caleb/New-ListContentsOfKMLFile
[New] List contents of KML file
2 parents 83e4a2b + ea1f3bf commit 285a023

File tree

5 files changed

+374
-0
lines changed

5 files changed

+374
-0
lines changed

Samples.xcodeproj/project.pbxproj

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@
341341
D75B58512AAFB3030038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75B58502AAFB3030038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift */; };
342342
D75B58522AAFB37C0038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D75B58502AAFB3030038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift */; };
343343
D75C35672AB50338003CD55F /* GroupLayersTogetherView.GroupLayerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75C35662AB50338003CD55F /* GroupLayersTogetherView.GroupLayerListView.swift */; };
344+
D75E5EE62CC0340100252595 /* ListContentsOfKMLFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75E5EE22CC0340100252595 /* ListContentsOfKMLFileView.swift */; };
345+
D75E5EE92CC0342700252595 /* ListContentsOfKMLFileView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D75E5EE22CC0340100252595 /* ListContentsOfKMLFileView.swift */; };
346+
D75E5EEC2CC0466900252595 /* esri_test_data.kmz in Resources */ = {isa = PBXBuildFile; fileRef = D75E5EEA2CC0466900252595 /* esri_test_data.kmz */; settings = {ASSET_TAGS = (ListContentsOfKmlFile, ); }; };
344347
D75F66362B48EABC00434974 /* SearchForWebMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75F66332B48EABC00434974 /* SearchForWebMapView.swift */; };
345348
D75F66392B48EB1800434974 /* SearchForWebMapView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D75F66332B48EABC00434974 /* SearchForWebMapView.swift */; };
346349
D76000A22AF18BAB00B3084D /* FindRouteInTransportNetworkView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7749AD52AF08BF50086632F /* FindRouteInTransportNetworkView.Model.swift */; };
@@ -564,6 +567,7 @@
564567
dstPath = "";
565568
dstSubfolderSpec = 7;
566569
files = (
570+
D75E5EE92CC0342700252595 /* ListContentsOfKMLFileView.swift in Copy Source Code Files */,
567571
D7201CDB2CC6B72A004BDB7D /* AddTiledLayerAsBasemapView.swift in Copy Source Code Files */,
568572
D7BE7E722CC19CE5006DDB0C /* AddTiledLayerView.swift in Copy Source Code Files */,
569573
D7BEBAD52CBDFE3900F882E7 /* DisplayAlternateSymbolsAtDifferentScalesView.swift in Copy Source Code Files */,
@@ -973,6 +977,8 @@
973977
D7588F5C2B7D8DAA008B75E2 /* NavigateRouteWithReroutingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigateRouteWithReroutingView.swift; sourceTree = "<group>"; };
974978
D75B58502AAFB3030038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StyleFeaturesWithCustomDictionaryView.swift; sourceTree = "<group>"; };
975979
D75C35662AB50338003CD55F /* GroupLayersTogetherView.GroupLayerListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupLayersTogetherView.GroupLayerListView.swift; sourceTree = "<group>"; };
980+
D75E5EE22CC0340100252595 /* ListContentsOfKMLFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListContentsOfKMLFileView.swift; sourceTree = "<group>"; };
981+
D75E5EEA2CC0466900252595 /* esri_test_data.kmz */ = {isa = PBXFileReference; lastKnownFileType = file; path = esri_test_data.kmz; sourceTree = "<group>"; };
976982
D75F66332B48EABC00434974 /* SearchForWebMapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchForWebMapView.swift; sourceTree = "<group>"; };
977983
D76000AB2AF19C2300B3084D /* FindRouteInMobileMapPackageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindRouteInMobileMapPackageView.swift; sourceTree = "<group>"; };
978984
D76000B62AF19FCA00B3084D /* SanFrancisco.mmpk */ = {isa = PBXFileReference; lastKnownFileType = file; path = SanFrancisco.mmpk; sourceTree = "<group>"; };
@@ -1290,6 +1296,7 @@
12901296
D70082E72ACF8F6C00E0C3C2 /* Identify KML features */,
12911297
D751018A2A2E960300B8FA48 /* Identify layer features */,
12921298
D7464F182ACE0445007FEE88 /* Identify raster cell */,
1299+
D75E5EE52CC0340100252595 /* List contents of KML file */,
12931300
D776880E2B69826B007C3860 /* List spatial reference transformations */,
12941301
D718A1E92B575FD900447087 /* Manage bookmarks */,
12951302
D752D93C2A3914E5003EB25E /* Manage operational layers */,
@@ -1479,6 +1486,7 @@
14791486
00D4EF8128638BF100B9CC30 /* cb1b20748a9f4d128dad8a87244e3e37 */,
14801487
4D126D7629CA3B3F00CFB7A7 /* d5bad9f4fee9483791e405880fb466da */,
14811488
D77572AC2A295DC100F490CD /* d1453556d91e46dea191c20c398b82cd */,
1489+
D75E5EEB2CC0466900252595 /* da301cb122874d5497f8a8f6c81eb36e */,
14821490
D7E7D0862AEB3C36003AAD02 /* df193653ed39449195af0c9725701dca */,
14831491
F111CCC2288B63DB00205358 /* e1f3a7254cb845b09450f54937c16061 */,
14841492
D7C5233F2BED9BBF00E8221A /* e4a398afe9a945f3b0f4dca1e4faccb5 */,
@@ -2346,6 +2354,22 @@
23462354
path = "Style features with custom dictionary";
23472355
sourceTree = "<group>";
23482356
};
2357+
D75E5EE52CC0340100252595 /* List contents of KML file */ = {
2358+
isa = PBXGroup;
2359+
children = (
2360+
D75E5EE22CC0340100252595 /* ListContentsOfKMLFileView.swift */,
2361+
);
2362+
path = "List contents of KML file";
2363+
sourceTree = "<group>";
2364+
};
2365+
D75E5EEB2CC0466900252595 /* da301cb122874d5497f8a8f6c81eb36e */ = {
2366+
isa = PBXGroup;
2367+
children = (
2368+
D75E5EEA2CC0466900252595 /* esri_test_data.kmz */,
2369+
);
2370+
path = da301cb122874d5497f8a8f6c81eb36e;
2371+
sourceTree = "<group>";
2372+
};
23492373
D75F66322B48EABC00434974 /* Search for web map */ = {
23502374
isa = PBXGroup;
23512375
children = (
@@ -3107,6 +3131,7 @@
31073131
GenerateOfflineMapWithLocalBasemap,
31083132
GeocodeOffline,
31093133
IdentifyRasterCell,
3134+
ListContentsOfKmlFile,
31103135
NavigateRouteWithRerouting,
31113136
OrbitCameraAroundObject,
31123137
ShowDeviceLocationWithNmeaDataSources,
@@ -3189,6 +3214,7 @@
31893214
D7E7D09A2AEB3C47003AAD02 /* san_diego_offline_routing in Resources */,
31903215
F111CCC4288B641900205358 /* Yellowstone.mmpk in Resources */,
31913216
D77572AE2A295DDE00F490CD /* PacificSouthWest2 in Resources */,
3217+
D75E5EEC2CC0466900252595 /* esri_test_data.kmz in Resources */,
31923218
10D321932BDB187400B39B1B /* naperville_imagery.tpkx in Resources */,
31933219
00D4EFB12863CE6300B9CC30 /* ScottishWildlifeTrust_reserves in Resources */,
31943220
D7781D492B7EB03400E53C51 /* SanDiegoTourPath.json in Resources */,
@@ -3399,6 +3425,7 @@
33993425
1C19B4F12A578E46001D2506 /* CreateLoadReportView.Views.swift in Sources */,
34003426
E066DD3B2860CA08004D3D5B /* ShowResultOfSpatialRelationshipsView.swift in Sources */,
34013427
7573E81E29D6134C00BEED9C /* TraceUtilityNetworkView.Views.swift in Sources */,
3428+
D75E5EE62CC0340100252595 /* ListContentsOfKMLFileView.swift in Sources */,
34023429
4D2ADC5A29C4F612003B367F /* ChangeMapViewBackgroundView.swift in Sources */,
34033430
95DEB9B62C127A92009BEC35 /* ShowViewshedFromPointOnMapView.swift in Sources */,
34043431
D7BA38972BFBFC0F009954F5 /* QueryRelatedFeaturesView.swift in Sources */,
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright 2024 Esri
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import ArcGIS
16+
import SwiftUI
17+
18+
struct ListContentsOfKMLFileView: View {
19+
/// The view model for the sample.
20+
@StateObject private var model = Model()
21+
22+
var body: some View {
23+
VStack(spacing: 0) {
24+
Text("Tap on a disclosure to reveal a node's children. Tap on a node to open it in a scene.")
25+
.multilineTextAlignment(.center)
26+
.frame(maxWidth: .infinity, alignment: .center)
27+
.padding(8)
28+
.background(Color(.systemGroupedBackground))
29+
30+
// Recursively displays the dataset's nodes in a list.
31+
List(model.kmlDataset?.rootNodes ?? [], id: \.name, children: \.children) { node in
32+
VStack(alignment: .leading) {
33+
NavigationLink {
34+
if let viewpoint = model.nodeViewpoints[node.name] {
35+
SceneView(scene: model.scene, viewpoint: viewpoint)
36+
.navigationTitle(node.name)
37+
} else {
38+
Text("This node has no extent to view.")
39+
.navigationTitle(node.name)
40+
}
41+
} label: {
42+
VStack(alignment: .leading) {
43+
if !node.name.isEmpty {
44+
Text(node.name)
45+
}
46+
Text(node.typeLabel)
47+
.font(.footnote)
48+
}
49+
#if targetEnvironment(macCatalyst)
50+
.padding(.leading)
51+
#endif
52+
}
53+
}
54+
}
55+
}
56+
.errorAlert(presentingError: $model.error)
57+
}
58+
}
59+
60+
// MARK: Model
61+
62+
private extension ListContentsOfKMLFileView {
63+
/// The view model for the sample.
64+
@MainActor
65+
final class Model: ObservableObject {
66+
/// A dataset containing the KML data from a local file.
67+
@Published private(set) var kmlDataset: KMLDataset?
68+
69+
/// The viewpoints for the nodes in the dataset.
70+
@Published private(set) var nodeViewpoints: [String: Viewpoint] = [:]
71+
72+
/// The error shown in the error alert.
73+
@Published var error: Error?
74+
75+
/// A scene for displaying the KML data.
76+
let scene: ArcGIS.Scene = {
77+
let scene = Scene(basemapStyle: .arcGISImagery)
78+
let elevationSource = ArcGISTiledElevationSource(url: .worldElevationService)
79+
scene.baseSurface.addElevationSource(elevationSource)
80+
return scene
81+
}()
82+
83+
/// The task used for the asynchronous setup operations.
84+
private var setupTask: Task<Void, Never>?
85+
86+
init() {
87+
setupTask = Task { [weak self] in
88+
guard let self else { return }
89+
90+
do {
91+
try await setUpKMLDataset()
92+
} catch {
93+
self.error = error
94+
}
95+
}
96+
}
97+
98+
deinit {
99+
setupTask?.cancel()
100+
}
101+
102+
/// Sets up the KML dataset and adds it to the scene as layer.
103+
private func setUpKMLDataset() async throws {
104+
// Creates the dataset using a local ".kml" file in the bundle.
105+
let kmlDataset = KMLDataset(name: "esri_test_data", bundle: .main)!
106+
try await kmlDataset.load()
107+
self.kmlDataset = kmlDataset
108+
109+
// Adds the dataset to the scene as a KML layer.
110+
let kmlLayer = KMLLayer(dataset: kmlDataset)
111+
scene.addOperationalLayer(kmlLayer)
112+
try await scene.load()
113+
114+
try await setUpKMLNodes(kmlDataset.rootNodes)
115+
}
116+
117+
/// Recursively creates viewpoints for KML nodes in a given list.
118+
/// - Parameter kmlNodes: The list of KML nodes to set up.
119+
private func setUpKMLNodes(_ kmlNodes: [KMLNode]) async throws {
120+
// Loads the surface so that the elevation can be queried when the viewpoint is made.
121+
try await scene.baseSurface.load()
122+
123+
for node in kmlNodes {
124+
let viewpoint = try await Viewpoint(kmlNode: node, surface: scene.baseSurface)
125+
nodeViewpoints[node.name] = viewpoint
126+
127+
// Ensures the node is visible since some are hidden by default.
128+
node.isVisible = true
129+
130+
if let childNodes = node.children {
131+
try await setUpKMLNodes(childNodes)
132+
}
133+
}
134+
}
135+
}
136+
}
137+
138+
// MARK: Helper Extensions
139+
140+
private extension KMLNode {
141+
/// The child nodes of the node, if any.
142+
var children: [KMLNode]? {
143+
switch self {
144+
case let container as KMLContainer:
145+
container.childNodes
146+
case let networkLink as KMLNetworkLink:
147+
networkLink.childNodes
148+
default:
149+
nil
150+
}
151+
}
152+
153+
/// A human-readable label of the type of the node.
154+
var typeLabel: String {
155+
switch self {
156+
case is KMLDocument: "Document"
157+
case is KMLFolder: "Folder"
158+
case is KMLContainer: "Container"
159+
case is KMLGroundOverlay: "Ground Overlay"
160+
case is KMLNetworkLink: "Network Link"
161+
case is KMLPhotoOverlay: "Photo Overlay"
162+
case is KMLPlacemark: "Placemark"
163+
case is KMLScreenOverlay: "Screen Overlay"
164+
case is KMLTour: "Tour"
165+
default: "Unknown"
166+
}
167+
}
168+
}
169+
170+
private extension Viewpoint {
171+
/// Creates a viewpoint from a KML node.
172+
/// - Parameters:
173+
/// - kmlNode: The KML node.
174+
/// - surface: A surface that determines the elevation needed to offset the viewpoint.
175+
init?(kmlNode: KMLNode, surface: Surface) async throws {
176+
if let kmlViewpoint = kmlNode.viewpoint {
177+
try await self.init(kmlViewpoint: kmlViewpoint, surface: surface)
178+
} else if let extent = kmlNode.extent {
179+
// Creates a viewpoint with node's extent if it doesn't have a predefined viewpoint.
180+
try await self.init(kmlNodeExtent: extent, surface: surface)
181+
} else {
182+
return nil
183+
}
184+
}
185+
186+
/// Creates a viewpoint from a KML viewpoint.
187+
/// - Parameters:
188+
/// - kmlViewpoint: The KML viewpoint.
189+
/// - surface: A surface that determines the elevation needed to offset the viewpoint.
190+
private init(kmlViewpoint: KMLViewpoint, surface: Surface) async throws {
191+
switch kmlViewpoint.kind {
192+
case .lookAt:
193+
var lookAtPoint = kmlViewpoint.location
194+
if kmlViewpoint.altitudeMode != .absolute {
195+
// If the elevation is relative, account for the surface's elevation.
196+
let elevation = try await surface.elevation(at: kmlViewpoint.location)
197+
lookAtPoint = kmlViewpoint.location.withBuilder { $0.z += elevation }
198+
}
199+
200+
let camera = Camera(
201+
lookingAt: lookAtPoint,
202+
distance: kmlViewpoint.range,
203+
heading: kmlViewpoint.heading,
204+
pitch: kmlViewpoint.pitch,
205+
roll: kmlViewpoint.roll
206+
)
207+
self.init(latitude: .nan, longitude: .nan, scale: .nan, camera: camera)
208+
case .camera:
209+
let camera = Camera(
210+
location: kmlViewpoint.location,
211+
heading: kmlViewpoint.heading,
212+
pitch: kmlViewpoint.pitch,
213+
roll: kmlViewpoint.roll
214+
)
215+
self.init(latitude: .nan, longitude: .nan, scale: .nan, camera: camera)
216+
@unknown default:
217+
fatalError("Unexpected KMLViewpoint.Kind: \(kmlViewpoint.kind)")
218+
}
219+
}
220+
221+
/// Creates a viewpoint from the extent of a KML node.
222+
/// - Parameters:
223+
/// - extent: The extent of a KML node.
224+
/// - surface: A surface that determines the elevation needed to offset the viewpoint.
225+
private init?(kmlNodeExtent extent: Envelope, surface: Surface) async throws {
226+
// Ensures the extent isn't empty since some nodes don't include a geometry.
227+
guard !extent.isEmpty else { return nil }
228+
229+
let extentCenter = extent.center
230+
let elevation = try await surface.elevation(at: extentCenter)
231+
232+
if extent.extent.width == 0, extent.height == 0 {
233+
// If the extent is not empty, but the width and height are still zero,
234+
// default values (based on Google Earth) are used to create a camera.
235+
let elevatedCenter = extentCenter.withBuilder { $0.z += elevation }
236+
let camera = Camera(
237+
lookingAt: elevatedCenter,
238+
distance: 1000,
239+
heading: 0,
240+
pitch: 45,
241+
roll: 0
242+
)
243+
self.init(latitude: .nan, longitude: .nan, scale: .nan, camera: camera)
244+
} else {
245+
// Adds the elevation and a buffer to the extent.
246+
let bufferedExtent = extent.withBuilder { builder in
247+
builder.zMin += elevation
248+
builder.zMax += elevation
249+
builder.expand(by: 1.1)
250+
}
251+
self.init(boundingGeometry: bufferedExtent)
252+
}
253+
}
254+
}
255+
256+
private extension URL {
257+
/// A web URL to the Terrain3D image server on ArcGIS REST.
258+
static var worldElevationService: URL {
259+
URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")!
260+
}
261+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# List contents of KML file
2+
3+
List the contents of a KML file.
4+
5+
![Screenshot of List contents of KML file sample](list-contents-of-kml-file.png)
6+
7+
## Use case
8+
9+
KML files can contain a hierarchy of features, including network links to other KML content. A user may wish to traverse through the contents of KML nodes to know what data is contained within each node and, recursively, their children.
10+
11+
## How to use the sample
12+
13+
The contents of the KML file are shown in a tree. Tap on a disclosure to reveal a node's children. Tap on a node to open it in a scene zoomed to that node. Not all nodes can be zoomed to (e.g., screen overlays).
14+
15+
## How it works
16+
17+
1. Add the KML file to the scene as a layer.
18+
2. Explore the root nodes of the `KMLDataset` recursively explored to create a view model.
19+
* Each node is enabled for display at this step. KML files may include nodes that are turned off by default.
20+
3. When a node is selected, use the node's `extent` to create a `Viewpoint` and pass it to the `SceneView`.
21+
22+
## Relevant API
23+
24+
* KMLContainer
25+
* KMLDataset
26+
* KMLDocument
27+
* KMLFolder
28+
* KMLGroundOverlay
29+
* KMLLayer
30+
* KMLNetworkLink
31+
* KMLNode
32+
* KMLPlacemark
33+
* KMLScreenOverlay
34+
35+
## Offline data
36+
37+
This sample uses the [esri_test_data](https://www.arcgis.com/home/item.html?id=da301cb122874d5497f8a8f6c81eb36e) KML file. It is downloaded from ArcGIS Online automatically.
38+
39+
## Tags
40+
41+
Keyhole, KML, KMZ, layers, OGC

0 commit comments

Comments
 (0)