diff --git a/README.md b/README.md index 6580d718b..8f3bb74ef 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,25 @@ # ArcGIS Maps SDK for Swift Samples -This repository contains Swift sample code demonstrating the capabilities of [ArcGIS Maps SDK for Swift](https://developers.arcgis.com/swift/) and how to use them in your own app. The project can be opened in Xcode and run on a simulator or a device. +This repository contains Swift sample code demonstrating the capabilities of the [ArcGIS Maps SDK for Swift](https://developers.arcgis.com/swift/) and how to use those capabilities in your own app. The project can be opened in Xcode and run on a simulator or a device. + +## Features + +* Maps - Open, create, interact with and save maps +* Scenes - Visualize 3D environments and symbols +* Layers - Display vector and raster data in maps and scenes +* Augmented Reality - View data overlaid on the real world through your device's camera +* Visualization - Show graphics, popups, callouts, sketches, and style maps with symbols and renderers +* Edit and Manage Data - Add, delete, and edit features and attachments, and taking data offline +* Search and Query - Find addresses, places, and points of interest +* Routing and Logistics - Calculate routes between locations and around barriers +* Analysis - Perform spatial analysis via geoprocessing tasks and services +* Cloud and Portal - Search for web maps and securely connect to your portal +* Utility Networks - Work with utility networks, performing traces and exploring network elements ## Requirements -* [ArcGIS Maps SDK for Swift](https://developers.arcgis.com/swift/) 200.3 (or newer) -* [ArcGIS Maps SDK for Swift Toolkit](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit) 200.3 (or newer) +* [ArcGIS Maps SDK for Swift](https://developers.arcgis.com/swift/) 200.4 (or newer) +* [ArcGIS Maps SDK for Swift Toolkit](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit) 200.4 (or newer) * Xcode 15.0 (or newer) The *ArcGIS Maps SDK for Swift Samples app* has a *Target SDK* version of *15.0*, meaning that it can run on devices with *iOS 15.0* or newer. @@ -21,6 +35,9 @@ The *ArcGIS Maps SDK for Swift Samples app* has a *Target SDK* version of *15.0* ## Configuring API Keys +> [!IMPORTANT] +> Acquire the keys from your [dashboard](https://developers.arcgis.com/dashboard). Visit the developer's website to learn more about [API keys](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/api-keys/). + To run this app and access specific, ready-to-use services such as basemap layer, follow the steps to add an API key to a secrets file stored in the project file's directory, `$(SRCROOT)/.secrets`. 1. Create a hidden secrets file in the project file's directory. @@ -29,7 +46,7 @@ To run this app and access specific, ready-to-use services such as basemap layer touch .secrets ``` -2. Add your **API Key** to the secrets file aforementioned. Adding an API key allows you to access a set of ready-to-use services, including basemaps. Acquire the keys from your [dashboard](https://developers.arcgis.com/dashboard). Visit the developer's website to learn more about [API keys](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/api-keys/). +2. Add your **API Key** to the aforementioned secrets file. Adding an API key allows you to access a set of ready-to-use services, including basemaps. ```sh echo ARCGIS_API_KEY_IOS=your-api-key >> .secrets @@ -54,7 +71,7 @@ Find a bug or want to request a new feature? Please let us know by [creating an ## Licensing -Copyright 2022 - 2023 Esri +Copyright 2022 - 2024 Esri Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Samples.xcodeproj/project.pbxproj b/Samples.xcodeproj/project.pbxproj index 2101d8fb5..f67b1a5d0 100644 --- a/Samples.xcodeproj/project.pbxproj +++ b/Samples.xcodeproj/project.pbxproj @@ -7,11 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 0000FB6E2BBDB17600845921 /* Add3DTilesLayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0000FB6B2BBDB17600845921 /* Add3DTilesLayerView.swift */; }; + 0000FB712BBDC01400845921 /* Add3DTilesLayerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 0000FB6B2BBDB17600845921 /* Add3DTilesLayerView.swift */; }; 0005580A2817C51E00224BC6 /* SampleDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000558092817C51E00224BC6 /* SampleDetailView.swift */; }; + 000D43162B9918420003D3C2 /* ConfigureBasemapStyleParametersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000D43132B9918420003D3C2 /* ConfigureBasemapStyleParametersView.swift */; }; + 000D43182B993A030003D3C2 /* ConfigureBasemapStyleParametersView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 000D43132B9918420003D3C2 /* ConfigureBasemapStyleParametersView.swift */; }; 00181B462846AD7100654571 /* View+ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00181B452846AD7100654571 /* View+ErrorAlert.swift */; }; 001C6DE127FE8A9400D472C2 /* AppSecrets.swift.masque in Sources */ = {isa = PBXBuildFile; fileRef = 001C6DD827FE585A00D472C2 /* AppSecrets.swift.masque */; }; 00273CF42A82AB5900A7A77D /* SamplesSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00273CF32A82AB5900A7A77D /* SamplesSearchView.swift */; }; - 00273CF62A82AB8700A7A77D /* SampleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00273CF52A82AB8700A7A77D /* SampleRow.swift */; }; + 00273CF62A82AB8700A7A77D /* SampleLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00273CF52A82AB8700A7A77D /* SampleLink.swift */; }; 0039A4E92885C50300592C86 /* AddSceneLayerFromServiceView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = E066DD3F28610F55004D3D5B /* AddSceneLayerFromServiceView.swift */; }; 0039A4EA2885C50300592C86 /* ClipGeometryView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = E000E75F2869E33D005D87C5 /* ClipGeometryView.swift */; }; 0039A4EB2885C50300592C86 /* CreatePlanarAndGeodeticBuffersView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = E004A6EC2849556E002A1FE6 /* CreatePlanarAndGeodeticBuffersView.swift */; }; @@ -40,7 +44,7 @@ 0044289329C9234300160767 /* GetElevationAtPointOnSurfaceView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 0044289129C90C0B00160767 /* GetElevationAtPointOnSurfaceView.swift */; }; 0044CDDF2995C39E004618CE /* ShowDeviceLocationHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0044CDDE2995C39E004618CE /* ShowDeviceLocationHistoryView.swift */; }; 0044CDE02995D4DD004618CE /* ShowDeviceLocationHistoryView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 0044CDDE2995C39E004618CE /* ShowDeviceLocationHistoryView.swift */; }; - 004FE87129DF5D8700075217 /* Bristol in Resources */ = {isa = PBXBuildFile; fileRef = 004FE87029DF5D8700075217 /* Bristol */; settings = {ASSET_TAGS = (Animate3DGraphic, ChangeCameraController, ); }; }; + 004FE87129DF5D8700075217 /* Bristol in Resources */ = {isa = PBXBuildFile; fileRef = 004FE87029DF5D8700075217 /* Bristol */; settings = {ASSET_TAGS = (Animate3DGraphic, ChangeCameraController, OrbitCameraAroundObject, StylePointWithDistanceCompositeSceneSymbol, ); }; }; 006C835528B40682004AEB7F /* BrowseBuildingFloorsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = E0FE32E628747778002C6ACA /* BrowseBuildingFloorsView.swift */; }; 006C835628B40682004AEB7F /* DisplayMapFromMobileMapPackageView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = F111CCC0288B5D5600205358 /* DisplayMapFromMobileMapPackageView.swift */; }; 0074ABBF28174BCF0037244A /* DisplayMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0074ABBE28174BCF0037244A /* DisplayMapView.swift */; }; @@ -52,12 +56,6 @@ 00B04273282EC59E0072E1B4 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B04272282EC59E0072E1B4 /* AboutView.swift */; }; 00B042E8282EDC690072E1B4 /* SetBasemapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B042E5282EDC690072E1B4 /* SetBasemapView.swift */; }; 00B04FB5283EEBA80026C882 /* DisplayOverviewMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B04FB4283EEBA80026C882 /* DisplayOverviewMapView.swift */; }; - 00B56F792B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B56F782B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift */; }; - 00B56F7B2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B56F7A2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift */; }; - 00B56F7D2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B56F7C2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift */; }; - 00B56F7E2B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 00B56F782B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift */; }; - 00B56F7F2B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 00B56F7A2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift */; }; - 00B56F802B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 00B56F7C2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift */; }; 00C43AED2947DC350099AE34 /* ArcGISToolkit in Frameworks */ = {isa = PBXBuildFile; productRef = 00C43AEC2947DC350099AE34 /* ArcGISToolkit */; }; 00C7993B2A845AAF00AFE342 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00C7993A2A845AAF00AFE342 /* Sidebar.swift */; }; 00C94A0D28B53DE1004E42D9 /* raster-file in Resources */ = {isa = PBXBuildFile; fileRef = 00C94A0C28B53DE1004E42D9 /* raster-file */; settings = {ASSET_TAGS = (AddRasterFromFile, ); }; }; @@ -84,6 +82,10 @@ 1C19B4F72A578E69001D2506 /* CreateLoadReportView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C19B4EF2A578E46001D2506 /* CreateLoadReportView.Model.swift */; }; 1C19B4F82A578E69001D2506 /* CreateLoadReportView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C19B4ED2A578E46001D2506 /* CreateLoadReportView.swift */; }; 1C19B4F92A578E69001D2506 /* CreateLoadReportView.Views.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C19B4EB2A578E46001D2506 /* CreateLoadReportView.Views.swift */; }; + 1C2538542BABACB100337307 /* AugmentRealityToNavigateRouteView.RoutePlannerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C2538522BABACB100337307 /* AugmentRealityToNavigateRouteView.RoutePlannerView.swift */; }; + 1C2538552BABACB100337307 /* AugmentRealityToNavigateRouteView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C2538532BABACB100337307 /* AugmentRealityToNavigateRouteView.swift */; }; + 1C2538562BABACFD00337307 /* AugmentRealityToNavigateRouteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C2538532BABACB100337307 /* AugmentRealityToNavigateRouteView.swift */; }; + 1C2538572BABACFD00337307 /* AugmentRealityToNavigateRouteView.RoutePlannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C2538522BABACB100337307 /* AugmentRealityToNavigateRouteView.RoutePlannerView.swift */; }; 1C26ED192A859525009B7721 /* FilterFeaturesInSceneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C26ED152A859525009B7721 /* FilterFeaturesInSceneView.swift */; }; 1C26ED202A8BEC63009B7721 /* FilterFeaturesInSceneView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C26ED152A859525009B7721 /* FilterFeaturesInSceneView.swift */; }; 1C3B7DC82A5F64FC00907443 /* AnalyzeNetworkWithSubnetworkTraceView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3B7DC32A5F64FC00907443 /* AnalyzeNetworkWithSubnetworkTraceView.Model.swift */; }; @@ -98,8 +100,8 @@ 1C43BC852A43783900509BF8 /* SetVisibilityOfSubtypeSublayerView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C43BC7C2A43781100509BF8 /* SetVisibilityOfSubtypeSublayerView.Model.swift */; }; 1C43BC862A43783900509BF8 /* SetVisibilityOfSubtypeSublayerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C43BC7E2A43781100509BF8 /* SetVisibilityOfSubtypeSublayerView.swift */; }; 1C43BC872A43783900509BF8 /* SetVisibilityOfSubtypeSublayerView.Views.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C43BC792A43781100509BF8 /* SetVisibilityOfSubtypeSublayerView.Views.swift */; }; - 1C56B5E62A82C02D000381DA /* DisplayPointsUsingClusteringFeatureReductionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C56B5E22A82C02D000381DA /* DisplayPointsUsingClusteringFeatureReductionView.swift */; }; - 1C56B5E72A82C057000381DA /* DisplayPointsUsingClusteringFeatureReductionView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C56B5E22A82C02D000381DA /* DisplayPointsUsingClusteringFeatureReductionView.swift */; }; + 1C8EC7472BAE2891001A6929 /* AugmentRealityToCollectDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C8EC7432BAE2891001A6929 /* AugmentRealityToCollectDataView.swift */; }; + 1C8EC74B2BAE28A9001A6929 /* AugmentRealityToCollectDataView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C8EC7432BAE2891001A6929 /* AugmentRealityToCollectDataView.swift */; }; 1C929F092A27B86800134252 /* ShowUtilityAssociationsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1CAF831B2A20305F000E1E60 /* ShowUtilityAssociationsView.swift */; }; 1C965C3929DB9176002F8536 /* ShowRealisticLightAndShadowsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 1C9B74C529DB43580038B06F /* ShowRealisticLightAndShadowsView.swift */; }; 1C9B74C929DB43580038B06F /* ShowRealisticLightAndShadowsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C9B74C529DB43580038B06F /* ShowRealisticLightAndShadowsView.swift */; }; @@ -167,6 +169,8 @@ D704AA5B2AB22D8400A3BB63 /* GroupLayersTogetherView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D704AA592AB22C1A00A3BB63 /* GroupLayersTogetherView.swift */; }; D7054AE92ACCCB6C007235BA /* Animate3DGraphicView.SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7054AE82ACCCB6C007235BA /* Animate3DGraphicView.SettingsView.swift */; }; D7054AEA2ACCCC34007235BA /* Animate3DGraphicView.SettingsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7054AE82ACCCB6C007235BA /* Animate3DGraphicView.SettingsView.swift */; }; + D7058B102B59E44B000A888A /* StylePointWithSceneSymbolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7058B0D2B59E44B000A888A /* StylePointWithSceneSymbolView.swift */; }; + D7058B122B59E468000A888A /* StylePointWithSceneSymbolView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7058B0D2B59E44B000A888A /* StylePointWithSceneSymbolView.swift */; }; D7058FB12ACB423C00A40F14 /* Animate3DGraphicView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7058FB02ACB423C00A40F14 /* Animate3DGraphicView.Model.swift */; }; D7058FB22ACB424E00A40F14 /* Animate3DGraphicView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7058FB02ACB423C00A40F14 /* Animate3DGraphicView.Model.swift */; }; D7084FA92AD771AA00EC7F4F /* AugmentRealityToFlyOverSceneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7084FA62AD771AA00EC7F4F /* AugmentRealityToFlyOverSceneView.swift */; }; @@ -176,8 +180,14 @@ D710996E2A27D9B30065A1C1 /* DensifyAndGeneralizeGeometryView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D710996C2A27D9210065A1C1 /* DensifyAndGeneralizeGeometryView.swift */; }; D71099702A2802FA0065A1C1 /* DensifyAndGeneralizeGeometryView.SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D710996F2A2802FA0065A1C1 /* DensifyAndGeneralizeGeometryView.SettingsView.swift */; }; D71099712A280D830065A1C1 /* DensifyAndGeneralizeGeometryView.SettingsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D710996F2A2802FA0065A1C1 /* DensifyAndGeneralizeGeometryView.SettingsView.swift */; }; + D718A1E72B570F7500447087 /* OrbitCameraAroundObjectView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D718A1E62B570F7500447087 /* OrbitCameraAroundObjectView.Model.swift */; }; + D718A1E82B571C9100447087 /* OrbitCameraAroundObjectView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D718A1E62B570F7500447087 /* OrbitCameraAroundObjectView.Model.swift */; }; + D718A1ED2B575FD900447087 /* ManageBookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D718A1EA2B575FD900447087 /* ManageBookmarksView.swift */; }; + D718A1F02B57602000447087 /* ManageBookmarksView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D718A1EA2B575FD900447087 /* ManageBookmarksView.swift */; }; D71C5F642AAA7A88006599FD /* CreateSymbolStylesFromWebStylesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71C5F632AAA7A88006599FD /* CreateSymbolStylesFromWebStylesView.swift */; }; D71C5F652AAA83D2006599FD /* CreateSymbolStylesFromWebStylesView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D71C5F632AAA7A88006599FD /* CreateSymbolStylesFromWebStylesView.swift */; }; + D71D516E2B51D7B600B2A2BE /* SearchForWebMapView.Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71D516D2B51D7B600B2A2BE /* SearchForWebMapView.Views.swift */; }; + D71D516F2B51D87700B2A2BE /* SearchForWebMapView.Views.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D71D516D2B51D7B600B2A2BE /* SearchForWebMapView.Views.swift */; }; D71FCB8A2AD6277F000E517C /* CreateMobileGeodatabaseView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71FCB892AD6277E000E517C /* CreateMobileGeodatabaseView.Model.swift */; }; D71FCB8B2AD628B9000E517C /* CreateMobileGeodatabaseView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D71FCB892AD6277E000E517C /* CreateMobileGeodatabaseView.Model.swift */; }; D721EEA82ABDFF550040BE46 /* LothianRiversAnno.mmpk in Resources */ = {isa = PBXBuildFile; fileRef = D721EEA72ABDFF550040BE46 /* LothianRiversAnno.mmpk */; settings = {ASSET_TAGS = (ShowMobileMapPackageExpirationDate, ); }; }; @@ -199,9 +209,13 @@ D73723792AF5ADD800846884 /* FindRouteInMobileMapPackageView.MobileMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73723782AF5ADD700846884 /* FindRouteInMobileMapPackageView.MobileMapView.swift */; }; D737237A2AF5AE1600846884 /* FindRouteInMobileMapPackageView.MobileMapView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D73723782AF5ADD700846884 /* FindRouteInMobileMapPackageView.MobileMapView.swift */; }; D737237B2AF5AE1A00846884 /* FindRouteInMobileMapPackageView.Models.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D73723742AF5877500846884 /* FindRouteInMobileMapPackageView.Models.swift */; }; + D73F06692B5EE73D000B574F /* QueryFeaturesWithArcadeExpressionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73F06662B5EE73D000B574F /* QueryFeaturesWithArcadeExpressionView.swift */; }; + D73F066C2B5EE760000B574F /* QueryFeaturesWithArcadeExpressionView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D73F06662B5EE73D000B574F /* QueryFeaturesWithArcadeExpressionView.swift */; }; D73F8CF42AB1089900CD39DA /* Restaurant.stylx in Resources */ = {isa = PBXBuildFile; fileRef = D73F8CF32AB1089900CD39DA /* Restaurant.stylx */; settings = {ASSET_TAGS = (StyleFeaturesWithCustomDictionary, ); }; }; D73FC0FD2AD4A18D0067A19B /* CreateMobileGeodatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73FC0FC2AD4A18D0067A19B /* CreateMobileGeodatabaseView.swift */; }; D73FC0FE2AD4A19A0067A19B /* CreateMobileGeodatabaseView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D73FC0FC2AD4A18D0067A19B /* CreateMobileGeodatabaseView.swift */; }; + D73FC90B2B6312A0001AC486 /* AddFeaturesWithContingentValuesView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D74F03EF2B609A7D00E83688 /* AddFeaturesWithContingentValuesView.Model.swift */; }; + D73FC90C2B6312A5001AC486 /* AddFeaturesWithContingentValuesView.AddFeatureView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7F8C0422B608F120072BFA7 /* AddFeaturesWithContingentValuesView.AddFeatureView.swift */; }; D73FCFF72B02A3AA0006360D /* FindAddressWithReverseGeocodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73FCFF42B02A3AA0006360D /* FindAddressWithReverseGeocodeView.swift */; }; D73FCFFA2B02A3C50006360D /* FindAddressWithReverseGeocodeView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D73FCFF42B02A3AA0006360D /* FindAddressWithReverseGeocodeView.swift */; }; D73FCFFF2B02C7630006360D /* FindRouteAroundBarriersView.Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73FCFFE2B02C7630006360D /* FindRouteAroundBarriersView.Views.swift */; }; @@ -219,6 +233,9 @@ D74C8BFE2ABA5605007C76B8 /* StyleSymbolsFromMobileStyleFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C8BFD2ABA5605007C76B8 /* StyleSymbolsFromMobileStyleFileView.swift */; }; D74C8BFF2ABA56C0007C76B8 /* StyleSymbolsFromMobileStyleFileView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D74C8BFD2ABA5605007C76B8 /* StyleSymbolsFromMobileStyleFileView.swift */; }; D74C8C022ABA6202007C76B8 /* emoji-mobile.stylx in Resources */ = {isa = PBXBuildFile; fileRef = D74C8C012ABA6202007C76B8 /* emoji-mobile.stylx */; settings = {ASSET_TAGS = (StyleSymbolsFromMobileStyleFile, ); }; }; + D74EA7842B6DADA5008F6C7C /* ValidateUtilityNetworkTopologyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA7812B6DADA5008F6C7C /* ValidateUtilityNetworkTopologyView.swift */; }; + D74EA7872B6DADCC008F6C7C /* ValidateUtilityNetworkTopologyView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D74EA7812B6DADA5008F6C7C /* ValidateUtilityNetworkTopologyView.swift */; }; + D74F03F02B609A7D00E83688 /* AddFeaturesWithContingentValuesView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F03EF2B609A7D00E83688 /* AddFeaturesWithContingentValuesView.Model.swift */; }; D75101812A2E493600B8FA48 /* ShowLabelsOnLayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75101802A2E493600B8FA48 /* ShowLabelsOnLayerView.swift */; }; D75101822A2E497F00B8FA48 /* ShowLabelsOnLayerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D75101802A2E493600B8FA48 /* ShowLabelsOnLayerView.swift */; }; D751018E2A2E962D00B8FA48 /* IdentifyLayerFeaturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D751018D2A2E962D00B8FA48 /* IdentifyLayerFeaturesView.swift */; }; @@ -235,15 +252,33 @@ D754E3242A1D66C20006C5F1 /* StylePointWithPictureMarkerSymbolsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D754E3222A1D66820006C5F1 /* StylePointWithPictureMarkerSymbolsView.swift */; }; D7553CDB2AE2DFEC00DC2A70 /* GeocodeOfflineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7553CD82AE2DFEC00DC2A70 /* GeocodeOfflineView.swift */; }; D7553CDD2AE2E00E00DC2A70 /* GeocodeOfflineView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7553CD82AE2DFEC00DC2A70 /* GeocodeOfflineView.swift */; }; + D757D14B2B6C46E50065F78F /* ListSpatialReferenceTransformationsView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D757D14A2B6C46E50065F78F /* ListSpatialReferenceTransformationsView.Model.swift */; }; + D757D14C2B6C60170065F78F /* ListSpatialReferenceTransformationsView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D757D14A2B6C46E50065F78F /* ListSpatialReferenceTransformationsView.Model.swift */; }; + D7588F5F2B7D8DAA008B75E2 /* NavigateRouteWithReroutingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7588F5C2B7D8DAA008B75E2 /* NavigateRouteWithReroutingView.swift */; }; + D7588F622B7D8DED008B75E2 /* NavigateRouteWithReroutingView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7588F5C2B7D8DAA008B75E2 /* NavigateRouteWithReroutingView.swift */; }; D75B58512AAFB3030038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75B58502AAFB3030038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift */; }; D75B58522AAFB37C0038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D75B58502AAFB3030038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift */; }; D75C35672AB50338003CD55F /* GroupLayersTogetherView.GroupLayerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75C35662AB50338003CD55F /* GroupLayersTogetherView.GroupLayerListView.swift */; }; + D75F66362B48EABC00434974 /* SearchForWebMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75F66332B48EABC00434974 /* SearchForWebMapView.swift */; }; + D75F66392B48EB1800434974 /* SearchForWebMapView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D75F66332B48EABC00434974 /* SearchForWebMapView.swift */; }; D76000A22AF18BAB00B3084D /* FindRouteInTransportNetworkView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7749AD52AF08BF50086632F /* FindRouteInTransportNetworkView.Model.swift */; }; D76000AE2AF19C2300B3084D /* FindRouteInMobileMapPackageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76000AB2AF19C2300B3084D /* FindRouteInMobileMapPackageView.swift */; }; D76000B12AF19C4600B3084D /* FindRouteInMobileMapPackageView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D76000AB2AF19C2300B3084D /* FindRouteInMobileMapPackageView.swift */; }; D76000B72AF19FCA00B3084D /* SanFrancisco.mmpk in Resources */ = {isa = PBXBuildFile; fileRef = D76000B62AF19FCA00B3084D /* SanFrancisco.mmpk */; settings = {ASSET_TAGS = (FindRouteInMobileMapPackage, ); }; }; D7634FAF2A43B7AC00F8AEFB /* CreateConvexHullAroundGeometriesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7634FAE2A43B7AC00F8AEFB /* CreateConvexHullAroundGeometriesView.swift */; }; D7634FB02A43B8B000F8AEFB /* CreateConvexHullAroundGeometriesView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7634FAE2A43B7AC00F8AEFB /* CreateConvexHullAroundGeometriesView.swift */; }; + D7635FF12B9272CB0044AB97 /* DisplayClustersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7635FED2B9272CB0044AB97 /* DisplayClustersView.swift */; }; + D7635FFB2B9277DC0044AB97 /* ConfigureClustersView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7635FF52B9277DC0044AB97 /* ConfigureClustersView.Model.swift */; }; + D7635FFD2B9277DC0044AB97 /* ConfigureClustersView.SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7635FF72B9277DC0044AB97 /* ConfigureClustersView.SettingsView.swift */; }; + D7635FFE2B9277DC0044AB97 /* ConfigureClustersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7635FF82B9277DC0044AB97 /* ConfigureClustersView.swift */; }; + D76360002B9296420044AB97 /* ConfigureClustersView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7635FF82B9277DC0044AB97 /* ConfigureClustersView.swift */; }; + D76360012B92964A0044AB97 /* ConfigureClustersView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7635FF52B9277DC0044AB97 /* ConfigureClustersView.Model.swift */; }; + D76360022B9296520044AB97 /* ConfigureClustersView.SettingsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7635FF72B9277DC0044AB97 /* ConfigureClustersView.SettingsView.swift */; }; + D76360032B9296580044AB97 /* DisplayClustersView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7635FED2B9272CB0044AB97 /* DisplayClustersView.swift */; }; + D76495212B74687E0042699E /* ValidateUtilityNetworkTopologyView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76495202B74687E0042699E /* ValidateUtilityNetworkTopologyView.Model.swift */; }; + D76495222B7468940042699E /* ValidateUtilityNetworkTopologyView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D76495202B74687E0042699E /* ValidateUtilityNetworkTopologyView.Model.swift */; }; + D76929FA2B4F79540047205E /* OrbitCameraAroundObjectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76929F52B4F78340047205E /* OrbitCameraAroundObjectView.swift */; }; + D76929FB2B4F795C0047205E /* OrbitCameraAroundObjectView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D76929F52B4F78340047205E /* OrbitCameraAroundObjectView.swift */; }; D769C2122A29019B00030F61 /* SetUpLocationDrivenGeotriggersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D769C2112A29019B00030F61 /* SetUpLocationDrivenGeotriggersView.swift */; }; D769C2132A29057200030F61 /* SetUpLocationDrivenGeotriggersView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D769C2112A29019B00030F61 /* SetUpLocationDrivenGeotriggersView.swift */; }; D76EE6072AF9AFE100DA0325 /* FindRouteAroundBarriersView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76EE6062AF9AFE100DA0325 /* FindRouteAroundBarriersView.Model.swift */; }; @@ -256,10 +291,21 @@ D77570C02A2942F800F490CD /* AnimateImagesWithImageOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77570BF2A2942F800F490CD /* AnimateImagesWithImageOverlayView.swift */; }; D77570C12A2943D900F490CD /* AnimateImagesWithImageOverlayView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D77570BF2A2942F800F490CD /* AnimateImagesWithImageOverlayView.swift */; }; D77572AE2A295DDE00F490CD /* PacificSouthWest2 in Resources */ = {isa = PBXBuildFile; fileRef = D77572AD2A295DDD00F490CD /* PacificSouthWest2 */; settings = {ASSET_TAGS = (AnimateImagesWithImageOverlay, ); }; }; + D77688132B69826B007C3860 /* ListSpatialReferenceTransformationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77688102B69826B007C3860 /* ListSpatialReferenceTransformationsView.swift */; }; + D77688152B69828E007C3860 /* ListSpatialReferenceTransformationsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D77688102B69826B007C3860 /* ListSpatialReferenceTransformationsView.swift */; }; + D7781D492B7EB03400E53C51 /* SanDiegoTourPath.json in Resources */ = {isa = PBXBuildFile; fileRef = D7781D482B7EB03400E53C51 /* SanDiegoTourPath.json */; settings = {ASSET_TAGS = (NavigateRouteWithRerouting, ); }; }; + D7781D4B2B7ECCB700E53C51 /* NavigateRouteWithReroutingView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7781D4A2B7ECCB700E53C51 /* NavigateRouteWithReroutingView.Model.swift */; }; + D7781D4C2B7ECCC800E53C51 /* NavigateRouteWithReroutingView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7781D4A2B7ECCB700E53C51 /* NavigateRouteWithReroutingView.Model.swift */; }; + D77BC5392B59A2D3007B49B6 /* StylePointWithDistanceCompositeSceneSymbolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BC5362B59A2D3007B49B6 /* StylePointWithDistanceCompositeSceneSymbolView.swift */; }; + D77BC53C2B59A309007B49B6 /* StylePointWithDistanceCompositeSceneSymbolView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D77BC5362B59A2D3007B49B6 /* StylePointWithDistanceCompositeSceneSymbolView.swift */; }; + D77D9C002BB2438200B38A6C /* AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77D9BFF2BB2438200B38A6C /* AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift */; }; + D77D9C012BB2439400B38A6C /* AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D77D9BFF2BB2438200B38A6C /* AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift */; }; D78666AD2A2161F100C60110 /* FindNearestVertexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78666AC2A2161F100C60110 /* FindNearestVertexView.swift */; }; D78666AE2A21629200C60110 /* FindNearestVertexView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D78666AC2A2161F100C60110 /* FindNearestVertexView.swift */; }; D79EE76E2A4CEA5D005A52AE /* SetUpLocationDrivenGeotriggersView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79EE76D2A4CEA5D005A52AE /* SetUpLocationDrivenGeotriggersView.Model.swift */; }; D79EE76F2A4CEA7F005A52AE /* SetUpLocationDrivenGeotriggersView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D79EE76D2A4CEA5D005A52AE /* SetUpLocationDrivenGeotriggersView.Model.swift */; }; + D7A737E02BABB9FE00B7C7FC /* AugmentRealityToShowHiddenInfrastructureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A737DC2BABB9FE00B7C7FC /* AugmentRealityToShowHiddenInfrastructureView.swift */; }; + D7A737E32BABBA2200B7C7FC /* AugmentRealityToShowHiddenInfrastructureView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7A737DC2BABB9FE00B7C7FC /* AugmentRealityToShowHiddenInfrastructureView.swift */; }; D7ABA2F92A32579C0021822B /* MeasureDistanceInSceneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ABA2F82A32579C0021822B /* MeasureDistanceInSceneView.swift */; }; D7ABA2FA2A32760D0021822B /* MeasureDistanceInSceneView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7ABA2F82A32579C0021822B /* MeasureDistanceInSceneView.swift */; }; D7ABA2FF2A32881C0021822B /* ShowViewshedFromGeoelementInSceneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ABA2FE2A32881C0021822B /* ShowViewshedFromGeoelementInSceneView.swift */; }; @@ -268,12 +314,20 @@ D7AE861F2AC39E7F0049B626 /* DisplayAnnotationView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7AE861D2AC39DC50049B626 /* DisplayAnnotationView.swift */; }; D7AE86202AC3A1050049B626 /* AddCustomDynamicEntityDataSourceView.Vessel.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 7900C5F52A83FC3F002D430F /* AddCustomDynamicEntityDataSourceView.Vessel.swift */; }; D7AE86212AC3A10A0049B626 /* GroupLayersTogetherView.GroupLayerListView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D75C35662AB50338003CD55F /* GroupLayersTogetherView.GroupLayerListView.swift */; }; + D7B759B32B1FFBE300017FDD /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B759B22B1FFBE300017FDD /* FavoritesView.swift */; }; + D7BA8C442B2A4DAA00018633 /* Array+RawRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BA8C432B2A4DAA00018633 /* Array+RawRepresentable.swift */; }; + D7BA8C462B2A8ACA00018633 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BA8C452B2A8ACA00018633 /* String.swift */; }; D7C16D1B2AC5F95300689E89 /* Animate3DGraphicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C16D1A2AC5F95300689E89 /* Animate3DGraphicView.swift */; }; D7C16D1C2AC5F96900689E89 /* Animate3DGraphicView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7C16D1A2AC5F95300689E89 /* Animate3DGraphicView.swift */; }; D7C16D1F2AC5FE8200689E89 /* Pyrenees.csv in Resources */ = {isa = PBXBuildFile; fileRef = D7C16D1E2AC5FE8200689E89 /* Pyrenees.csv */; settings = {ASSET_TAGS = (Animate3DGraphic, ); }; }; D7C16D222AC5FE9800689E89 /* GrandCanyon.csv in Resources */ = {isa = PBXBuildFile; fileRef = D7C16D212AC5FE9800689E89 /* GrandCanyon.csv */; settings = {ASSET_TAGS = (Animate3DGraphic, ); }; }; D7C16D252AC5FEA600689E89 /* Snowdon.csv in Resources */ = {isa = PBXBuildFile; fileRef = D7C16D242AC5FEA600689E89 /* Snowdon.csv */; settings = {ASSET_TAGS = (Animate3DGraphic, ); }; }; D7C16D282AC5FEB700689E89 /* Hawaii.csv in Resources */ = {isa = PBXBuildFile; fileRef = D7C16D272AC5FEB600689E89 /* Hawaii.csv */; settings = {ASSET_TAGS = (Animate3DGraphic, ); }; }; + D7C3AB4A2B683291008909B9 /* SetFeatureRequestModeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C3AB472B683291008909B9 /* SetFeatureRequestModeView.swift */; }; + D7C3AB4D2B6832B7008909B9 /* SetFeatureRequestModeView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7C3AB472B683291008909B9 /* SetFeatureRequestModeView.swift */; }; + D7C6420C2B4F47E10042B8F7 /* SearchForWebMapView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6420B2B4F47E10042B8F7 /* SearchForWebMapView.Model.swift */; }; + D7C6420D2B4F5DDB0042B8F7 /* SearchForWebMapView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7C6420B2B4F47E10042B8F7 /* SearchForWebMapView.Model.swift */; }; + D7C97B562B75C10C0097CDA1 /* ValidateUtilityNetworkTopologyView.Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C97B552B75C10C0097CDA1 /* ValidateUtilityNetworkTopologyView.Views.swift */; }; D7CC33FF2A31475C00198EDF /* ShowLineOfSightBetweenPointsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CC33FD2A31475C00198EDF /* ShowLineOfSightBetweenPointsView.swift */; }; D7CC34002A3147FF00198EDF /* ShowLineOfSightBetweenPointsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7CC33FD2A31475C00198EDF /* ShowLineOfSightBetweenPointsView.swift */; }; D7CE9F9B2AE2F575008F7A5F /* streetmap_SD.tpkx in Resources */ = {isa = PBXBuildFile; fileRef = D7CE9F9A2AE2F575008F7A5F /* streetmap_SD.tpkx */; settings = {ASSET_TAGS = (GeocodeOffline, ); }; }; @@ -287,7 +341,7 @@ D7E557682A1D768800B9FB09 /* AddWMSLayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E557672A1D768800B9FB09 /* AddWMSLayerView.swift */; }; D7E7D0812AEB39D5003AAD02 /* FindRouteInTransportNetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E7D0802AEB39D5003AAD02 /* FindRouteInTransportNetworkView.swift */; }; D7E7D0822AEB3A1D003AAD02 /* FindRouteInTransportNetworkView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7E7D0802AEB39D5003AAD02 /* FindRouteInTransportNetworkView.swift */; }; - D7E7D09A2AEB3C47003AAD02 /* san_diego_offline_routing in Resources */ = {isa = PBXBuildFile; fileRef = D7E7D0992AEB3C47003AAD02 /* san_diego_offline_routing */; settings = {ASSET_TAGS = (FindRouteInTransportNetwork, ); }; }; + D7E7D09A2AEB3C47003AAD02 /* san_diego_offline_routing in Resources */ = {isa = PBXBuildFile; fileRef = D7E7D0992AEB3C47003AAD02 /* san_diego_offline_routing */; settings = {ASSET_TAGS = (FindRouteInTransportNetwork, NavigateRouteWithRerouting, ); }; }; D7E9EF292A1D2219000C4865 /* SetMinAndMaxScaleView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7EAF3592A1C023800D822C4 /* SetMinAndMaxScaleView.swift */; }; D7E9EF2A2A1D29F2000C4865 /* SetMaxExtentView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D734FA092A183A5B00246D7E /* SetMaxExtentView.swift */; }; D7EAF35A2A1C023800D822C4 /* SetMinAndMaxScaleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EAF3592A1C023800D822C4 /* SetMinAndMaxScaleView.swift */; }; @@ -296,6 +350,12 @@ D7EF5D752A26A03A00FEBDE5 /* ShowCoordinatesInMultipleFormatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EF5D742A26A03A00FEBDE5 /* ShowCoordinatesInMultipleFormatsView.swift */; }; D7EF5D762A26A1EE00FEBDE5 /* ShowCoordinatesInMultipleFormatsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7EF5D742A26A03A00FEBDE5 /* ShowCoordinatesInMultipleFormatsView.swift */; }; D7F2784C2A1D76F5002E4567 /* AddWMSLayerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7E557672A1D768800B9FB09 /* AddWMSLayerView.swift */; }; + D7F850042B7C427A00680D7C /* ValidateUtilityNetworkTopologyView.Views.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7C97B552B75C10C0097CDA1 /* ValidateUtilityNetworkTopologyView.Views.swift */; }; + D7F8C0392B60564D0072BFA7 /* AddFeaturesWithContingentValuesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F8C0362B60564D0072BFA7 /* AddFeaturesWithContingentValuesView.swift */; }; + D7F8C03B2B6056790072BFA7 /* AddFeaturesWithContingentValuesView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7F8C0362B60564D0072BFA7 /* AddFeaturesWithContingentValuesView.swift */; }; + D7F8C03E2B605AF60072BFA7 /* ContingentValuesBirdNests.geodatabase in Resources */ = {isa = PBXBuildFile; fileRef = D7F8C03D2B605AF60072BFA7 /* ContingentValuesBirdNests.geodatabase */; settings = {ASSET_TAGS = (AddFeaturesWithContingentValues, ); }; }; + D7F8C0412B605E720072BFA7 /* FillmoreTopographicMap.vtpk in Resources */ = {isa = PBXBuildFile; fileRef = D7F8C0402B605E720072BFA7 /* FillmoreTopographicMap.vtpk */; settings = {ASSET_TAGS = (AddFeaturesWithContingentValues, ); }; }; + D7F8C0432B608F120072BFA7 /* AddFeaturesWithContingentValuesView.AddFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F8C0422B608F120072BFA7 /* AddFeaturesWithContingentValuesView.AddFeatureView.swift */; }; E000E7602869E33D005D87C5 /* ClipGeometryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E000E75F2869E33D005D87C5 /* ClipGeometryView.swift */; }; E000E763286A0B18005D87C5 /* CutGeometryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E000E762286A0B18005D87C5 /* CutGeometryView.swift */; }; E004A6C128414332002A1FE6 /* SetViewpointRotationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E004A6BD28414332002A1FE6 /* SetViewpointRotationView.swift */; }; @@ -323,7 +383,6 @@ E070A0A3286F3B6000F2B606 /* DownloadPreplannedMapAreaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E070A0A2286F3B6000F2B606 /* DownloadPreplannedMapAreaView.swift */; }; E088E1572862579D00413100 /* SetSurfacePlacementModeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E088E1562862579D00413100 /* SetSurfacePlacementModeView.swift */; }; E088E1742863B5F800413100 /* GenerateOfflineMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E088E1732863B5F800413100 /* GenerateOfflineMapView.swift */; }; - E08953F12891899600E077CF /* EnvironmentValues+SampleInfoVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = E08953F02891899600E077CF /* EnvironmentValues+SampleInfoVisibility.swift */; }; E0A1AEE328874590003C797D /* AddFeatureLayersView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 00D4EF7F2863842100B9CC30 /* AddFeatureLayersView.swift */; }; E0D04FF228A5390000747989 /* DownloadPreplannedMapAreaView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D04FF128A5390000747989 /* DownloadPreplannedMapAreaView.Model.swift */; }; E0EA0B772866390E00C9621D /* ProjectGeometryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0EA0B762866390E00C9621D /* ProjectGeometryView.swift */; }; @@ -386,9 +445,37 @@ dstPath = ""; dstSubfolderSpec = 7; files = ( - 00B56F7E2B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift in Copy Source Code Files */, - 00B56F7F2B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift in Copy Source Code Files */, - 00B56F802B0EBE9C00B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift in Copy Source Code Files */, + 0000FB712BBDC01400845921 /* Add3DTilesLayerView.swift in Copy Source Code Files */, + D77D9C012BB2439400B38A6C /* AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift in Copy Source Code Files */, + D7A737E32BABBA2200B7C7FC /* AugmentRealityToShowHiddenInfrastructureView.swift in Copy Source Code Files */, + 1C2538542BABACB100337307 /* AugmentRealityToNavigateRouteView.RoutePlannerView.swift in Copy Source Code Files */, + 1C2538552BABACB100337307 /* AugmentRealityToNavigateRouteView.swift in Copy Source Code Files */, + 1C8EC74B2BAE28A9001A6929 /* AugmentRealityToCollectDataView.swift in Copy Source Code Files */, + 000D43182B993A030003D3C2 /* ConfigureBasemapStyleParametersView.swift in Copy Source Code Files */, + D76360032B9296580044AB97 /* DisplayClustersView.swift in Copy Source Code Files */, + D76360022B9296520044AB97 /* ConfigureClustersView.SettingsView.swift in Copy Source Code Files */, + D76360012B92964A0044AB97 /* ConfigureClustersView.Model.swift in Copy Source Code Files */, + D76360002B9296420044AB97 /* ConfigureClustersView.swift in Copy Source Code Files */, + D7781D4C2B7ECCC800E53C51 /* NavigateRouteWithReroutingView.Model.swift in Copy Source Code Files */, + D7588F622B7D8DED008B75E2 /* NavigateRouteWithReroutingView.swift in Copy Source Code Files */, + D7F850042B7C427A00680D7C /* ValidateUtilityNetworkTopologyView.Views.swift in Copy Source Code Files */, + D76495222B7468940042699E /* ValidateUtilityNetworkTopologyView.Model.swift in Copy Source Code Files */, + D74EA7872B6DADCC008F6C7C /* ValidateUtilityNetworkTopologyView.swift in Copy Source Code Files */, + D757D14C2B6C60170065F78F /* ListSpatialReferenceTransformationsView.Model.swift in Copy Source Code Files */, + D77688152B69828E007C3860 /* ListSpatialReferenceTransformationsView.swift in Copy Source Code Files */, + D7C3AB4D2B6832B7008909B9 /* SetFeatureRequestModeView.swift in Copy Source Code Files */, + D73FC90C2B6312A5001AC486 /* AddFeaturesWithContingentValuesView.AddFeatureView.swift in Copy Source Code Files */, + D73FC90B2B6312A0001AC486 /* AddFeaturesWithContingentValuesView.Model.swift in Copy Source Code Files */, + D7F8C03B2B6056790072BFA7 /* AddFeaturesWithContingentValuesView.swift in Copy Source Code Files */, + D73F066C2B5EE760000B574F /* QueryFeaturesWithArcadeExpressionView.swift in Copy Source Code Files */, + D718A1F02B57602000447087 /* ManageBookmarksView.swift in Copy Source Code Files */, + D77BC53C2B59A309007B49B6 /* StylePointWithDistanceCompositeSceneSymbolView.swift in Copy Source Code Files */, + D718A1E82B571C9100447087 /* OrbitCameraAroundObjectView.Model.swift in Copy Source Code Files */, + D76929FB2B4F795C0047205E /* OrbitCameraAroundObjectView.swift in Copy Source Code Files */, + D7058B122B59E468000A888A /* StylePointWithSceneSymbolView.swift in Copy Source Code Files */, + D71D516F2B51D87700B2A2BE /* SearchForWebMapView.Views.swift in Copy Source Code Files */, + D7C6420D2B4F5DDB0042B8F7 /* SearchForWebMapView.Model.swift in Copy Source Code Files */, + D75F66392B48EB1800434974 /* SearchForWebMapView.swift in Copy Source Code Files */, D73FCFFA2B02A3C50006360D /* FindAddressWithReverseGeocodeView.swift in Copy Source Code Files */, D742E4952B04134C00690098 /* DisplayWebSceneFromPortalItemView.swift in Copy Source Code Files */, D7010EC12B05618400D43F55 /* DisplaySceneFromMobileScenePackageView.swift in Copy Source Code Files */, @@ -432,7 +519,6 @@ D71C5F652AAA83D2006599FD /* CreateSymbolStylesFromWebStylesView.swift in Copy Source Code Files */, 79D84D152A81718F00F45262 /* AddCustomDynamicEntityDataSourceView.swift in Copy Source Code Files */, 1C26ED202A8BEC63009B7721 /* FilterFeaturesInSceneView.swift in Copy Source Code Files */, - 1C56B5E72A82C057000381DA /* DisplayPointsUsingClusteringFeatureReductionView.swift in Copy Source Code Files */, D7ABA3002A3288970021822B /* ShowViewshedFromGeoelementInSceneView.swift in Copy Source Code Files */, 1C3B7DCD2A5F652500907443 /* AnalyzeNetworkWithSubnetworkTraceView.Model.swift in Copy Source Code Files */, 1C3B7DCE2A5F652500907443 /* AnalyzeNetworkWithSubnetworkTraceView.swift in Copy Source Code Files */, @@ -532,11 +618,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0000FB6B2BBDB17600845921 /* Add3DTilesLayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Add3DTilesLayerView.swift; sourceTree = ""; }; 000558092817C51E00224BC6 /* SampleDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleDetailView.swift; sourceTree = ""; }; + 000D43132B9918420003D3C2 /* ConfigureBasemapStyleParametersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigureBasemapStyleParametersView.swift; sourceTree = ""; }; 00181B452846AD7100654571 /* View+ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ErrorAlert.swift"; sourceTree = ""; }; 001C6DD827FE585A00D472C2 /* AppSecrets.swift.masque */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = AppSecrets.swift.masque; sourceTree = ""; }; 00273CF32A82AB5900A7A77D /* SamplesSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SamplesSearchView.swift; sourceTree = ""; }; - 00273CF52A82AB8700A7A77D /* SampleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleRow.swift; sourceTree = ""; }; + 00273CF52A82AB8700A7A77D /* SampleLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleLink.swift; sourceTree = ""; }; 003D7C342821EBCC009DDFD2 /* masquerade */ = {isa = PBXFileReference; lastKnownFileType = text; path = masquerade; sourceTree = ""; }; 003D7C352821EBCC009DDFD2 /* GenerateSampleViewSourceCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateSampleViewSourceCode.swift; sourceTree = ""; }; 0042E24228E4BF8F001F33D6 /* ShowViewshedFromPointInSceneView.Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowViewshedFromPointInSceneView.Model.swift; sourceTree = ""; }; @@ -554,9 +642,6 @@ 00B04272282EC59E0072E1B4 /* AboutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 00B042E5282EDC690072E1B4 /* SetBasemapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetBasemapView.swift; sourceTree = ""; }; 00B04FB4283EEBA80026C882 /* DisplayOverviewMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayOverviewMapView.swift; sourceTree = ""; }; - 00B56F782B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddClusteringFeatureReductionToAPointFeatureLayerView.swift; sourceTree = ""; }; - 00B56F7A2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift; sourceTree = ""; }; - 00B56F7C2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift; sourceTree = ""; }; 00C7993A2A845AAF00AFE342 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; 00C94A0C28B53DE1004E42D9 /* raster-file */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "raster-file"; sourceTree = ""; }; 00CB9137284814A4005C2C5D /* SearchWithGeocodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchWithGeocodeView.swift; sourceTree = ""; }; @@ -577,6 +662,8 @@ 1C19B4EB2A578E46001D2506 /* CreateLoadReportView.Views.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateLoadReportView.Views.swift; sourceTree = ""; }; 1C19B4ED2A578E46001D2506 /* CreateLoadReportView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateLoadReportView.swift; sourceTree = ""; }; 1C19B4EF2A578E46001D2506 /* CreateLoadReportView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateLoadReportView.Model.swift; sourceTree = ""; }; + 1C2538522BABACB100337307 /* AugmentRealityToNavigateRouteView.RoutePlannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AugmentRealityToNavigateRouteView.RoutePlannerView.swift; sourceTree = ""; }; + 1C2538532BABACB100337307 /* AugmentRealityToNavigateRouteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AugmentRealityToNavigateRouteView.swift; sourceTree = ""; }; 1C26ED152A859525009B7721 /* FilterFeaturesInSceneView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterFeaturesInSceneView.swift; sourceTree = ""; }; 1C3B7DC32A5F64FC00907443 /* AnalyzeNetworkWithSubnetworkTraceView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnalyzeNetworkWithSubnetworkTraceView.Model.swift; sourceTree = ""; }; 1C3B7DC62A5F64FC00907443 /* AnalyzeNetworkWithSubnetworkTraceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnalyzeNetworkWithSubnetworkTraceView.swift; sourceTree = ""; }; @@ -584,7 +671,7 @@ 1C43BC792A43781100509BF8 /* SetVisibilityOfSubtypeSublayerView.Views.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetVisibilityOfSubtypeSublayerView.Views.swift; sourceTree = ""; }; 1C43BC7C2A43781100509BF8 /* SetVisibilityOfSubtypeSublayerView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetVisibilityOfSubtypeSublayerView.Model.swift; sourceTree = ""; }; 1C43BC7E2A43781100509BF8 /* SetVisibilityOfSubtypeSublayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetVisibilityOfSubtypeSublayerView.swift; sourceTree = ""; }; - 1C56B5E22A82C02D000381DA /* DisplayPointsUsingClusteringFeatureReductionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayPointsUsingClusteringFeatureReductionView.swift; sourceTree = ""; }; + 1C8EC7432BAE2891001A6929 /* AugmentRealityToCollectDataView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AugmentRealityToCollectDataView.swift; sourceTree = ""; }; 1C9B74C529DB43580038B06F /* ShowRealisticLightAndShadowsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShowRealisticLightAndShadowsView.swift; sourceTree = ""; }; 1C9B74D529DB54560038B06F /* ChangeCameraControllerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangeCameraControllerView.swift; sourceTree = ""; }; 1CAB8D442A3CEAB0002AA649 /* RunValveIsolationTraceView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RunValveIsolationTraceView.Model.swift; sourceTree = ""; }; @@ -620,12 +707,16 @@ D701D72B2A37C7F7006FF0C8 /* bradley_low_3ds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = bradley_low_3ds; sourceTree = ""; }; D704AA592AB22C1A00A3BB63 /* GroupLayersTogetherView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupLayersTogetherView.swift; sourceTree = ""; }; D7054AE82ACCCB6C007235BA /* Animate3DGraphicView.SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Animate3DGraphicView.SettingsView.swift; sourceTree = ""; }; + D7058B0D2B59E44B000A888A /* StylePointWithSceneSymbolView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StylePointWithSceneSymbolView.swift; sourceTree = ""; }; D7058FB02ACB423C00A40F14 /* Animate3DGraphicView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Animate3DGraphicView.Model.swift; sourceTree = ""; }; D7084FA62AD771AA00EC7F4F /* AugmentRealityToFlyOverSceneView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AugmentRealityToFlyOverSceneView.swift; sourceTree = ""; }; D70BE5782A5624A80022CA02 /* CategoriesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesView.swift; sourceTree = ""; }; D710996C2A27D9210065A1C1 /* DensifyAndGeneralizeGeometryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DensifyAndGeneralizeGeometryView.swift; sourceTree = ""; }; D710996F2A2802FA0065A1C1 /* DensifyAndGeneralizeGeometryView.SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DensifyAndGeneralizeGeometryView.SettingsView.swift; sourceTree = ""; }; + D718A1E62B570F7500447087 /* OrbitCameraAroundObjectView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OrbitCameraAroundObjectView.Model.swift; sourceTree = ""; }; + D718A1EA2B575FD900447087 /* ManageBookmarksView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManageBookmarksView.swift; sourceTree = ""; }; D71C5F632AAA7A88006599FD /* CreateSymbolStylesFromWebStylesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSymbolStylesFromWebStylesView.swift; sourceTree = ""; }; + D71D516D2B51D7B600B2A2BE /* SearchForWebMapView.Views.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchForWebMapView.Views.swift; sourceTree = ""; }; D71FCB892AD6277E000E517C /* CreateMobileGeodatabaseView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateMobileGeodatabaseView.Model.swift; sourceTree = ""; }; D721EEA72ABDFF550040BE46 /* LothianRiversAnno.mmpk */ = {isa = PBXFileReference; lastKnownFileType = file; path = LothianRiversAnno.mmpk; sourceTree = ""; }; D722BD212A420DAD002C2087 /* ShowExtrudedFeaturesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShowExtrudedFeaturesView.swift; sourceTree = ""; }; @@ -638,6 +729,7 @@ D734FA092A183A5B00246D7E /* SetMaxExtentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetMaxExtentView.swift; sourceTree = ""; }; D73723742AF5877500846884 /* FindRouteInMobileMapPackageView.Models.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindRouteInMobileMapPackageView.Models.swift; sourceTree = ""; }; D73723782AF5ADD700846884 /* FindRouteInMobileMapPackageView.MobileMapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindRouteInMobileMapPackageView.MobileMapView.swift; sourceTree = ""; }; + D73F06662B5EE73D000B574F /* QueryFeaturesWithArcadeExpressionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QueryFeaturesWithArcadeExpressionView.swift; sourceTree = ""; }; D73F8CF32AB1089900CD39DA /* Restaurant.stylx */ = {isa = PBXFileReference; lastKnownFileType = file; path = Restaurant.stylx; sourceTree = ""; }; D73FC0FC2AD4A18D0067A19B /* CreateMobileGeodatabaseView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateMobileGeodatabaseView.swift; sourceTree = ""; }; D73FCFF42B02A3AA0006360D /* FindAddressWithReverseGeocodeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindAddressWithReverseGeocodeView.swift; sourceTree = ""; }; @@ -650,6 +742,8 @@ D7497F3F2AC4BA4100167AD2 /* Edinburgh_Pylon_Dimensions.mmpk */ = {isa = PBXFileReference; lastKnownFileType = file; path = Edinburgh_Pylon_Dimensions.mmpk; sourceTree = ""; }; D74C8BFD2ABA5605007C76B8 /* StyleSymbolsFromMobileStyleFileView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StyleSymbolsFromMobileStyleFileView.swift; sourceTree = ""; }; D74C8C012ABA6202007C76B8 /* emoji-mobile.stylx */ = {isa = PBXFileReference; lastKnownFileType = file; path = "emoji-mobile.stylx"; sourceTree = ""; }; + D74EA7812B6DADA5008F6C7C /* ValidateUtilityNetworkTopologyView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidateUtilityNetworkTopologyView.swift; sourceTree = ""; }; + D74F03EF2B609A7D00E83688 /* AddFeaturesWithContingentValuesView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddFeaturesWithContingentValuesView.Model.swift; sourceTree = ""; }; D75101802A2E493600B8FA48 /* ShowLabelsOnLayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShowLabelsOnLayerView.swift; sourceTree = ""; }; D751018D2A2E962D00B8FA48 /* IdentifyLayerFeaturesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentifyLayerFeaturesView.swift; sourceTree = ""; }; D752D93F2A39154C003EB25E /* ManageOperationalLayersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManageOperationalLayersView.swift; sourceTree = ""; }; @@ -658,11 +752,20 @@ D75362D12A1E886700D83028 /* ApplyUniqueValueRendererView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplyUniqueValueRendererView.swift; sourceTree = ""; }; D754E3222A1D66820006C5F1 /* StylePointWithPictureMarkerSymbolsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StylePointWithPictureMarkerSymbolsView.swift; sourceTree = ""; }; D7553CD82AE2DFEC00DC2A70 /* GeocodeOfflineView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeocodeOfflineView.swift; sourceTree = ""; }; + D757D14A2B6C46E50065F78F /* ListSpatialReferenceTransformationsView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListSpatialReferenceTransformationsView.Model.swift; sourceTree = ""; }; + D7588F5C2B7D8DAA008B75E2 /* NavigateRouteWithReroutingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigateRouteWithReroutingView.swift; sourceTree = ""; }; D75B58502AAFB3030038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StyleFeaturesWithCustomDictionaryView.swift; sourceTree = ""; }; D75C35662AB50338003CD55F /* GroupLayersTogetherView.GroupLayerListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupLayersTogetherView.GroupLayerListView.swift; sourceTree = ""; }; + D75F66332B48EABC00434974 /* SearchForWebMapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchForWebMapView.swift; sourceTree = ""; }; D76000AB2AF19C2300B3084D /* FindRouteInMobileMapPackageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindRouteInMobileMapPackageView.swift; sourceTree = ""; }; D76000B62AF19FCA00B3084D /* SanFrancisco.mmpk */ = {isa = PBXFileReference; lastKnownFileType = file; path = SanFrancisco.mmpk; sourceTree = ""; }; D7634FAE2A43B7AC00F8AEFB /* CreateConvexHullAroundGeometriesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateConvexHullAroundGeometriesView.swift; sourceTree = ""; }; + D7635FED2B9272CB0044AB97 /* DisplayClustersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayClustersView.swift; sourceTree = ""; }; + D7635FF52B9277DC0044AB97 /* ConfigureClustersView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigureClustersView.Model.swift; sourceTree = ""; }; + D7635FF72B9277DC0044AB97 /* ConfigureClustersView.SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigureClustersView.SettingsView.swift; sourceTree = ""; }; + D7635FF82B9277DC0044AB97 /* ConfigureClustersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigureClustersView.swift; sourceTree = ""; }; + D76495202B74687E0042699E /* ValidateUtilityNetworkTopologyView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidateUtilityNetworkTopologyView.Model.swift; sourceTree = ""; }; + D76929F52B4F78340047205E /* OrbitCameraAroundObjectView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OrbitCameraAroundObjectView.swift; sourceTree = ""; }; D769C2112A29019B00030F61 /* SetUpLocationDrivenGeotriggersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetUpLocationDrivenGeotriggersView.swift; sourceTree = ""; }; D76EE6062AF9AFE100DA0325 /* FindRouteAroundBarriersView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindRouteAroundBarriersView.Model.swift; sourceTree = ""; }; D7705D552AFC244E00CC0335 /* FindClosestFacilityToMultiplePointsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindClosestFacilityToMultiplePointsView.swift; sourceTree = ""; }; @@ -670,16 +773,28 @@ D7749AD52AF08BF50086632F /* FindRouteInTransportNetworkView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindRouteInTransportNetworkView.Model.swift; sourceTree = ""; }; D77570BF2A2942F800F490CD /* AnimateImagesWithImageOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimateImagesWithImageOverlayView.swift; sourceTree = ""; }; D77572AD2A295DDD00F490CD /* PacificSouthWest2 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = PacificSouthWest2; sourceTree = ""; }; + D77688102B69826B007C3860 /* ListSpatialReferenceTransformationsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListSpatialReferenceTransformationsView.swift; sourceTree = ""; }; + D7781D482B7EB03400E53C51 /* SanDiegoTourPath.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = SanDiegoTourPath.json; sourceTree = ""; }; + D7781D4A2B7ECCB700E53C51 /* NavigateRouteWithReroutingView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigateRouteWithReroutingView.Model.swift; sourceTree = ""; }; + D77BC5362B59A2D3007B49B6 /* StylePointWithDistanceCompositeSceneSymbolView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StylePointWithDistanceCompositeSceneSymbolView.swift; sourceTree = ""; }; + D77D9BFF2BB2438200B38A6C /* AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift; sourceTree = ""; }; D78666AC2A2161F100C60110 /* FindNearestVertexView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindNearestVertexView.swift; sourceTree = ""; }; D79EE76D2A4CEA5D005A52AE /* SetUpLocationDrivenGeotriggersView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetUpLocationDrivenGeotriggersView.Model.swift; sourceTree = ""; }; + D7A737DC2BABB9FE00B7C7FC /* AugmentRealityToShowHiddenInfrastructureView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AugmentRealityToShowHiddenInfrastructureView.swift; sourceTree = ""; }; D7ABA2F82A32579C0021822B /* MeasureDistanceInSceneView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MeasureDistanceInSceneView.swift; sourceTree = ""; }; D7ABA2FE2A32881C0021822B /* ShowViewshedFromGeoelementInSceneView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShowViewshedFromGeoelementInSceneView.swift; sourceTree = ""; }; D7AE861D2AC39DC50049B626 /* DisplayAnnotationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayAnnotationView.swift; sourceTree = ""; }; + D7B759B22B1FFBE300017FDD /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = ""; }; + D7BA8C432B2A4DAA00018633 /* Array+RawRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+RawRepresentable.swift"; sourceTree = ""; }; + D7BA8C452B2A8ACA00018633 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; D7C16D1A2AC5F95300689E89 /* Animate3DGraphicView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Animate3DGraphicView.swift; sourceTree = ""; }; D7C16D1E2AC5FE8200689E89 /* Pyrenees.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Pyrenees.csv; sourceTree = ""; }; D7C16D212AC5FE9800689E89 /* GrandCanyon.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = GrandCanyon.csv; sourceTree = ""; }; D7C16D242AC5FEA600689E89 /* Snowdon.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Snowdon.csv; sourceTree = ""; }; D7C16D272AC5FEB600689E89 /* Hawaii.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Hawaii.csv; sourceTree = ""; }; + D7C3AB472B683291008909B9 /* SetFeatureRequestModeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetFeatureRequestModeView.swift; sourceTree = ""; }; + D7C6420B2B4F47E10042B8F7 /* SearchForWebMapView.Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchForWebMapView.Model.swift; sourceTree = ""; }; + D7C97B552B75C10C0097CDA1 /* ValidateUtilityNetworkTopologyView.Views.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidateUtilityNetworkTopologyView.Views.swift; sourceTree = ""; }; D7CC33FD2A31475C00198EDF /* ShowLineOfSightBetweenPointsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShowLineOfSightBetweenPointsView.swift; sourceTree = ""; }; D7CE9F9A2AE2F575008F7A5F /* streetmap_SD.tpkx */ = {isa = PBXFileReference; lastKnownFileType = file; path = streetmap_SD.tpkx; sourceTree = ""; }; D7CE9FA22AE2F595008F7A5F /* san-diego-eagle-locator */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "san-diego-eagle-locator"; sourceTree = ""; }; @@ -692,6 +807,10 @@ D7EAF3592A1C023800D822C4 /* SetMinAndMaxScaleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetMinAndMaxScaleView.swift; sourceTree = ""; }; D7ECF5972AB8BE63003FB2BE /* RenderMultilayerSymbolsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RenderMultilayerSymbolsView.swift; sourceTree = ""; }; D7EF5D742A26A03A00FEBDE5 /* ShowCoordinatesInMultipleFormatsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShowCoordinatesInMultipleFormatsView.swift; sourceTree = ""; }; + D7F8C0362B60564D0072BFA7 /* AddFeaturesWithContingentValuesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddFeaturesWithContingentValuesView.swift; sourceTree = ""; }; + D7F8C03D2B605AF60072BFA7 /* ContingentValuesBirdNests.geodatabase */ = {isa = PBXFileReference; lastKnownFileType = file; path = ContingentValuesBirdNests.geodatabase; sourceTree = ""; }; + D7F8C0402B605E720072BFA7 /* FillmoreTopographicMap.vtpk */ = {isa = PBXFileReference; lastKnownFileType = file; path = FillmoreTopographicMap.vtpk; sourceTree = ""; }; + D7F8C0422B608F120072BFA7 /* AddFeaturesWithContingentValuesView.AddFeatureView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddFeaturesWithContingentValuesView.AddFeatureView.swift; sourceTree = ""; }; E000E75F2869E33D005D87C5 /* ClipGeometryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipGeometryView.swift; sourceTree = ""; }; E000E762286A0B18005D87C5 /* CutGeometryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CutGeometryView.swift; sourceTree = ""; }; E004A6BD28414332002A1FE6 /* SetViewpointRotationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetViewpointRotationView.swift; sourceTree = ""; }; @@ -716,7 +835,6 @@ E070A0A2286F3B6000F2B606 /* DownloadPreplannedMapAreaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadPreplannedMapAreaView.swift; sourceTree = ""; }; E088E1562862579D00413100 /* SetSurfacePlacementModeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetSurfacePlacementModeView.swift; sourceTree = ""; }; E088E1732863B5F800413100 /* GenerateOfflineMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateOfflineMapView.swift; sourceTree = ""; }; - E08953F02891899600E077CF /* EnvironmentValues+SampleInfoVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+SampleInfoVisibility.swift"; sourceTree = ""; }; E0D04FF128A5390000747989 /* DownloadPreplannedMapAreaView.Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadPreplannedMapAreaView.Model.swift; sourceTree = ""; }; E0EA0B762866390E00C9621D /* ProjectGeometryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectGeometryView.swift; sourceTree = ""; }; E0FE32E628747778002C6ACA /* BrowseBuildingFloorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseBuildingFloorsView.swift; sourceTree = ""; }; @@ -737,15 +855,24 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0000FB6D2BBDB17600845921 /* Add 3D tiles layer */ = { + isa = PBXGroup; + children = ( + 0000FB6B2BBDB17600845921 /* Add3DTilesLayerView.swift */, + ); + path = "Add 3D tiles layer"; + sourceTree = ""; + }; 0005580D281872BE00224BC6 /* Views */ = { isa = PBXGroup; children = ( 00B04272282EC59E0072E1B4 /* AboutView.swift */, D70BE5782A5624A80022CA02 /* CategoriesView.swift */, 00E5400D27F3CCA100CF66D5 /* ContentView.swift */, + D7B759B22B1FFBE300017FDD /* FavoritesView.swift */, 000558092817C51E00224BC6 /* SampleDetailView.swift */, E041ABD6287DB04D0056009B /* SampleInfoView.swift */, - 00273CF52A82AB8700A7A77D /* SampleRow.swift */, + 00273CF52A82AB8700A7A77D /* SampleLink.swift */, 00273CF32A82AB5900A7A77D /* SamplesSearchView.swift */, 00C7993A2A845AAF00AFE342 /* Sidebar.swift */, E041ABBF287CA9F00056009B /* WebView.swift */, @@ -753,10 +880,19 @@ path = Views; sourceTree = ""; }; + 000D43152B9918420003D3C2 /* Configure basemap style parameters */ = { + isa = PBXGroup; + children = ( + 000D43132B9918420003D3C2 /* ConfigureBasemapStyleParametersView.swift */, + ); + path = "Configure basemap style parameters"; + sourceTree = ""; + }; 00181B442846AD3900654571 /* Extensions */ = { isa = PBXGroup; children = ( - E08953F02891899600E077CF /* EnvironmentValues+SampleInfoVisibility.swift */, + D7BA8C432B2A4DAA00018633 /* Array+RawRepresentable.swift */, + D7BA8C452B2A8ACA00018633 /* String.swift */, 00181B452846AD7100654571 /* View+ErrorAlert.swift */, E0082216287755AC002AD138 /* View+Sheet.swift */, ); @@ -811,10 +947,11 @@ 0074ABB228174B830037244A /* Samples */ = { isa = PBXGroup; children = ( - 00B56F752B0E966600B68A0D /* Add clustering feature reduction to a point feature layer */, + 0000FB6D2BBDB17600845921 /* Add 3D tiles layer */, 79D84D0C2A815BED00F45262 /* Add custom dynamic entity data source */, 4D2ADC3E29C26D05003B367F /* Add dynamic entity layer */, 00D4EF7E2863840D00B9CC30 /* Add feature layers */, + D7F8C0342B60564D0072BFA7 /* Add features with contingent values */, F19A316128906F0D003B7EF9 /* Add raster from file */, E066DD3E28610F3F004D3D5B /* Add scene layer from service */, D7E557602A1D743100B9FB09 /* Add WMS layer */, @@ -822,7 +959,10 @@ D7C16D172AC5F6C100689E89 /* Animate 3D graphic */, D77570BC2A29427200F490CD /* Animate images with image overlay */, D75362CC2A1E862B00D83028 /* Apply unique value renderer */, + 1C8EC7422BAE2891001A6929 /* Augment reality to collect data */, D7084FA42AD771AA00EC7F4F /* Augment reality to fly over scene */, + 1C2538472BABAC7B00337307 /* Augment reality to navigate route */, + D7A737DF2BABB9FE00B7C7FC /* Augment reality to show hidden infrastructure */, D72F27292ADA1E4400F906DA /* Augment reality to show tabletop scene */, 218F35B229C28F4A00502022 /* Authenticate with OAuth */, E0FE32E528747762002C6ACA /* Browse building floors */, @@ -830,6 +970,8 @@ 4D2ADC5329C4F612003B367F /* Change map view background */, 1C0C1C3229D34DAE005C8B24 /* Change viewpoint */, E000E75E2869E325005D87C5 /* Clip geometry */, + 000D43152B9918420003D3C2 /* Configure basemap style parameters */, + D7635FF32B9277DC0044AB97 /* Configure clusters */, 88F93CBE29C3D4E30006B28E /* Create and edit geometries */, 79B7B8082A1BF8B300F57C27 /* Create and save KML file */, D7E440D12A1ECBC2005D74DE /* Create buffers around points */, @@ -842,13 +984,13 @@ E000E761286A0B07005D87C5 /* Cut geometry */, D71099692A27D8880065A1C1 /* Densify and generalize geometry */, D7AE861A2AC39D750049B626 /* Display annotation */, + D7635FEA2B9272CB0044AB97 /* Display clusters */, 00A7A1422A2FC58300F035F7 /* Display content of utility network container */, D7497F382AC4B45300167AD2 /* Display dimensions */, 0074ABB328174B830037244A /* Display map */, F111CCBD288B548400205358 /* Display map from mobile map package */, D752D95B2A3BCDD4003EB25E /* Display map from portal item */, 00B04FB3283EEB830026C882 /* Display overview map */, - 1C56B5DE2A82C02D000381DA /* Display points using clustering feature reduction */, E004A6D528465C70002A1FE6 /* Display scene */, D7010EBA2B05616900D43F55 /* Display scene from mobile scene package */, D742E48E2B04132B00690098 /* Display web scene from portal item */, @@ -871,18 +1013,25 @@ D70082E72ACF8F6C00E0C3C2 /* Identify KML features */, D751018A2A2E960300B8FA48 /* Identify layer features */, D7464F182ACE0445007FEE88 /* Identify raster cell */, + D776880E2B69826B007C3860 /* List spatial reference transformations */, + D718A1E92B575FD900447087 /* Manage bookmarks */, D752D93C2A3914E5003EB25E /* Manage operational layers */, D7ABA2F52A3256610021822B /* Measure distance in scene */, D752D9422A3A6EB8003EB25E /* Monitor changes to map load status */, 75DD739029D38B1B0010229D /* Navigate route */, + D7588F5B2B7D8DAA008B75E2 /* Navigate route with rerouting */, + D76929F32B4F78340047205E /* Orbit camera around object */, D7232EDD2AC1E5410079ABFF /* Play KML tour */, E0EA0B75286638FD00C9621D /* Project geometry */, 108EC03F29D25AE1000F35D0 /* Query feature table */, + D73F06652B5EE73D000B574F /* Query features with Arcade expression */, D7ECF5942AB8BDCA003FB2BE /* Render multilayer symbols */, 1CAB8D402A3CEAB0002AA649 /* Run valve isolation trace */, + D75F66322B48EABC00434974 /* Search for web map */, 00CB913628481475005C2C5D /* Search with geocode */, E004A6F4284FA3C5002A1FE6 /* Select features in feature layer */, 00B042E3282EDC690072E1B4 /* Set basemap */, + D7C3AB462B683291008909B9 /* Set feature request mode */, D734FA072A183A5A00246D7E /* Set max extent */, D7EAF34F2A1C011000D822C4 /* Set min and max scale */, E088E1552862578800413100 /* Set surface placement mode */, @@ -908,9 +1057,12 @@ D75B584D2AAFB2C20038B3B4 /* Style features with custom dictionary */, E066DD362860AB0B004D3D5B /* Style graphics with renderer */, E004A6E42846A609002A1FE6 /* Style graphics with symbols */, + D77BC5352B59A2D3007B49B6 /* Style point with distance composite scene symbol */, D754E31D2A1D661D0006C5F1 /* Style point with picture marker symbols */, + D7058B0B2B59E44B000A888A /* Style point with scene symbol */, D74C8BFA2ABA5572007C76B8 /* Style symbols from mobile style file */, 7573E81229D6134C00BEED9C /* Trace utility network */, + D74EA7802B6DADA5008F6C7C /* Validate utility network topology */, ); path = Samples; sourceTree = ""; @@ -975,16 +1127,6 @@ path = "Display overview map"; sourceTree = ""; }; - 00B56F752B0E966600B68A0D /* Add clustering feature reduction to a point feature layer */ = { - isa = PBXGroup; - children = ( - 00B56F782B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift */, - 00B56F7A2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift */, - 00B56F7C2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift */, - ); - path = "Add clustering feature reduction to a point feature layer"; - sourceTree = ""; - }; 00C94A0228B53DCC004E42D9 /* 7c4c679ab06a4df19dc497f577f111bd */ = { isa = PBXGroup; children = ( @@ -1005,6 +1147,7 @@ isa = PBXGroup; children = ( D74C8C002ABA6202007C76B8 /* 1bd036f221f54a99abc9e46ff3511cbf */, + D7781D472B7EB03400E53C51 /* 4caec8c55ea2463982f1af7d9611b8d5 */, D7C16D1D2AC5FE8200689E89 /* 5a9b60cee9ba41e79640a06bcdf8084d */, 00C94A0228B53DCC004E42D9 /* 7c4c679ab06a4df19dc497f577f111bd */, D701D7242A37C7E4006FF0C8 /* 07d62a792ab6496d9b772a24efea45d0 */, @@ -1021,11 +1164,13 @@ D721EEA62ABDFF550040BE46 /* 174150279af74a2ba6f8b87a567f480b */, 792222DB2A81AA5D00619FFE /* a8a942c228af4fac96baa78ad60f511f */, D7464F202ACE0910007FEE88 /* b5f977c78ec74b3a8857ca86d1d9b318 */, + D7F8C03F2B605E720072BFA7 /* b5106355f1634b8996e634c04b6a930a */, 00D4EF8128638BF100B9CC30 /* cb1b20748a9f4d128dad8a87244e3e37 */, 4D126D7629CA3B3F00CFB7A7 /* d5bad9f4fee9483791e405880fb466da */, D77572AC2A295DC100F490CD /* d1453556d91e46dea191c20c398b82cd */, D7E7D0862AEB3C36003AAD02 /* df193653ed39449195af0c9725701dca */, F111CCC2288B63DB00205358 /* e1f3a7254cb845b09450f54937c16061 */, + D7F8C03C2B605AF60072BFA7 /* e12b54ea799f4606a2712157cf9f6e41 */, D7C16D262AC5FEB600689E89 /* e87c154fb9c2487f999143df5b08e9b1 */, D7497F3E2AC4BA4100167AD2 /* f5ff6f5556a945bca87ca513b8729a1e */, ); @@ -1124,6 +1269,15 @@ path = "Create load report"; sourceTree = ""; }; + 1C2538472BABAC7B00337307 /* Augment reality to navigate route */ = { + isa = PBXGroup; + children = ( + 1C2538532BABACB100337307 /* AugmentRealityToNavigateRouteView.swift */, + 1C2538522BABACB100337307 /* AugmentRealityToNavigateRouteView.RoutePlannerView.swift */, + ); + path = "Augment reality to navigate route"; + sourceTree = ""; + }; 1C26ED122A859525009B7721 /* Filter features in scene */ = { isa = PBXGroup; children = ( @@ -1159,12 +1313,12 @@ path = "Set visibility of subtype sublayer"; sourceTree = ""; }; - 1C56B5DE2A82C02D000381DA /* Display points using clustering feature reduction */ = { + 1C8EC7422BAE2891001A6929 /* Augment reality to collect data */ = { isa = PBXGroup; children = ( - 1C56B5E22A82C02D000381DA /* DisplayPointsUsingClusteringFeatureReductionView.swift */, + 1C8EC7432BAE2891001A6929 /* AugmentRealityToCollectDataView.swift */, ); - path = "Display points using clustering feature reduction"; + path = "Augment reality to collect data"; sourceTree = ""; }; 1C965C4629DBA879002F8536 /* 681d6f7694644709a7c830ec57a2d72b */ = { @@ -1342,6 +1496,14 @@ path = "Group layers together"; sourceTree = ""; }; + D7058B0B2B59E44B000A888A /* Style point with scene symbol */ = { + isa = PBXGroup; + children = ( + D7058B0D2B59E44B000A888A /* StylePointWithSceneSymbolView.swift */, + ); + path = "Style point with scene symbol"; + sourceTree = ""; + }; D7084FA42AD771AA00EC7F4F /* Augment reality to fly over scene */ = { isa = PBXGroup; children = ( @@ -1359,6 +1521,14 @@ path = "Densify and generalize geometry"; sourceTree = ""; }; + D718A1E92B575FD900447087 /* Manage bookmarks */ = { + isa = PBXGroup; + children = ( + D718A1EA2B575FD900447087 /* ManageBookmarksView.swift */, + ); + path = "Manage bookmarks"; + sourceTree = ""; + }; D71C5F602AAA7854006599FD /* Create symbol styles from web styles */ = { isa = PBXGroup; children = ( @@ -1423,6 +1593,14 @@ path = "Set max extent"; sourceTree = ""; }; + D73F06652B5EE73D000B574F /* Query features with Arcade expression */ = { + isa = PBXGroup; + children = ( + D73F06662B5EE73D000B574F /* QueryFeaturesWithArcadeExpressionView.swift */, + ); + path = "Query features with Arcade expression"; + sourceTree = ""; + }; D73F8CF22AB1089900CD39DA /* 751138a2e0844e06853522d54103222a */ = { isa = PBXGroup; children = ( @@ -1513,6 +1691,16 @@ path = 1bd036f221f54a99abc9e46ff3511cbf; sourceTree = ""; }; + D74EA7802B6DADA5008F6C7C /* Validate utility network topology */ = { + isa = PBXGroup; + children = ( + D74EA7812B6DADA5008F6C7C /* ValidateUtilityNetworkTopologyView.swift */, + D76495202B74687E0042699E /* ValidateUtilityNetworkTopologyView.Model.swift */, + D7C97B552B75C10C0097CDA1 /* ValidateUtilityNetworkTopologyView.Views.swift */, + ); + path = "Validate utility network topology"; + sourceTree = ""; + }; D751017D2A2E490800B8FA48 /* Show labels on layer */ = { isa = PBXGroup; children = ( @@ -1578,6 +1766,15 @@ path = "Geocode offline"; sourceTree = ""; }; + D7588F5B2B7D8DAA008B75E2 /* Navigate route with rerouting */ = { + isa = PBXGroup; + children = ( + D7588F5C2B7D8DAA008B75E2 /* NavigateRouteWithReroutingView.swift */, + D7781D4A2B7ECCB700E53C51 /* NavigateRouteWithReroutingView.Model.swift */, + ); + path = "Navigate route with rerouting"; + sourceTree = ""; + }; D75B584D2AAFB2C20038B3B4 /* Style features with custom dictionary */ = { isa = PBXGroup; children = ( @@ -1586,6 +1783,16 @@ path = "Style features with custom dictionary"; sourceTree = ""; }; + D75F66322B48EABC00434974 /* Search for web map */ = { + isa = PBXGroup; + children = ( + D75F66332B48EABC00434974 /* SearchForWebMapView.swift */, + D7C6420B2B4F47E10042B8F7 /* SearchForWebMapView.Model.swift */, + D71D516D2B51D7B600B2A2BE /* SearchForWebMapView.Views.swift */, + ); + path = "Search for web map"; + sourceTree = ""; + }; D76000AA2AF19C2300B3084D /* Find route in mobile map package */ = { isa = PBXGroup; children = ( @@ -1604,6 +1811,33 @@ path = 260eb6535c824209964cf281766ebe43; sourceTree = ""; }; + D7635FEA2B9272CB0044AB97 /* Display clusters */ = { + isa = PBXGroup; + children = ( + D7635FED2B9272CB0044AB97 /* DisplayClustersView.swift */, + ); + path = "Display clusters"; + sourceTree = ""; + }; + D7635FF32B9277DC0044AB97 /* Configure clusters */ = { + isa = PBXGroup; + children = ( + D7635FF82B9277DC0044AB97 /* ConfigureClustersView.swift */, + D7635FF52B9277DC0044AB97 /* ConfigureClustersView.Model.swift */, + D7635FF72B9277DC0044AB97 /* ConfigureClustersView.SettingsView.swift */, + ); + path = "Configure clusters"; + sourceTree = ""; + }; + D76929F32B4F78340047205E /* Orbit camera around object */ = { + isa = PBXGroup; + children = ( + D76929F52B4F78340047205E /* OrbitCameraAroundObjectView.swift */, + D718A1E62B570F7500447087 /* OrbitCameraAroundObjectView.Model.swift */, + ); + path = "Orbit camera around object"; + sourceTree = ""; + }; D769C20D2A28FF8600030F61 /* Set up location-driven geotriggers */ = { isa = PBXGroup; children = ( @@ -1645,6 +1879,31 @@ path = d1453556d91e46dea191c20c398b82cd; sourceTree = ""; }; + D776880E2B69826B007C3860 /* List spatial reference transformations */ = { + isa = PBXGroup; + children = ( + D77688102B69826B007C3860 /* ListSpatialReferenceTransformationsView.swift */, + D757D14A2B6C46E50065F78F /* ListSpatialReferenceTransformationsView.Model.swift */, + ); + path = "List spatial reference transformations"; + sourceTree = ""; + }; + D7781D472B7EB03400E53C51 /* 4caec8c55ea2463982f1af7d9611b8d5 */ = { + isa = PBXGroup; + children = ( + D7781D482B7EB03400E53C51 /* SanDiegoTourPath.json */, + ); + path = 4caec8c55ea2463982f1af7d9611b8d5; + sourceTree = ""; + }; + D77BC5352B59A2D3007B49B6 /* Style point with distance composite scene symbol */ = { + isa = PBXGroup; + children = ( + D77BC5362B59A2D3007B49B6 /* StylePointWithDistanceCompositeSceneSymbolView.swift */, + ); + path = "Style point with distance composite scene symbol"; + sourceTree = ""; + }; D78666A92A21616D00C60110 /* Find nearest vertex */ = { isa = PBXGroup; children = ( @@ -1653,6 +1912,15 @@ path = "Find nearest vertex"; sourceTree = ""; }; + D7A737DF2BABB9FE00B7C7FC /* Augment reality to show hidden infrastructure */ = { + isa = PBXGroup; + children = ( + D7A737DC2BABB9FE00B7C7FC /* AugmentRealityToShowHiddenInfrastructureView.swift */, + D77D9BFF2BB2438200B38A6C /* AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift */, + ); + path = "Augment reality to show hidden infrastructure"; + sourceTree = ""; + }; D7ABA2F52A3256610021822B /* Measure distance in scene */ = { isa = PBXGroup; children = ( @@ -1727,6 +1995,14 @@ path = e87c154fb9c2487f999143df5b08e9b1; sourceTree = ""; }; + D7C3AB462B683291008909B9 /* Set feature request mode */ = { + isa = PBXGroup; + children = ( + D7C3AB472B683291008909B9 /* SetFeatureRequestModeView.swift */, + ); + path = "Set feature request mode"; + sourceTree = ""; + }; D7CC33FB2A31475C00198EDF /* Show line of sight between points */ = { isa = PBXGroup; children = ( @@ -1826,6 +2102,32 @@ path = "Show coordinates in multiple formats"; sourceTree = ""; }; + D7F8C0342B60564D0072BFA7 /* Add features with contingent values */ = { + isa = PBXGroup; + children = ( + D7F8C0362B60564D0072BFA7 /* AddFeaturesWithContingentValuesView.swift */, + D74F03EF2B609A7D00E83688 /* AddFeaturesWithContingentValuesView.Model.swift */, + D7F8C0422B608F120072BFA7 /* AddFeaturesWithContingentValuesView.AddFeatureView.swift */, + ); + path = "Add features with contingent values"; + sourceTree = ""; + }; + D7F8C03C2B605AF60072BFA7 /* e12b54ea799f4606a2712157cf9f6e41 */ = { + isa = PBXGroup; + children = ( + D7F8C03D2B605AF60072BFA7 /* ContingentValuesBirdNests.geodatabase */, + ); + path = e12b54ea799f4606a2712157cf9f6e41; + sourceTree = ""; + }; + D7F8C03F2B605E720072BFA7 /* b5106355f1634b8996e634c04b6a930a */ = { + isa = PBXGroup; + children = ( + D7F8C0402B605E720072BFA7 /* FillmoreTopographicMap.vtpk */, + ); + path = b5106355f1634b8996e634c04b6a930a; + sourceTree = ""; + }; E000E75E2869E325005D87C5 /* Clip geometry */ = { isa = PBXGroup; children = ( @@ -2063,6 +2365,7 @@ KnownAssetTags = ( AddCustomDynamicEntityDataSource, AddFeatureLayers, + AddFeaturesWithContingentValues, AddRasterFromFile, Animate3DGraphic, AnimateImagesWithImageOverlay, @@ -2075,14 +2378,17 @@ FindRouteInTransportNetwork, GeocodeOffline, IdentifyRasterCell, + NavigateRouteWithRerouting, + OrbitCameraAroundObject, ShowDeviceLocationWithNmeaDataSources, ShowMobileMapPackageExpirationDate, ShowViewshedFromGeoelementInScene, StyleFeaturesWithCustomDictionary, + StylePointWithDistanceCompositeSceneSymbol, StyleSymbolsFromMobileStyleFile, ); LastSwiftUpdateCheck = 1330; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 1530; ORGANIZATIONNAME = Esri; TargetAttributes = { 00E5401227F3CCA200CF66D5 = { @@ -2116,6 +2422,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D7F8C0412B605E720072BFA7 /* FillmoreTopographicMap.vtpk in Resources */, + D7F8C03E2B605AF60072BFA7 /* ContingentValuesBirdNests.geodatabase in Resources */, E041AC1E288076A60056009B /* info.css in Resources */, D7CE9F9B2AE2F575008F7A5F /* streetmap_SD.tpkx in Resources */, D721EEA82ABDFF550040BE46 /* LothianRiversAnno.mmpk in Resources */, @@ -2144,6 +2452,7 @@ F111CCC4288B641900205358 /* Yellowstone.mmpk in Resources */, D77572AE2A295DDE00F490CD /* PacificSouthWest2 in Resources */, 00D4EFB12863CE6300B9CC30 /* ScottishWildlifeTrust_reserves in Resources */, + D7781D492B7EB03400E53C51 /* SanDiegoTourPath.json in Resources */, D7C16D222AC5FE9800689E89 /* GrandCanyon.csv in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2235,53 +2544,65 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1C2538562BABACFD00337307 /* AugmentRealityToNavigateRouteView.swift in Sources */, + 1C2538572BABACFD00337307 /* AugmentRealityToNavigateRouteView.RoutePlannerView.swift in Sources */, + D76929FA2B4F79540047205E /* OrbitCameraAroundObjectView.swift in Sources */, 79D84D132A81711A00F45262 /* AddCustomDynamicEntityDataSourceView.swift in Sources */, E000E7602869E33D005D87C5 /* ClipGeometryView.swift in Sources */, 4D2ADC6729C50BD6003B367F /* AddDynamicEntityLayerView.Model.swift in Sources */, E004A6E928493BCE002A1FE6 /* ShowDeviceLocationView.swift in Sources */, 1C26ED192A859525009B7721 /* FilterFeaturesInSceneView.swift in Sources */, F111CCC1288B5D5600205358 /* DisplayMapFromMobileMapPackageView.swift in Sources */, + D7BA8C462B2A8ACA00018633 /* String.swift in Sources */, + D76495212B74687E0042699E /* ValidateUtilityNetworkTopologyView.Model.swift in Sources */, D7337C5A2ABCFDB100A5D865 /* StyleSymbolsFromMobileStyleFileView.SymbolOptionsListView.swift in Sources */, 1C43BC842A43781200509BF8 /* SetVisibilityOfSubtypeSublayerView.swift in Sources */, 00A7A1462A2FC58300F035F7 /* DisplayContentOfUtilityNetworkContainerView.swift in Sources */, D7553CDB2AE2DFEC00DC2A70 /* GeocodeOfflineView.swift in Sources */, + D757D14B2B6C46E50065F78F /* ListSpatialReferenceTransformationsView.Model.swift in Sources */, 218F35B829C28F4A00502022 /* AuthenticateWithOAuthView.swift in Sources */, 79B7B80A2A1BF8EC00F57C27 /* CreateAndSaveKMLView.swift in Sources */, + D7C6420C2B4F47E10042B8F7 /* SearchForWebMapView.Model.swift in Sources */, + 000D43162B9918420003D3C2 /* ConfigureBasemapStyleParametersView.swift in Sources */, 7573E81C29D6134C00BEED9C /* TraceUtilityNetworkView.Enums.swift in Sources */, 7573E81A29D6134C00BEED9C /* TraceUtilityNetworkView.Model.swift in Sources */, 1C3B7DC82A5F64FC00907443 /* AnalyzeNetworkWithSubnetworkTraceView.Model.swift in Sources */, D752D9402A39154C003EB25E /* ManageOperationalLayersView.swift in Sources */, D7ABA2F92A32579C0021822B /* MeasureDistanceInSceneView.swift in Sources */, D73723792AF5ADD800846884 /* FindRouteInMobileMapPackageView.MobileMapView.swift in Sources */, - 00B56F792B0E967500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.swift in Sources */, E004A6E028466279002A1FE6 /* ShowCalloutView.swift in Sources */, E000E763286A0B18005D87C5 /* CutGeometryView.swift in Sources */, D7705D582AFC244E00CC0335 /* FindClosestFacilityToMultiplePointsView.swift in Sources */, D73FCFFF2B02C7630006360D /* FindRouteAroundBarriersView.Views.swift in Sources */, 4D126D7229CA1E1800CFB7A7 /* FileNMEASentenceReader.swift in Sources */, 001C6DE127FE8A9400D472C2 /* AppSecrets.swift.masque in Sources */, + D77BC5392B59A2D3007B49B6 /* StylePointWithDistanceCompositeSceneSymbolView.swift in Sources */, D7084FA92AD771AA00EC7F4F /* AugmentRealityToFlyOverSceneView.swift in Sources */, D75B58512AAFB3030038B3B4 /* StyleFeaturesWithCustomDictionaryView.swift in Sources */, E0D04FF228A5390000747989 /* DownloadPreplannedMapAreaView.Model.swift in Sources */, 00CCB8A5285BAF8700BBAB70 /* OnDemandResource.swift in Sources */, + D7635FFE2B9277DC0044AB97 /* ConfigureClustersView.swift in Sources */, 1C19B4F32A578E46001D2506 /* CreateLoadReportView.swift in Sources */, 7900C5F62A83FC3F002D430F /* AddCustomDynamicEntityDataSourceView.Vessel.swift in Sources */, D71099702A2802FA0065A1C1 /* DensifyAndGeneralizeGeometryView.SettingsView.swift in Sources */, E004A6ED2849556E002A1FE6 /* CreatePlanarAndGeodeticBuffersView.swift in Sources */, E041ABD7287DB04D0056009B /* SampleInfoView.swift in Sources */, + D7635FFD2B9277DC0044AB97 /* ConfigureClustersView.SettingsView.swift in Sources */, 1C43BC822A43781200509BF8 /* SetVisibilityOfSubtypeSublayerView.Model.swift in Sources */, D71FCB8A2AD6277F000E517C /* CreateMobileGeodatabaseView.Model.swift in Sources */, D752D9462A3A6F80003EB25E /* MonitorChangesToMapLoadStatusView.swift in Sources */, E0082217287755AC002AD138 /* View+Sheet.swift in Sources */, 00181B462846AD7100654571 /* View+ErrorAlert.swift in Sources */, - 00273CF62A82AB8700A7A77D /* SampleRow.swift in Sources */, + 00273CF62A82AB8700A7A77D /* SampleLink.swift in Sources */, D7ABA2FF2A32881C0021822B /* ShowViewshedFromGeoelementInSceneView.swift in Sources */, E0FE32E728747778002C6ACA /* BrowseBuildingFloorsView.swift in Sources */, + D7F8C0432B608F120072BFA7 /* AddFeaturesWithContingentValuesView.AddFeatureView.swift in Sources */, D752D95F2A3BCE06003EB25E /* DisplayMapFromPortalItemView.swift in Sources */, 1CAB8D4B2A3CEAB0002AA649 /* RunValveIsolationTraceView.Model.swift in Sources */, E070A0A3286F3B6000F2B606 /* DownloadPreplannedMapAreaView.swift in Sources */, D77570C02A2942F800F490CD /* AnimateImagesWithImageOverlayView.swift in Sources */, D7054AE92ACCCB6C007235BA /* Animate3DGraphicView.SettingsView.swift in Sources */, + D7BA8C442B2A4DAA00018633 /* Array+RawRepresentable.swift in Sources */, E0EA0B772866390E00C9621D /* ProjectGeometryView.swift in Sources */, D74C8BFE2ABA5605007C76B8 /* StyleSymbolsFromMobileStyleFileView.swift in Sources */, D7E7D0812AEB39D5003AAD02 /* FindRouteInTransportNetworkView.swift in Sources */, @@ -2289,17 +2610,23 @@ 0042E24328E4BF8F001F33D6 /* ShowViewshedFromPointInSceneView.Model.swift in Sources */, D7E557682A1D768800B9FB09 /* AddWMSLayerView.swift in Sources */, D7497F3C2AC4B4C100167AD2 /* DisplayDimensionsView.swift in Sources */, + D7C97B562B75C10C0097CDA1 /* ValidateUtilityNetworkTopologyView.Views.swift in Sources */, D73FCFF72B02A3AA0006360D /* FindAddressWithReverseGeocodeView.swift in Sources */, 0005580A2817C51E00224BC6 /* SampleDetailView.swift in Sources */, + D75F66362B48EABC00434974 /* SearchForWebMapView.swift in Sources */, + D7058B102B59E44B000A888A /* StylePointWithSceneSymbolView.swift in Sources */, + 1C8EC7472BAE2891001A6929 /* AugmentRealityToCollectDataView.swift in Sources */, D75C35672AB50338003CD55F /* GroupLayersTogetherView.GroupLayerListView.swift in Sources */, 4D2ADC6229C5071C003B367F /* ChangeMapViewBackgroundView.Model.swift in Sources */, 0074ABCD2817BCC30037244A /* SamplesApp+Samples.swift.tache in Sources */, D79EE76E2A4CEA5D005A52AE /* SetUpLocationDrivenGeotriggersView.Model.swift in Sources */, + D74F03F02B609A7D00E83688 /* AddFeaturesWithContingentValuesView.Model.swift in Sources */, E004A6F3284E4FEB002A1FE6 /* ShowResultOfSpatialOperationsView.swift in Sources */, D751018E2A2E962D00B8FA48 /* IdentifyLayerFeaturesView.swift in Sources */, F1E71BF1289473760064C33F /* AddRasterFromFileView.swift in Sources */, 00B04273282EC59E0072E1B4 /* AboutView.swift in Sources */, 7573E81F29D6134C00BEED9C /* TraceUtilityNetworkView.swift in Sources */, + D7781D4B2B7ECCB700E53C51 /* NavigateRouteWithReroutingView.Model.swift in Sources */, 4D2ADC6929C50C4C003B367F /* AddDynamicEntityLayerView.SettingsView.swift in Sources */, 1C42E04729D2396B004FC4BE /* ShowPopupView.swift in Sources */, 79302F872A1ED71B0002336A /* CreateAndSaveKMLView.Views.swift in Sources */, @@ -2311,7 +2638,9 @@ D7ECF5982AB8BE63003FB2BE /* RenderMultilayerSymbolsView.swift in Sources */, D769C2122A29019B00030F61 /* SetUpLocationDrivenGeotriggersView.swift in Sources */, 79302F852A1ED4E30002336A /* CreateAndSaveKMLView.Model.swift in Sources */, + D7C3AB4A2B683291008909B9 /* SetFeatureRequestModeView.swift in Sources */, D7058FB12ACB423C00A40F14 /* Animate3DGraphicView.Model.swift in Sources */, + D77D9C002BB2438200B38A6C /* AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift in Sources */, 0044CDDF2995C39E004618CE /* ShowDeviceLocationHistoryView.swift in Sources */, E041ABC0287CA9F00056009B /* WebView.swift in Sources */, D7705D642AFC570700CC0335 /* FindClosestFacilityFromPointView.swift in Sources */, @@ -2319,20 +2648,20 @@ 1CAF831F2A20305F000E1E60 /* ShowUtilityAssociationsView.swift in Sources */, 00C7993B2A845AAF00AFE342 /* Sidebar.swift in Sources */, E004A6C128414332002A1FE6 /* SetViewpointRotationView.swift in Sources */, - 00B56F7B2B0EA71600B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift in Sources */, 883C121529C9136600062FF9 /* DownloadPreplannedMapAreaView.MapPicker.swift in Sources */, D72C43F32AEB066D00B6157B /* GeocodeOfflineView.Model.swift in Sources */, 1C9B74C929DB43580038B06F /* ShowRealisticLightAndShadowsView.swift in Sources */, + D7635FF12B9272CB0044AB97 /* DisplayClustersView.swift in Sources */, D7232EE12AC1E5AA0079ABFF /* PlayKMLTourView.swift in Sources */, - 00B56F7D2B0EA73500B68A0D /* AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift in Sources */, D7010EBF2B05616900D43F55 /* DisplaySceneFromMobileScenePackageView.swift in Sources */, - 1C56B5E62A82C02D000381DA /* DisplayPointsUsingClusteringFeatureReductionView.swift in Sources */, D7337C602ABD142D00A5D865 /* ShowMobileMapPackageExpirationDateView.swift in Sources */, 00E5401E27F3CCA200CF66D5 /* ContentView.swift in Sources */, D7634FAF2A43B7AC00F8AEFB /* CreateConvexHullAroundGeometriesView.swift in Sources */, E066DD382860AB28004D3D5B /* StyleGraphicsWithRendererView.swift in Sources */, 108EC04129D25B2C000F35D0 /* QueryFeatureTableView.swift in Sources */, + D71D516E2B51D7B600B2A2BE /* SearchForWebMapView.Views.swift in Sources */, 00B04FB5283EEBA80026C882 /* DisplayOverviewMapView.swift in Sources */, + D718A1E72B570F7500447087 /* OrbitCameraAroundObjectView.Model.swift in Sources */, D71C5F642AAA7A88006599FD /* CreateSymbolStylesFromWebStylesView.swift in Sources */, D7CC33FF2A31475C00198EDF /* ShowLineOfSightBetweenPointsView.swift in Sources */, D70BE5792A5624A80022CA02 /* CategoriesView.swift in Sources */, @@ -2357,20 +2686,25 @@ D704AA5A2AB22C1A00A3BB63 /* GroupLayersTogetherView.swift in Sources */, E004A6DC28465C70002A1FE6 /* DisplaySceneView.swift in Sources */, E066DD35285CF3B3004D3D5B /* FindRouteView.swift in Sources */, + D7B759B32B1FFBE300017FDD /* FavoritesView.swift in Sources */, D722BD222A420DAD002C2087 /* ShowExtrudedFeaturesView.swift in Sources */, E004A6F6284FA42A002A1FE6 /* SelectFeaturesInFeatureLayerView.swift in Sources */, + D77688132B69826B007C3860 /* ListSpatialReferenceTransformationsView.swift in Sources */, D75101812A2E493600B8FA48 /* ShowLabelsOnLayerView.swift in Sources */, 1C3B7DCB2A5F64FC00907443 /* AnalyzeNetworkWithSubnetworkTraceView.swift in Sources */, - E08953F12891899600E077CF /* EnvironmentValues+SampleInfoVisibility.swift in Sources */, 00B042E8282EDC690072E1B4 /* SetBasemapView.swift in Sources */, E004A6E62846A61F002A1FE6 /* StyleGraphicsWithSymbolsView.swift in Sources */, + 0000FB6E2BBDB17600845921 /* Add3DTilesLayerView.swift in Sources */, + D74EA7842B6DADA5008F6C7C /* ValidateUtilityNetworkTopologyView.swift in Sources */, E088E1742863B5F800413100 /* GenerateOfflineMapView.swift in Sources */, 0074ABC428174F430037244A /* Sample.swift in Sources */, 00A7A14A2A2FC5B700F035F7 /* DisplayContentOfUtilityNetworkContainerView.Model.swift in Sources */, E004A6F0284E4B9B002A1FE6 /* DownloadVectorTilesToLocalCacheView.swift in Sources */, 1CAB8D4E2A3CEAB0002AA649 /* RunValveIsolationTraceView.swift in Sources */, + D7A737E02BABB9FE00B7C7FC /* AugmentRealityToShowHiddenInfrastructureView.swift in Sources */, 4D2ADC4329C26D05003B367F /* AddDynamicEntityLayerView.swift in Sources */, D70082EB2ACF900100E0C3C2 /* IdentifyKMLFeaturesView.swift in Sources */, + D7635FFB2B9277DC0044AB97 /* ConfigureClustersView.Model.swift in Sources */, D7EAF35A2A1C023800D822C4 /* SetMinAndMaxScaleView.swift in Sources */, 1C19B4F52A578E46001D2506 /* CreateLoadReportView.Model.swift in Sources */, 0042E24528E4F82C001F33D6 /* ShowViewshedFromPointInSceneView.ViewshedSettingsView.swift in Sources */, @@ -2381,6 +2715,7 @@ D78666AD2A2161F100C60110 /* FindNearestVertexView.swift in Sources */, D7C16D1B2AC5F95300689E89 /* Animate3DGraphicView.swift in Sources */, D744FD172A2112D90084A66C /* CreateConvexHullAroundPointsView.swift in Sources */, + D718A1ED2B575FD900447087 /* ManageBookmarksView.swift in Sources */, D73723762AF5877500846884 /* FindRouteInMobileMapPackageView.Models.swift in Sources */, 00CB9138284814A4005C2C5D /* SearchWithGeocodeView.swift in Sources */, 1C43BC7F2A43781200509BF8 /* SetVisibilityOfSubtypeSublayerView.Views.swift in Sources */, @@ -2388,9 +2723,12 @@ D731F3C12AD0D2AC00A8431E /* IdentifyGraphicsView.swift in Sources */, 00E5401C27F3CCA200CF66D5 /* SamplesApp.swift in Sources */, E066DD4028610F55004D3D5B /* AddSceneLayerFromServiceView.swift in Sources */, + D7F8C0392B60564D0072BFA7 /* AddFeaturesWithContingentValuesView.swift in Sources */, 00F279D62AF418DC00CECAF8 /* AddDynamicEntityLayerView.VehicleCallout.swift in Sources */, D7749AD62AF08BF50086632F /* FindRouteInTransportNetworkView.Model.swift in Sources */, + D73F06692B5EE73D000B574F /* QueryFeaturesWithArcadeExpressionView.swift in Sources */, D7464F1E2ACE04B3007FEE88 /* IdentifyRasterCellView.swift in Sources */, + D7588F5F2B7D8DAA008B75E2 /* NavigateRouteWithReroutingView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2525,7 +2863,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 200.3.0; + MARKETING_VERSION = 200.4.0; PRODUCT_BUNDLE_IDENTIFIER = "com.esri.arcgis-swift-sdk-samples"; PRODUCT_NAME = Samples; SDKROOT = iphoneos; @@ -2556,7 +2894,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 200.3.0; + MARKETING_VERSION = 200.4.0; PRODUCT_BUNDLE_IDENTIFIER = "com.esri.arcgis-swift-sdk-samples"; PRODUCT_NAME = Samples; SDKROOT = iphoneos; @@ -2599,7 +2937,7 @@ repositoryURL = "https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 200.3.0; + minimumVersion = 200.4.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Samples.xcodeproj/xcshareddata/xcschemes/Samples.xcscheme b/Samples.xcodeproj/xcshareddata/xcschemes/Samples.xcscheme index f16642c6c..a87d28dd9 100644 --- a/Samples.xcodeproj/xcshareddata/xcschemes/Samples.xcscheme +++ b/Samples.xcodeproj/xcshareddata/xcschemes/Samples.xcscheme @@ -1,6 +1,6 @@ str: return json.dumps(data, indent=4, sort_keys=True) + def check_category(self) -> None: + """ + Check if + 1. metadata contains a category. + 2. category is valid. + + :return: None. Throws if exception occurs. + """ + if not self.category: + raise Exception(f'Error category - Missing category.') + elif self.category not in categories: + raise Exception(f'Error category - Invalid category - "{self.category}".') + class Readme: essential_headers = { diff --git a/Scripts/CI/metadata_checker.py b/Scripts/CI/metadata_checker.py index 0c05b6cd0..469ae3ded 100644 --- a/Scripts/CI/metadata_checker.py +++ b/Scripts/CI/metadata_checker.py @@ -78,6 +78,12 @@ def run_check(path: str) -> None: diff = '\n'.join(unified_diff(expected, actual)) raise Exception(f'Error inconsistent metadata - {path} - {diff}') + # 4. Check category. + try: + checker.check_category() + except Exception as err: + raise Exception(f'{checker.folder_path} - {err}') + def all_samples(path: str): """ diff --git a/Scripts/DowloadPortalItemData.swift b/Scripts/DowloadPortalItemData.swift index be37dac32..ecbca4520 100644 --- a/Scripts/DowloadPortalItemData.swift +++ b/Scripts/DowloadPortalItemData.swift @@ -19,6 +19,7 @@ // A mapping of item IDs to filenames is maintained in the download directory. // This mapping efficiently checks whether an item has already been downloaded. // If an item already exists, it will skip that item. +// To delete and re-downloaded an item, remove its entry from the plist. import Foundation @@ -31,7 +32,7 @@ struct SampleDependency: Decodable { } /// A Portal Item and its data URL. -struct PortalItem { +struct PortalItem: Hashable { static let arcGISOnlinePortalURL = URL(string: "https://www.arcgis.com")! /// The identifier of the item. @@ -125,61 +126,47 @@ func uncompressArchive(at sourceURL: URL, to destinationURL: URL) throws { process.waitUntilExit() } -/// Downloads file from portal and write the file(s) to appropriate path(s). +/// Downloads a file from a URL to a given location. /// - Parameters: /// - sourceURL: The portal URL to the resource. -/// - downloadDirectory: The directory that stores downloaded data. -/// - completion: A closure to handle the results. -func downloadFile(at sourceURL: URL, to downloadDirectory: URL, completion: @escaping (Result) -> Void) { - let downloadTaskCompleted = { (temporaryURL: URL?, response: URLResponse?, error: Error?) in - if let temporaryURL = temporaryURL, - let response = response, - let suggestedFilename = response.suggestedFilename { - do { - let downloadName: String - let isArchive = (suggestedFilename as NSString).pathExtension == "zip" - // If the downloaded file is an archive and contains - // - 1 file, use the name of that file. - // - multiple files, use the suggested filename (*.zip). - // If it is not an archive, use the server suggested filename. - if isArchive { - let count = try count(ofFilesInArchiveAt: temporaryURL) - if count == 1 { - downloadName = try name(ofFileInArchiveAt: temporaryURL) - } else { - downloadName = suggestedFilename - } - } else { - downloadName = suggestedFilename - } - - let downloadURL = downloadDirectory.appendingPathComponent(downloadName, isDirectory: false) - - if FileManager.default.fileExists(atPath: downloadURL.path) { - try FileManager.default.removeItem(at: downloadURL) - } - - if isArchive { - let extractURL = downloadURL.pathExtension == "zip" - // Uncompresses to directory named after archive. - ? downloadURL.deletingPathExtension() - // Uncompresses to appropriate subdirectory. - : downloadURL.deletingLastPathComponent() - try uncompressArchive(at: temporaryURL, to: extractURL) - } else { - try FileManager.default.moveItem(at: temporaryURL, to: downloadURL) - } - - completion(.success(downloadURL)) - } catch { - completion(.failure(error)) - } - } else if let error = error { - completion(.failure(error)) +/// - downloadDirectory: The directory to store the downloaded data in. +/// - Throws: Exceptions when downloading, naming, uncompressing, and moving the file. +/// - Returns: The name of the downloaded file. +func downloadFile(from sourceURL: URL, to downloadDirectory: URL) async throws -> String? { + let (temporaryURL, response) = try await URLSession.shared.download(from: sourceURL) + + guard let suggestedFilename = response.suggestedFilename else { return nil } + let isArchive = NSString(string: suggestedFilename).pathExtension == "zip" + + let downloadName: String = try { + // If the downloaded file is an archive and contains + // - 1 file, use the name of that file. + // - multiple files, use the server suggested filename (*.zip). + // If it is not an archive, use the server suggested filename. + if isArchive, + try count(ofFilesInArchiveAt: temporaryURL) == 1 { + return try name(ofFileInArchiveAt: temporaryURL) + } else { + return suggestedFilename } + }() + let downloadURL = downloadDirectory.appendingPathComponent(downloadName, isDirectory: false) + + try? FileManager.default.removeItem(at: downloadURL) + + if isArchive { + let extractURL = downloadURL.pathExtension == "zip" + // Uncompresses to directory named after archive. + ? downloadURL.deletingPathExtension() + // Uncompresses to appropriate subdirectory. + : downloadURL.deletingLastPathComponent() + + try uncompressArchive(at: temporaryURL, to: extractURL) + } else { + try FileManager.default.moveItem(at: temporaryURL, to: downloadURL) } - let downloadTask = URLSession.shared.downloadTask(with: sourceURL, completionHandler: downloadTaskCompleted) - downloadTask.resume() + + return downloadName } // MARK: Script Entry @@ -207,7 +194,7 @@ if !FileManager.default.fileExists(atPath: downloadDirectoryURL.path) { } /// Portal Items created from iterating through all metadata's "offline\_data". -let portalItems: [PortalItem] = { +let portalItems: Set = { do { // Finds all subdirectories under the root Samples directory. let sampleSubDirectories = try FileManager.default @@ -218,7 +205,7 @@ let portalItems: [PortalItem] = { // Omit the decoding errors from samples that don't have dependencies. let sampleDependencies = sampleJSONs .compactMap { try? parseJSON(at: $0) } - return sampleDependencies.flatMap(\.offlineData) + return Set(sampleDependencies.lazy.flatMap(\.offlineData)) } catch { print("error: Error decoding Samples dependencies: \(error.localizedDescription)") exit(1) @@ -241,31 +228,47 @@ let previousDownloadedItems: DownloadedItems = { }() var downloadedItems = previousDownloadedItems -// Asynchronously downloads portal items. -let dispatchGroup = DispatchGroup() - -portalItems.forEach { portalItem in - let destinationURL = downloadDirectoryURL.appendingPathComponent(portalItem.identifier, isDirectory: true) - // Checks if a directory exists or not, to see if an item is already downloaded. - if FileManager.default.fileExists(atPath: destinationURL.path) { - print("info: Item \(portalItem.identifier) has already been downloaded.") - } else { +await withTaskGroup(of: Void.self) { group in + for portalItem in portalItems { + let destinationURL = downloadDirectoryURL.appendingPathComponent( + portalItem.identifier, + isDirectory: true + ) + + // Checks to see if an item needs downloading. + guard downloadedItems[portalItem.identifier] == nil || + !FileManager.default.fileExists(atPath: destinationURL.path) else { + print("note: Item already downloaded: \(portalItem.identifier)") + continue + } + + // Deletes the directory when the item is not in the plist. + try? FileManager.default.removeItem(at: destinationURL) + do { - // Creates an enclosing directory with portal item ID as its name. - try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: false) + // Creates an enclosing directory with the portal item ID as its name. + try FileManager.default.createDirectory( + at: destinationURL, + withIntermediateDirectories: false + ) } catch { - print("error: Error creating download directory: \(error.localizedDescription).") + print("error: Error creating download directory: \(error.localizedDescription)") exit(1) } - print("info: Downloading item \(portalItem.identifier)") - fflush(stdout) - dispatchGroup.enter() - downloadFile(at: portalItem.dataURL, to: destinationURL) { result in - switch result { - case .success(let url): - downloadedItems[portalItem.identifier] = url.lastPathComponent - dispatchGroup.leave() - case .failure(let error): + + group.addTask { + do { + guard let downloadName = try await downloadFile( + from: portalItem.dataURL, + to: destinationURL + ) else { return } + print("note: Downloaded item: \(portalItem.identifier)") + fflush(stdout) + + _ = await MainActor.run { + downloadedItems.updateValue(downloadName, forKey: portalItem.identifier) + } + } catch { print("error: Error downloading item \(portalItem.identifier): \(error.localizedDescription)") URLSession.shared.invalidateAndCancel() exit(1) @@ -273,7 +276,6 @@ portalItems.forEach { portalItem in } } } -dispatchGroup.wait() // Updates the downloaded items property list record if needed. if downloadedItems != previousDownloadedItems { diff --git a/Scripts/GenerateSampleViewSourceCode.swift b/Scripts/GenerateSampleViewSourceCode.swift index e50848860..a1f60a066 100644 --- a/Scripts/GenerateSampleViewSourceCode.swift +++ b/Scripts/GenerateSampleViewSourceCode.swift @@ -143,6 +143,11 @@ private let arrayRepresentation = """ [ \(entries) ] + #if targetEnvironment(macCatalyst) || targetEnvironment(simulator) + // Exclude AR samples from Mac Catalyst and Simulator targets + // as they don't have camera and sensors available. + .filter { $0.category != "Augmented Reality" } + #endif """ do { diff --git a/Shared/Assets.xcassets/Categories/Favorites-bg.imageset/Contents.json b/Shared/Assets.xcassets/Categories/Favorites-bg.imageset/Contents.json new file mode 100644 index 000000000..b29db68b6 --- /dev/null +++ b/Shared/Assets.xcassets/Categories/Favorites-bg.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Favorites-bg@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Favorites-bg@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Assets.xcassets/Categories/Favorites-bg.imageset/Favorites-bg@2x.png b/Shared/Assets.xcassets/Categories/Favorites-bg.imageset/Favorites-bg@2x.png new file mode 100644 index 000000000..ff3a5150f Binary files /dev/null and b/Shared/Assets.xcassets/Categories/Favorites-bg.imageset/Favorites-bg@2x.png differ diff --git a/Shared/Assets.xcassets/Categories/Favorites-bg.imageset/Favorites-bg@3x.png b/Shared/Assets.xcassets/Categories/Favorites-bg.imageset/Favorites-bg@3x.png new file mode 100644 index 000000000..9577d4196 Binary files /dev/null and b/Shared/Assets.xcassets/Categories/Favorites-bg.imageset/Favorites-bg@3x.png differ diff --git a/Shared/Assets.xcassets/Categories/Favorites-icon.imageset/Contents.json b/Shared/Assets.xcassets/Categories/Favorites-icon.imageset/Contents.json new file mode 100644 index 000000000..ab6b5e4a2 --- /dev/null +++ b/Shared/Assets.xcassets/Categories/Favorites-icon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Favorites-icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Shared/Assets.xcassets/Categories/Favorites-icon.imageset/Favorites-icon.svg b/Shared/Assets.xcassets/Categories/Favorites-icon.imageset/Favorites-icon.svg new file mode 100644 index 000000000..6d6ab5e2c --- /dev/null +++ b/Shared/Assets.xcassets/Categories/Favorites-icon.imageset/Favorites-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Shared/Assets.xcassets/StopA.imageset/Contents.json b/Shared/Assets.xcassets/StopA.imageset/Contents.json new file mode 100644 index 000000000..4e822fdd1 --- /dev/null +++ b/Shared/Assets.xcassets/StopA.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stopA36.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stopA72.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stopA108.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Assets.xcassets/StopA.imageset/stopA108.png b/Shared/Assets.xcassets/StopA.imageset/stopA108.png new file mode 100644 index 000000000..cb47f010c Binary files /dev/null and b/Shared/Assets.xcassets/StopA.imageset/stopA108.png differ diff --git a/Shared/Assets.xcassets/StopA.imageset/stopA36.png b/Shared/Assets.xcassets/StopA.imageset/stopA36.png new file mode 100644 index 000000000..52a6feefd Binary files /dev/null and b/Shared/Assets.xcassets/StopA.imageset/stopA36.png differ diff --git a/Shared/Assets.xcassets/StopA.imageset/stopA72.png b/Shared/Assets.xcassets/StopA.imageset/stopA72.png new file mode 100644 index 000000000..b6a3a5378 Binary files /dev/null and b/Shared/Assets.xcassets/StopA.imageset/stopA72.png differ diff --git a/Shared/Assets.xcassets/StopB.imageset/Contents.json b/Shared/Assets.xcassets/StopB.imageset/Contents.json new file mode 100644 index 000000000..3592d3dd0 --- /dev/null +++ b/Shared/Assets.xcassets/StopB.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "StopB36.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "StopB72.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "StopB108.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Assets.xcassets/StopB.imageset/StopB108.png b/Shared/Assets.xcassets/StopB.imageset/StopB108.png new file mode 100644 index 000000000..84ffda99d Binary files /dev/null and b/Shared/Assets.xcassets/StopB.imageset/StopB108.png differ diff --git a/Shared/Assets.xcassets/StopB.imageset/StopB36.png b/Shared/Assets.xcassets/StopB.imageset/StopB36.png new file mode 100644 index 000000000..07ce52b7d Binary files /dev/null and b/Shared/Assets.xcassets/StopB.imageset/StopB36.png differ diff --git a/Shared/Assets.xcassets/StopB.imageset/StopB72.png b/Shared/Assets.xcassets/StopB.imageset/StopB72.png new file mode 100644 index 000000000..f4fb54dec Binary files /dev/null and b/Shared/Assets.xcassets/StopB.imageset/StopB72.png differ diff --git a/Shared/PrivacyInfo.xcprivacy b/Shared/PrivacyInfo.xcprivacy index e08a130bc..5704beda8 100644 --- a/Shared/PrivacyInfo.xcprivacy +++ b/Shared/PrivacyInfo.xcprivacy @@ -9,6 +9,15 @@ NSPrivacyCollectedDataTypes NSPrivacyAccessedAPITypes - + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + diff --git a/Shared/Samples/Add 3D tiles layer/Add3DTilesLayerView.swift b/Shared/Samples/Add 3D tiles layer/Add3DTilesLayerView.swift new file mode 100644 index 000000000..b79a95247 --- /dev/null +++ b/Shared/Samples/Add 3D tiles layer/Add3DTilesLayerView.swift @@ -0,0 +1,67 @@ +// 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 ArcGIS +import SwiftUI + +struct Add3DTilesLayerView: View { + /// A scene with dark gray basemap and an OGC 3D tiles layer. + @State private var scene: ArcGIS.Scene = { + // Creates a scene and sets an initial viewpoint. + let scene = Scene(basemapStyle: .arcGISDarkGray) + let camera = Camera( + latitude: 48.84553, + longitude: 9.16275, + altitude: 350, + heading: 0, + pitch: 75, + roll: 0 + ) + scene.initialViewpoint = Viewpoint(boundingGeometry: camera.location, camera: camera) + + // Creates a surface and adds an elevation source. + let surface = Surface() + surface.addElevationSource(ArcGISTiledElevationSource(url: .worldElevationService)) + + // Sets the surface to the scene's base surface. + scene.baseSurface = surface + + // Creates an OGC 3D tiles layer from a 3D tiles service URL. + let ogc3DTileslayer = OGC3DTilesLayer(url: .stuttgart3DTiles) + + // Adds the layer to the scene's operational layers. + scene.addOperationalLayer(ogc3DTileslayer) + return scene + }() + + var body: some View { + SceneView(scene: scene) + } +} + +private extension URL { + /// The URL of a Stuttgart, Germany city 3D tiles service. + static var stuttgart3DTiles: URL { + URL(string: "https://tiles.arcgis.com/tiles/N82JbI5EYtAkuUKU/arcgis/rest/services/Stuttgart/3DTilesServer/tileset.json")! + } + + /// The URL of the Terrain 3D ArcGIS REST Service. + static var worldElevationService: URL { + URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")! + } +} + +#Preview { + Add3DTilesLayerView() +} diff --git a/Shared/Samples/Add 3D tiles layer/README.md b/Shared/Samples/Add 3D tiles layer/README.md new file mode 100644 index 000000000..082e3cfe5 --- /dev/null +++ b/Shared/Samples/Add 3D tiles layer/README.md @@ -0,0 +1,34 @@ +# Add 3D tiles layer + +Add a layer to visualize 3D tiles data that conforms to the OGC 3D Tiles specification. + +![Image of Add 3D tiles layer sample](add-3d-tiles-layer.png) + +## Use case + +One possible use case is that when added to a scene, a 3D tiles layer can assist in performing visual analysis, such as line of sight analysis. A line of sight analysis can be used to assess whether a view is obstructed between an observer and a target. + +## How to use the sample + +When loaded, the sample will display a scene with an `OGC3DTilesLayer`. Pan around and zoom in to observe the scene of the `Ogc3DTilesLayer`. Notice how the layer's level of detail changes as you zoom in and out from the layer. + +## How it works + +1. Create a scene. +2. Create an `OGC3DTilesLayer` with the URL to a 3D tiles layer service. +3. Add the layer to the scene's operational layers. + +## Relevant API + +* OGC3DTilesLayer +* SceneView + +## About the data + +A layer to visualize 3D tiles data that conforms to the OGC 3D Tiles specification. As of ArcGIS Maps SDK for Swift 200.4, it supports analyses like viewshed and line of sight, but does not support other operations like individual feature identification. + +The 3D Tiles Open Geospatial Consortium (OGC) specification defines a spatial data structure and a set of tile formats designed for streaming and rendering 3D geospatial content. A 3D Tiles data set, known as a tileset, defines one or more tile formats organized into a hierarchical spatial data structure. For more information, see the [OGC 3D Tiles specification](https://www.ogc.org/standard/3DTiles). + +## Tags + +3d tiles, layers, OGC, OGC API, scene, service diff --git a/Shared/Samples/Add 3D tiles layer/README.metadata.json b/Shared/Samples/Add 3D tiles layer/README.metadata.json new file mode 100644 index 000000000..81a3273a8 --- /dev/null +++ b/Shared/Samples/Add 3D tiles layer/README.metadata.json @@ -0,0 +1,27 @@ +{ + "category": "Scenes", + "description": "Add a layer to visualize 3D tiles data that conforms to the OGC 3D Tiles specification.", + "ignore": false, + "images": [ + "add-3d-tiles-layer.png" + ], + "keywords": [ + "3d tiles", + "OGC", + "OGC API", + "layers", + "scene", + "service", + "OGC3DTilesLayer", + "SceneView" + ], + "redirect_from": [], + "relevant_apis": [ + "OGC3DTilesLayer", + "SceneView" + ], + "snippets": [ + "Add3DTilesLayerView.swift" + ], + "title": "Add 3D tiles layer" +} diff --git a/Shared/Samples/Add 3D tiles layer/add-3d-tiles-layer.png b/Shared/Samples/Add 3D tiles layer/add-3d-tiles-layer.png new file mode 100644 index 000000000..67965310f Binary files /dev/null and b/Shared/Samples/Add 3D tiles layer/add-3d-tiles-layer.png differ diff --git a/Shared/Samples/Add WMS layer/AddWMSLayerView.swift b/Shared/Samples/Add WMS layer/AddWMSLayerView.swift index a19605098..c3af0aea2 100644 --- a/Shared/Samples/Add WMS layer/AddWMSLayerView.swift +++ b/Shared/Samples/Add WMS layer/AddWMSLayerView.swift @@ -45,3 +45,7 @@ struct AddWMSLayerView: View { MapView(map: map) } } + +#Preview { + AddWMSLayerView() +} diff --git a/Shared/Samples/Add clustering feature reduction to a point feature layer/AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift b/Shared/Samples/Add clustering feature reduction to a point feature layer/AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift deleted file mode 100644 index 86125cead..000000000 --- a/Shared/Samples/Add clustering feature reduction to a point feature layer/AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2023 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 AddClusteringFeatureReductionToAPointFeatureLayerView { - struct SettingsView: View { - /// The model for the sample. - @ObservedObject var model: Model - - /// The action to dismiss the settings sheet. - @Environment(\.dismiss) private var dismiss: DismissAction - - /// The map view's scale. - let mapViewScale: Double - - /// A format style to display a floating point number's integer part. - private let formatStyle: FloatingPointFormatStyle = .number.precision(.fractionLength(0)) - - /// The maximum scale of feature clusters. - @State private var maxScale = 0.0 - - /// The radius of feature clusters. - @State private var radius = 60.0 - - var body: some View { - Form { - Section("Cluster Labels Visibility") { - Toggle("Show Labels", isOn: $model.showsLabels) - .toggleStyle(.switch) - } - - Section("Clustering Properties") { - VStack { - HStack { - Text("Cluster Radius") - Spacer() - Text(radius, format: formatStyle) - .foregroundColor(.secondary) - } - Slider( - value: $radius, - in: 30...85, - onEditingChanged: { isEditing in - if !isEditing { - model.radius = radius - } - } - ) - } - VStack { - HStack { - Text("Cluster Max Scale") - Spacer() - Text(maxScale, format: formatStyle) - .foregroundColor(.secondary) - } - Slider( - value: $maxScale, - in: 0...150000, - onEditingChanged: { isEditing in - if !isEditing { - model.maxScale = maxScale - } - } - ) - } - HStack { - Text("Current Map Scale") - Spacer() - Text(mapViewScale, format: formatStyle) - .foregroundColor(.secondary) - } - } - } - } - } -} diff --git a/Shared/Samples/Add clustering feature reduction to a point feature layer/add-clustering-feature-reduction-to-a-point-feature-layer.png b/Shared/Samples/Add clustering feature reduction to a point feature layer/add-clustering-feature-reduction-to-a-point-feature-layer.png deleted file mode 100644 index 844faba09..000000000 Binary files a/Shared/Samples/Add clustering feature reduction to a point feature layer/add-clustering-feature-reduction-to-a-point-feature-layer.png and /dev/null differ diff --git a/Shared/Samples/Add dynamic entity layer/AddDynamicEntityLayerView.SettingsView.swift b/Shared/Samples/Add dynamic entity layer/AddDynamicEntityLayerView.SettingsView.swift index cfd591767..ed1fcc786 100644 --- a/Shared/Samples/Add dynamic entity layer/AddDynamicEntityLayerView.SettingsView.swift +++ b/Shared/Samples/Add dynamic entity layer/AddDynamicEntityLayerView.SettingsView.swift @@ -18,7 +18,7 @@ import SwiftUI extension AddDynamicEntityLayerView { struct SettingsView: View { /// The view model for the sample. - @EnvironmentObject private var model: Model + @ObservedObject var model: Model /// The action to dismiss the settings sheet. @Environment(\.dismiss) private var dismiss: DismissAction diff --git a/Shared/Samples/Add dynamic entity layer/AddDynamicEntityLayerView.swift b/Shared/Samples/Add dynamic entity layer/AddDynamicEntityLayerView.swift index f19ef324b..d838bfc93 100644 --- a/Shared/Samples/Add dynamic entity layer/AddDynamicEntityLayerView.swift +++ b/Shared/Samples/Add dynamic entity layer/AddDynamicEntityLayerView.swift @@ -93,8 +93,8 @@ struct AddDynamicEntityLayerView: View { let button = Button("Dynamic Entity Settings") { isShowingSettings = true } - let settingsView = SettingsView(calloutPlacement: $placement) - .environmentObject(model) + let settingsView = SettingsView(model: model, calloutPlacement: $placement) + if #available(iOS 16, *) { button .popover(isPresented: $isShowingSettings, arrowEdge: .bottom) { @@ -114,3 +114,9 @@ struct AddDynamicEntityLayerView: View { } } } + +#Preview { + NavigationView { + AddDynamicEntityLayerView() + } +} diff --git a/Shared/Samples/Add features with contingent values/AddFeaturesWithContingentValuesView.AddFeatureView.swift b/Shared/Samples/Add features with contingent values/AddFeaturesWithContingentValuesView.AddFeatureView.swift new file mode 100644 index 000000000..5f2c88b5a --- /dev/null +++ b/Shared/Samples/Add features with contingent values/AddFeaturesWithContingentValuesView.AddFeatureView.swift @@ -0,0 +1,128 @@ +// 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 ArcGIS +import SwiftUI + +extension AddFeaturesWithContingentValuesView { + /// A view allowing the user to add a feature to the map. + struct AddFeatureView: View { + /// The view model for the sample. + @ObservedObject var model: Model + + /// The name of the selected status. + @State private var selectedStatusName: String? + + /// The coded value options for the feature's status attribute. + @State private var statusOptions: [CodedValue?] = [] + + /// The name of the selected protection. + @State private var selectedProtectionName: String? + + /// The contingent coded value options for the feature's protection attribute. + @State private var protectionOptions: [ContingentCodedValue?] = [] { + didSet { selectedProtectionName = nil } + } + + /// The selected exclusion area buffer size. + @State private var selectedBufferSize: Double? + + /// The range of size options for the feature's buffer size attribute. + @State private var bufferSizeRange: ClosedRange? { + didSet { selectedBufferSize = bufferSizeRange?.lowerBound ?? 0 } + } + + var body: some View { + List { + Section { + Picker("Status", selection: $selectedStatusName) { + ForEach(statusOptions, id: \.?.name) { option in + Text(option?.name ?? "") + } + } + .onChange(of: selectedStatusName) { newStatusName in + // Update the feature's status attribute. + guard let selectedCodedValue = statusOptions.first( + where: { $0?.name == newStatusName } + ) else { return } + + model.setFeatureAttributeValue(selectedCodedValue?.code, forKey: "Status") + + // Update the protection options. + protectionOptions = model.protectionContingentCodedValues() + + // Add nil to allow for an empty option in the picker. + protectionOptions.insert(nil, at: 0) + } + + Picker("Protection", selection: $selectedProtectionName) { + ForEach(protectionOptions, id: \.?.codedValue.name) { option in + Text(option?.codedValue.name ?? "") + } + } + .onChange(of: selectedProtectionName) { newProtectionName in + // Update the feature's protection attribute. + guard let selectedContingentValue = protectionOptions.first( + where: { $0?.codedValue.name == newProtectionName } + ) else { return } + + model.setFeatureAttributeValue( + selectedContingentValue?.codedValue.code, + forKey: "Protection" + ) + + // Update the buffer size range. + bufferSizeRange = model.bufferSizeRange() + } + + VStack { + HStack { + Text("Exclusion Area Buffer Size") + Spacer() + Text("\(Int(selectedBufferSize ?? 0))") + } + + Slider( + value: Binding( + get: { selectedBufferSize ?? 0 }, + set: { selectedBufferSize = $0 } + ), + in: bufferSizeRange ?? 0...0) + .onChange(of: selectedBufferSize ?? .nan) { newBufferSize in + guard newBufferSize.isFinite else { return } + + // Update the feature's buffer size attribute. + model.setFeatureAttributeValue( + Int32(newBufferSize), + forKey: "BufferSize" + ) + } + .disabled(bufferSizeRange == nil) + } + } header: { + Text("Set the attributes") + } footer: { + Text("The options will vary depending on which values are selected.") + } + } + .onAppear { + // Get the status coded values when the view appears. + statusOptions = model.statusCodedValues() + + // Add nil to allow for an empty option in the picker. + statusOptions.insert(nil, at: 0) + } + } + } +} diff --git a/Shared/Samples/Add features with contingent values/AddFeaturesWithContingentValuesView.Model.swift b/Shared/Samples/Add features with contingent values/AddFeaturesWithContingentValuesView.Model.swift new file mode 100644 index 000000000..fc502fc82 --- /dev/null +++ b/Shared/Samples/Add features with contingent values/AddFeaturesWithContingentValuesView.Model.swift @@ -0,0 +1,285 @@ +// 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 ArcGIS +import SwiftUI + +extension AddFeaturesWithContingentValuesView { + /// The view model for the sample. + @MainActor + class Model: ObservableObject { + // MARK: Properties + + /// A map with a topographic vector titled basemap of the Fillmore, CA, USA area. + let map: Map = { + // Create a vector tiled layer using a local URL. + let fillmoreVectorTiledLayer = ArcGISVectorTiledLayer(url: .fillmoreTopographicMap) + + // Create a map using the vector tiled layer as a basemap. + let fillmoreBasemap = Basemap(baseLayer: fillmoreVectorTiledLayer) + let map = Map(basemap: fillmoreBasemap) + + return map + }() + + /// The graphics overlay for the buffer graphics. + let graphicsOverlay: GraphicsOverlay = { + let graphicsOverlay = GraphicsOverlay() + + // Create a simple renderer for the buffer graphics. + let bufferPolygonOutlineSymbol = SimpleLineSymbol(style: .solid, color: .black, width: 2) + let bufferPolygonFillSymbol = SimpleFillSymbol( + style: .forwardDiagonal, + color: .red, + outline: bufferPolygonOutlineSymbol + ) + graphicsOverlay.renderer = SimpleRenderer(symbol: bufferPolygonFillSymbol) + + return graphicsOverlay + }() + + /// A temporary file containing a geodatabase copied from a local URL. + private let geodatabaseFile = GeodatabaseFile(fileURL: .contingentValuesBirdNests) + + /// The feature table containing the features. + private var featureTable: ArcGISFeatureTable? + + /// The feature in the feature table. + private(set) var feature: ArcGISFeature? + + /// A Boolean value indicating whether all the contingency constraints associated with the feature are valid. + @Published private(set) var contingenciesAreValid = false + + // MARK: Methods + + /// Loads the features from the geodatabase. + func loadFeatures() async throws { + // Get the feature table from the geodatabase. + try await geodatabaseFile?.geodatabase.load() + guard let featureTable = geodatabaseFile?.geodatabase.featureTables.first else { return } + self.featureTable = featureTable + + // Load the feature table's contingent values definition. + try await featureTable.contingentValuesDefinition.load() + + // Create a feature layer from the table and add it to the map. + let featureLayer = FeatureLayer(featureTable: featureTable) + map.addOperationalLayer(featureLayer) + + // Create graphics for the features' buffers and add them to the graphics overlay. + let bufferGraphics = try await bufferGraphics(for: featureTable) + graphicsOverlay.addGraphics(bufferGraphics) + } + + /// Adds a feature representing a bird's nest to the map at a given point. + /// - Parameter mapPoint: The point on the map. + func addFeature(at mapPoint: Point) async throws { + // Make a feature using the feature table. + guard let newFeature = featureTable?.makeFeature(geometry: mapPoint) as? ArcGISFeature + else { return } + + // Add the feature to the feature table. + try await featureTable?.add(newFeature) + feature = newFeature + + // Create an initial graphic for the buffer and add it to the graphics overlay. + graphicsOverlay.addGraphic(Graphic()) + } + + /// Removes the added feature from the map. + func removeFeature() async throws { + // Remove the feature from the feature table. + if let feature { + try await featureTable?.delete(feature) + self.feature = nil + } + + // Remove the feature's buffer graphic from the graphics overlay. + if let lastGraphic = graphicsOverlay.graphics.last { + graphicsOverlay.removeGraphic(lastGraphic) + } + + contingenciesAreValid = false + } + + /// Sets an attribute on the feature to a given value. + /// - Parameters: + /// - value: The value. + /// - key: The key associated with the attribute. + func setFeatureAttributeValue(_ value: Any?, forKey key: String) { + guard let featureTable, let feature else { return } + + // Update the feature's attribute. + feature.setAttributeValue(value, forKey: key) + + // Validate the feature's contingencies. + let contingencyViolations = featureTable.validateContingencyConstraints(for: feature) + contingenciesAreValid = contingencyViolations.isEmpty + + // Update the buffer graphic when needed. + guard key == "BufferSize" else { return } + graphicsOverlay.graphics.last?.geometry = bufferPolygon(for: feature) + } + + /// The coded values for the status field from the feature table. + /// - Returns: The coded values. + func statusCodedValues() -> [CodedValue] { + // Get the status field from the feature table. + let statusField = featureTable?.field(named: "Status") + + // Get the domain from the field. + guard let codedValueDomain = statusField?.domain as? CodedValueDomain else { return [] } + + // Get the coded values from the domain. + return codedValueDomain.codedValues + } + + /// The contingent coded values for the feature and the protection field from the feature table. + /// - Returns: The contingent coded values. + func protectionContingentCodedValues() -> [ContingentCodedValue] { + guard let feature else { return [] } + + // Get the contingent values result for the feature and protection field. + let contingentValuesResult = featureTable?.contingentValues( + with: feature, + forFieldNamed: "Protection" + ) + + // Get contingent coded values for the protection field group. + guard let protectionGroupContingentValues = contingentValuesResult? + .contingentValuesByFieldGroup["ProtectionFieldGroup"] as? [ContingentCodedValue] + else { return [] } + + return protectionGroupContingentValues + } + + /// The buffer size range for the feature and buffer size field from the feature table. + /// - Returns: A range made up from the contingent range value's min and max. + func bufferSizeRange() -> ClosedRange? { + guard let feature else { return nil } + + // Get the contingent values result for the feature and buffer size field. + let contingentValuesResult = featureTable?.contingentValues( + with: feature, + forFieldNamed: "BufferSize" + ) + + // Get contingent range values for the buffer size field group. + guard let bufferSizeGroupContingentValues = contingentValuesResult? + .contingentValuesByFieldGroup["BufferSizeFieldGroup"] as? [ContingentRangeValue] + else { return nil } + + // Create a range with min and max value from the contingent range value. + guard let contingentRangeValue = bufferSizeGroupContingentValues.first, + let minValue = contingentRangeValue.minValue as? Int, + let maxValue = contingentRangeValue.maxValue as? Int + else { return nil } + + return Double(minValue)...Double(maxValue) + } + + /// The buffer graphics for the features in a given feature table. + /// - Parameter featureTable: The feature table containing the features. + private func bufferGraphics( + for featureTable: GeodatabaseFeatureTable + ) async throws -> [Graphic] { + // Create the query parameters to filter for buffer sizes greater than 0. + let queryParameters = QueryParameters() + queryParameters.whereClause = "BufferSize > 0" + + // Query the features in the feature table using the query parameters. + let queryResult = try await featureTable.queryFeatures(using: queryParameters) + + // Create graphics for the features in the query result. + let bufferGraphics = queryResult.features().map { feature in + let bufferPolygon = bufferPolygon(for: feature) + return Graphic(geometry: bufferPolygon) + } + + return bufferGraphics + } + + /// A polygon created from a given feature's geometry and buffer size attribute. + /// - Parameter feature: The feature. + /// - Returns: A new `Polygon` object. + private func bufferPolygon(for feature: Feature) -> ArcGIS.Polygon? { + // Get the buffer size from the feature's attributes. + guard let bufferSize = feature.attributes["BufferSize"] as? Int32, + let featureGeometry = feature.geometry + else { return nil } + + // Create a polygon using the feature's geometry and buffer size. + return GeometryEngine.buffer(around: featureGeometry, distance: Double(bufferSize)) + } + } +} + +private extension AddFeaturesWithContingentValuesView.Model { + // MARK: GeodatabaseFile + + /// A temporary file containing a geodatabase copied from a given file URL. + final class GeodatabaseFile { + /// The geodatabase contained in the file. + private(set) var geodatabase: Geodatabase + + init?(fileURL: URL) { + do { + // Create a temporary directory. + let temporaryDirectoryURL = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: fileURL, + create: true + ) + + // Create a temporary URL where the geodatabase URL can be copied to. + let temporaryGeodatabaseURL = temporaryDirectoryURL + .appendingPathComponent("ContingentValuesBirdNests", isDirectory: false) + .appendingPathExtension("geodatabase") + + // Copy the item to the temporary URL. + try FileManager.default.copyItem(at: fileURL, to: temporaryGeodatabaseURL) + + // Create the geodatabase with the URL. + geodatabase = Geodatabase(fileURL: temporaryGeodatabaseURL) + } catch { + return nil + } + } + + deinit { + // Close the geodatabase + geodatabase.close() + + // Remove the temporary file. + try? FileManager.default.removeItem(at: geodatabase.fileURL) + + // Remove the temporary directory. + let temporaryDirectoryURL = geodatabase.fileURL.deletingLastPathComponent() + try? FileManager.default.removeItem(at: temporaryDirectoryURL) + } + } +} + +private extension URL { + /// A URL to the local "Contingent Values Bird Nests" geodatabase. + static var contingentValuesBirdNests: URL { + Bundle.main.url(forResource: "ContingentValuesBirdNests", withExtension: "geodatabase")! + } + + /// A URL to the local "Fillmore Topographic Map" vector tile package. + static var fillmoreTopographicMap: URL { + Bundle.main.url(forResource: "FillmoreTopographicMap", withExtension: "vtpk")! + } +} diff --git a/Shared/Samples/Add features with contingent values/AddFeaturesWithContingentValuesView.swift b/Shared/Samples/Add features with contingent values/AddFeaturesWithContingentValuesView.swift new file mode 100644 index 000000000..985d3cc5f --- /dev/null +++ b/Shared/Samples/Add features with contingent values/AddFeaturesWithContingentValuesView.swift @@ -0,0 +1,126 @@ +// 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 ArcGIS +import SwiftUI + +struct AddFeaturesWithContingentValuesView: View { + /// The view model for the sample. + @StateObject private var model = Model() + + /// The point on the map where the user tapped. + @State private var tapLocation: Point? + + /// A Boolean value indicating whether the add feature sheet is presented. + @State private var addFeatureSheetIsPresented = false + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + ZStack { + GeometryReader { geometryProxy in + MapViewReader { mapViewProxy in + MapView(map: model.map, graphicsOverlays: [model.graphicsOverlay]) + .onSingleTapGesture { _, mapPoint in + tapLocation = mapPoint + } + .task(id: tapLocation) { + // Add a feature representing a bird's nest when the map is tapped. + guard let tapLocation else { return } + + do { + try await model.addFeature(at: tapLocation) + addFeatureSheetIsPresented = true + + // Create an envelope from the screen's frame. + let viewRect = geometryProxy.frame(in: .local) + guard let viewExtent = mapViewProxy.envelope( + fromViewRect: viewRect + ) else { return } + + // Update the map's viewpoint with an offsetted tap location + // to center the feature in the top half of the screen. + let yOffset = (viewExtent.height / 2) / 2 + let newViewpointCenter = Point( + x: tapLocation.x, + y: tapLocation.y - yOffset + ) + await mapViewProxy.setViewpointCenter(newViewpointCenter) + } catch { + self.error = error + } + } + .task { + do { + // Load the features from the geodatabase when the sample loads. + try await model.loadFeatures() + + // Zoom to the extent of the added layer. + guard let extent = model.map.operationalLayers.first?.fullExtent + else { return } + await mapViewProxy.setViewpointGeometry(extent, padding: 15) + } catch { + self.error = error + } + } + } + } + + VStack { + Spacer() + + // A button that allows the popover to display. + Button("") {} + .opacity(0) + .sheet(isPresented: $addFeatureSheetIsPresented, detents: [.medium]) { + NavigationView { + AddFeatureView(model: model) + .navigationTitle("Add Bird Nest") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", role: .cancel) { + addFeatureSheetIsPresented = false + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + addFeatureSheetIsPresented = false + } + .disabled(!model.contingenciesAreValid) + } + } + } + .navigationViewStyle(.stack) + } + .task(id: addFeatureSheetIsPresented) { + // When the sheet closes, remove the feature if it is invalid. + guard !addFeatureSheetIsPresented, + !model.contingenciesAreValid, + model.feature != nil + else { return } + + do { + try await model.removeFeature() + } catch { + self.error = error + } + } + } + } + .errorAlert(presentingError: $error) + } +} diff --git a/Shared/Samples/Add features with contingent values/README.md b/Shared/Samples/Add features with contingent values/README.md new file mode 100644 index 000000000..d3253e1e3 --- /dev/null +++ b/Shared/Samples/Add features with contingent values/README.md @@ -0,0 +1,58 @@ +# Add features with contingent values + +Create and add features whose attribute values satisfy a predefined set of contingencies. + +![Image of add features with contingent values](add-features-with-contingent-values.png) + +## Use case + +Contingent values are a data design feature that allow you to make values in one field dependent on values in another field. Your choice for a value on one field further constrains the domain values that can be placed on another field. In this way, contingent values enforce data integrity by applying additional constraints to reduce the number of valid field inputs. + +For example, a field crew working in a sensitive habitat area may be required to stay a certain distance away from occupied bird nests, but the size of that exclusion area differs depending on the bird's level of protection according to presiding laws. Surveyors can add points of bird nests in the work area and their selection of the size of the exclusion area will be contingent on the values in other attribute fields. + +## How to use the sample + +Tap on the map to add a feature symbolizing a bird's nest. Then choose values describing the nest's status, protection, and buffer size. Notice how different values are available depending on the values of preceding fields. Once the contingent values are validated, tap "Done" to add the feature to the map. + +## How it works + +1. Create and load the `Geodatabase` from the mobile geodatabase location on file. +2. Load the first `GeodatabaseFeatureTable`. +3. Load the `ContingentValuesDefinition` from the feature table. +4. Create a new `FeatureLayer` from the feature table and add it to the map. +5. Create a new `Feature` from the feature table using `makeFeature(attributes:geometry:)`. +6. Get the first field from the feature table by name using `field(named:)`. +7. Then get the `domain` from the field as an `CodedValueDomain`. +8. Get the coded value domain's `codedValues` to get an array of `CodedValue`s. +9. After selecting a value from the initial coded values for the first field, retrieve the remaining valid contingent values for each field as you select the values for the attributes. + i. Get the `ContingentValueResult`s by using `contingentValues(with:field:)` with the feature and the target field by name. + ii. Get an array of valid `ContingentValues` from `contingentValuesByFieldGroup` dictionary with the name of the relevant field group. + iii. Iterate through the array of valid contingent values to create an array of `ContingentCodedValue` names or the minimum and maximum values of a `ContingentRangeValue` depending on the type of `ContingentValue` returned. +10. Validate the feature's contingent values by using `validateContingencyConstraints(for:)` with the current feature. If the resulting array is empty, the selected values are valid. + +## Relevant API + +* ArcGISFeatureTable +* CodedValue +* CodedValueDomain +* ContingencyConstraintViolation +* ContingentCodedValue +* ContingentRangeValue +* ContingentValuesDefinition +* ContingentValuesResult + +## Offline data + +This sample uses the [Contingent values birds nests](https://arcgis.com/home/item.html?id=e12b54ea799f4606a2712157cf9f6e41) mobile geodatabase and the [Fillmore topographic map](https://arcgis.com/home/item.html?id=b5106355f1634b8996e634c04b6a930a) vector tile package for the basemap. + +## About the data + +The mobile geodatabase contains birds nests in the Fillmore area, defined with contingent values. Each feature contains information about its status, protection, and buffer size. + +## Additional information + +Learn more about contingent values and how to utilize them on the [ArcGIS Pro documentation](https://pro.arcgis.com/en/pro-app/latest/help/data/geodatabases/overview/contingent-values.htm). + +## Tags + +coded values, contingent values, feature table, geodatabase diff --git a/Shared/Samples/Add features with contingent values/README.metadata.json b/Shared/Samples/Add features with contingent values/README.metadata.json new file mode 100644 index 000000000..a839c7dbd --- /dev/null +++ b/Shared/Samples/Add features with contingent values/README.metadata.json @@ -0,0 +1,43 @@ +{ + "category": "Edit and Manage Data", + "description": "Create and add features whose attribute values satisfy a predefined set of contingencies.", + "ignore": false, + "images": [ + "add-features-with-contingent-values.png" + ], + "keywords": [ + "coded values", + "contingent values", + "feature table", + "geodatabase", + "ArcGISFeatureTable", + "CodedValue", + "CodedValueDomain", + "ContingencyConstraintViolation", + "ContingentCodedValue", + "ContingentRangeValue", + "ContingentValuesDefinition", + "ContingentValuesResult" + ], + "offline_data": [ + "b5106355f1634b8996e634c04b6a930a", + "e12b54ea799f4606a2712157cf9f6e41" + ], + "redirect_from": [], + "relevant_apis": [ + "ArcGISFeatureTable", + "CodedValue", + "CodedValueDomain", + "ContingencyConstraintViolation", + "ContingentCodedValue", + "ContingentRangeValue", + "ContingentValuesDefinition", + "ContingentValuesResult" + ], + "snippets": [ + "AddFeaturesWithContingentValuesView.swift", + "AddFeaturesWithContingentValuesView.Model.swift", + "AddFeaturesWithContingentValuesView.AddFeatureView.swift" + ], + "title": "Add features with contingent values" +} diff --git a/Shared/Samples/Add features with contingent values/add-features-with-contingent-values.png b/Shared/Samples/Add features with contingent values/add-features-with-contingent-values.png new file mode 100644 index 000000000..574b694c9 Binary files /dev/null and b/Shared/Samples/Add features with contingent values/add-features-with-contingent-values.png differ diff --git a/Shared/Samples/Add scene layer from service/AddSceneLayerFromServiceView.swift b/Shared/Samples/Add scene layer from service/AddSceneLayerFromServiceView.swift index 5ec2873d5..1a20b8ba9 100644 --- a/Shared/Samples/Add scene layer from service/AddSceneLayerFromServiceView.swift +++ b/Shared/Samples/Add scene layer from service/AddSceneLayerFromServiceView.swift @@ -52,3 +52,7 @@ private extension URL { URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")! } } + +#Preview { + AddSceneLayerFromServiceView() +} diff --git a/Shared/Samples/Analyze network with subnetwork trace/AnalyzeNetworkWithSubnetworkTraceView.swift b/Shared/Samples/Analyze network with subnetwork trace/AnalyzeNetworkWithSubnetworkTraceView.swift index f303132e9..0536769e4 100644 --- a/Shared/Samples/Analyze network with subnetwork trace/AnalyzeNetworkWithSubnetworkTraceView.swift +++ b/Shared/Samples/Analyze network with subnetwork trace/AnalyzeNetworkWithSubnetworkTraceView.swift @@ -311,3 +311,9 @@ private extension UtilityNetworkAttributeComparison.Operator { static var allCases: [UtilityNetworkAttributeComparison.Operator] { [.equal, .notEqual, .greaterThan, .greaterThanEqual, .lessThan, .lessThanEqual, .includesTheValues, .doesNotIncludeTheValues, .includesAny, .doesNotIncludeAny] } } + +#Preview { + NavigationView { + AnalyzeNetworkWithSubnetworkTraceView() + } +} diff --git a/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.Model.swift b/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.Model.swift index 3ce8c9a38..5ee0519c4 100644 --- a/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.Model.swift +++ b/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.Model.swift @@ -128,8 +128,15 @@ extension Animate3DGraphicView { @Published private(set) var cameraPropertyTexts: [CameraProperty: String] = [:] init() { - // Set up the mission and the graphics. + // Set up the mission, graphics, and animation. updateMission() + + let displayLink = CADisplayLink(target: self, selector: #selector(updatePositions)) + animation.setup(displayLink: displayLink) + } + + deinit { + Task { await animation.displayLink?.invalidate() } } // MARK: Methods @@ -155,24 +162,6 @@ extension Animate3DGraphicView { } } - /// Starts a new animation by creating a timer used to move the graphics. - func startAnimation() { - // Stop any previous on going animation. - animation.stop() - animation.isPlaying = true - - // Create a new timer to update the graphics' position each iteration. - let interval = 1 / Double(animation.speed) - animation.timer = Timer.scheduledTimer( - timeInterval: interval, - target: self, - selector: #selector(updatePositions), - userInfo: nil, - repeats: true - ) - RunLoop.current.add(animation.timer!, forMode: .common) - } - /// Updates the text associated with a given camera controller property. /// - Parameters: /// - property: The camera controller property associated with the text to update. @@ -223,14 +212,18 @@ extension Animate3DGraphicView { /// A struct containing data for an animation. struct Animation { - /// The timer for the animation used to loop through the animation frames. - var timer: Timer? + /// The timer used to loop through the animation frames. + private(set) var displayLink: CADisplayLink? - /// The speed of the animation used to set the timer's time interval. - var speed = 50.0 + /// The speed of the animation. + var speed = AnimationSpeed.medium - /// A Boolean that indicates whether the animation is currently playing. - var isPlaying = false + /// A Boolean value indicating whether the animation is currently playing. + var isPlaying = false { + didSet { + displayLink?.isPaused = !isPlaying + } + } /// The current frame of the animation. var currentFrame: Frame { @@ -255,26 +248,31 @@ extension Animate3DGraphicView { /// The index of the current frame in the frames list. private var currentFrameIndex = 0 - /// Stops the animation by invalidating the timer. - mutating func stop() { - timer?.invalidate() - isPlaying = false + /// Sets up the animation using a given display link. + /// - Parameter displayLink: The display link used to run the animation. + mutating func setup(displayLink: CADisplayLink) { + // Add the display link to main thread common mode run loop, + // so it is not effected by UI events. + displayLink.add(to: .main, forMode: .common) + displayLink.preferredFramesPerSecond = 60 + self.displayLink = displayLink } /// Resets the animation to the beginning. mutating func reset() { - stop() + isPlaying = false currentFrameIndex = 0 } - /// Increments the animation to the next frame. + /// Increments the animation to the next frame based on the speed. mutating func nextFrame() { - if currentFrameIndex >= framesCount - 1 { + // Increment the frame index using the current speed. + let nextFrameIndex = currentFrameIndex + speed.rawValue + if frames.indices.contains(nextFrameIndex) { + currentFrameIndex = nextFrameIndex + } else { // Reset the animation when it has reached the end. reset() - } else { - // Move the index to point to the next frame. - currentFrameIndex += 1 } } @@ -359,6 +357,13 @@ extension Animate3DGraphicView { } } } + + /// An enumeration representing the speed of the animation. + enum AnimationSpeed: Int, CaseIterable { + case slow = 1 + case medium = 2 + case fast = 4 + } } private extension FormatStyle where Self == FloatingPointFormatStyle { diff --git a/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.swift b/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.swift index ebc2a3e6e..93977ae8f 100644 --- a/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.swift +++ b/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.swift @@ -42,6 +42,7 @@ struct Animate3DGraphicView: View { StatRow("Roll", value: model.animation.currentFrame.roll.formatted(.angle)) } .frame(width: 170, height: 100) + .padding([.leading, .trailing]) .background(.ultraThinMaterial) .cornerRadius(10) .shadow(radius: 3) @@ -59,7 +60,9 @@ struct Animate3DGraphicView: View { .attributionBarHidden(true) .onSingleTapGesture { _, _ in // Show/hide full map on tap. - isShowingFullMap.toggle() + withAnimation(.default.speed(2)) { + isShowingFullMap.toggle() + } } .frame(width: isShowingFullMap ? nil : 100, height: isShowingFullMap ? nil : 100) .cornerRadius(10) @@ -79,12 +82,12 @@ struct Animate3DGraphicView: View { /// The play/pause button for the animation. Button { - model.animation.isPlaying ? model.animation.stop() : model.startAnimation() + model.animation.isPlaying.toggle() } label: { Image(systemName: model.animation.isPlaying ? "pause.fill" : "play.fill") } - Spacer() + SettingsView(label: "Camera") { cameraSettings } @@ -93,15 +96,18 @@ struct Animate3DGraphicView: View { .task { await model.monitorCameraController() } - .onDisappear { - model.animation.stop() - } } /// The list containing the mission settings. private var missionSettings: some View { List { Section("Mission") { + VStack { + StatRow("Progress", value: model.animation.progress.formatted(.rounded)) + ProgressView(value: model.animation.progress) + } + .padding(.vertical) + Picker("Mission Selection", selection: $model.currentMission) { ForEach(Mission.allCases, id: \.self) { mission in Text(mission.label) @@ -111,21 +117,14 @@ struct Animate3DGraphicView: View { .labelsHidden() } - Section { - VStack { - StatRow("Animation Speed", value: model.animation.speed.formatted()) - Slider(value: $model.animation.speed, in: 1...200, step: 1) - .onChange(of: model.animation.speed) { _ in - if model.animation.isPlaying { - model.startAnimation() - } - } - } - VStack { - StatRow("Mission Progress", value: model.animation.progress.formatted(.rounded)) - ProgressView(value: model.animation.progress) - .padding() + Section("Speed") { + Picker("Animation Speed", selection: $model.animation.speed) { + ForEach(AnimationSpeed.allCases, id: \.self) { speed in + Text(String(describing: speed).capitalized) + } } + .pickerStyle(.inline) + .labelsHidden() } } } @@ -138,6 +137,7 @@ struct Animate3DGraphicView: View { VStack { StatRow(property.label, value: model.cameraPropertyTexts[property] ?? "") Slider(value: cameraPropertyBinding(for: property), in: property.range, step: 1) + .padding(.horizontal) } } } @@ -181,7 +181,6 @@ extension Animate3DGraphicView { Spacer() Text(value) } - .padding([.leading, .trailing]) } } } diff --git a/Shared/Samples/Apply unique value renderer/ApplyUniqueValueRendererView.swift b/Shared/Samples/Apply unique value renderer/ApplyUniqueValueRendererView.swift index 22f122dc5..924a1a39c 100644 --- a/Shared/Samples/Apply unique value renderer/ApplyUniqueValueRendererView.swift +++ b/Shared/Samples/Apply unique value renderer/ApplyUniqueValueRendererView.swift @@ -78,3 +78,7 @@ struct ApplyUniqueValueRendererView: View { MapView(map: map) } } + +#Preview { + ApplyUniqueValueRendererView() +} diff --git a/Shared/Samples/Augment reality to collect data/AugmentRealityToCollectDataView.swift b/Shared/Samples/Augment reality to collect data/AugmentRealityToCollectDataView.swift new file mode 100644 index 000000000..0e7df7d79 --- /dev/null +++ b/Shared/Samples/Augment reality to collect data/AugmentRealityToCollectDataView.swift @@ -0,0 +1,204 @@ +// 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 ArcGIS +import ArcGISToolkit +import SwiftUI + +struct AugmentRealityToCollectDataView: View { + /// The view model for this sample. + @StateObject private var model = Model() + /// The status text displayed to the user. + @State private var statusText = "Tap to create a feature" + /// A Boolean value indicating whether a feature can be added . + @State private var canAddFeature = false + /// A Boolean value indicating whether the tree health action sheet is presented. + @State private var treeHealthSheetIsPresented = false + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + VStack(spacing: 0) { + WorldScaleSceneView { _ in + SceneView(scene: model.scene, graphicsOverlays: [model.graphicsOverlay]) + } + .calibrationButtonAlignment(.bottomLeading) + .onCalibratingChanged { newCalibrating in + model.scene.baseSurface.opacity = newCalibrating ? 0.5 : 0 + } + .onSingleTapGesture { _, scenePoint in + model.graphicsOverlay.removeAllGraphics() + canAddFeature = true + + // Add feature graphic. + model.graphicsOverlay.addGraphic(Graphic(geometry: scenePoint)) + statusText = "Placed relative to ARKit plane" + } + .task { + do { + try await model.featureTable.load() + } catch { + self.error = error + } + } + .overlay(alignment: .top) { + Text(statusText) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(8) + .background(.regularMaterial, ignoresSafeAreaEdges: .horizontal) + } + Divider() + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Button { + treeHealthSheetIsPresented = true + } label: { + Image(systemName: "plus") + .imageScale(.large) + } + .disabled(!canAddFeature) + .confirmationDialog( + "Add Tree", + isPresented: $treeHealthSheetIsPresented, + titleVisibility: .visible, + actions: { + ForEach(TreeHealth.allCases, id: \.self) { treeHealth in + Button(treeHealth.label) { + statusText = "Adding feature" + Task { + do { + try await model.addTree(health: treeHealth) + statusText = "Tap to create a feature" + canAddFeature = false + } catch { + self.error = error + } + } + } + } + }, message: { + Text("How healthy is this tree?") + }) + } + } + .errorAlert(presentingError: $error) + } +} + +private extension AugmentRealityToCollectDataView { + @MainActor + class Model: ObservableObject { + /// A scene with an imagery basemap. + @State var scene: ArcGIS.Scene = { + // Creates an elevation source from Terrain3D REST service. + let elevationServiceURL = URL( + string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer" + )! + let elevationSource = ArcGISTiledElevationSource(url: elevationServiceURL) + let surface = Surface() + surface.addElevationSource(elevationSource) + surface.backgroundGrid.isVisible = false + // Allow camera to go beneath the surface. + surface.navigationConstraint = .unconstrained + let scene = Scene(basemapStyle: .arcGISImagery) + scene.baseSurface = surface + scene.baseSurface.opacity = 0 + return scene + }() + /// The AR tree survey service feature table. + let featureTable = ServiceFeatureTable( + url: URL(string: "https://services2.arcgis.com/ZQgQTuoyBrtmoGdP/arcgis/rest/services/AR_Tree_Survey/FeatureServer/0")! + ) + /// The graphics overlay which shows marker symbols. + @State var graphicsOverlay: GraphicsOverlay = { + let graphicsOverlay = GraphicsOverlay() + let tappedPointSymbol = SimpleMarkerSceneSymbol( + style: .diamond, + color: .orange, + height: 0.5, + width: 0.5, + depth: 0.5, + anchorPosition: .center + ) + graphicsOverlay.renderer = SimpleRenderer(symbol: tappedPointSymbol) + graphicsOverlay.sceneProperties.surfacePlacement = .absolute + return graphicsOverlay + }() + /// The selected tree health for the new feature. + @State private var treeHealth: TreeHealth? + + init() { + let featureLayer = FeatureLayer(featureTable: featureTable) + featureLayer.sceneProperties.surfacePlacement = .absolute + scene.addOperationalLayer(featureLayer) + } + + /// Adds a feature to represent a tree to the tree survey service feature table. + /// - Parameter treeHealth: The health of the tree. + func addTree(health: TreeHealth) async throws { + guard let featureGraphic = graphicsOverlay.graphics.first, + let featurePoint = featureGraphic.geometry as? Point else { return } + + // Create attributes for the new feature. + let featureAttributes: [String: Any] = [ + "Health": health.rawValue, + "Height": 3.2, + "Diameter": 1.2 + ] + + if let newFeature = featureTable.makeFeature( + attributes: featureAttributes, + geometry: featurePoint + ) as? ArcGISFeature { + do { + // Add the feature to the feature table. + try await featureTable.add(newFeature) + _ = try await featureTable.applyEdits() + } catch { + throw error + } + newFeature.refresh() + } + + graphicsOverlay.removeAllGraphics() + } + } +} + +private extension AugmentRealityToCollectDataView { + /// The health of a tree. + enum TreeHealth: Int16, CaseIterable, Equatable { + /// The tree is dead. + case dead = 0 + /// The tree is distressed. + case distressed = 5 + /// The tree is healthy. + case healthy = 10 + + /// A human-readable label for each kind of tree health. + var label: String { + switch self { + case .dead: "Dead" + case .distressed: "Distressed" + case .healthy: "Healthy" + } + } + } +} + +#Preview { + AugmentRealityToCollectDataView() +} diff --git a/Shared/Samples/Augment reality to collect data/README.md b/Shared/Samples/Augment reality to collect data/README.md new file mode 100644 index 000000000..6e717303a --- /dev/null +++ b/Shared/Samples/Augment reality to collect data/README.md @@ -0,0 +1,55 @@ +# Augment reality to collect data + +Tap on real-world objects to collect data. + +![Image of augment reality to collect data sample](augment-reality-to-collect-data.png) + +## Use case + +You can use AR to quickly photograph an object and automatically determine the object's real-world location, facilitating a more efficient data collection workflow. For example, you could quickly catalog trees in a park, while maintaining visual context of which trees have been recorded - no need for spray paint or tape. + +## How to use the sample + +Before you start, go through the on-screen calibration process to ensure accurate positioning of recorded features. + +When you tap, an orange diamond will appear at the tapped location. You can move around to visually verify that the tapped point is in the correct physical location. When you're satisfied, tap the '+' button to record the feature. + +## How it works + +1. Create the `WorldScaleSceneView` and add it to the view. +2. Load the feature service and display it with a feature layer. +3. Create and add the elevation surface to the scene. +4. Create a graphics overlay for planning the location of features to add. Configure the graphics overlay with a renderer and add the graphics overlay to the scene view. +5. When the user taps the screen, use `WorldScaleSceneView.onSingleTapGesture(perform:)` to find the real-world location of the tapped object using ARKit plane detection. +6. Add a graphic to the graphics overlay preview where the feature will be placed and allow the user to visually verify the placement. +7. Prompt the user for a tree health value, then create the feature. + +## Relevant API + +* GraphicsOverlay +* SceneView +* Surface +* WorldScaleSceneView + +## About the data + +The sample uses a publicly-editable sample tree survey feature service hosted on ArcGIS Online called [AR Tree Survey](https://arcgisruntime.maps.arcgis.com/home/item.html?id=8feb9ea6a27f48b58b3faf04e0e303ed). You can use AR to quickly record the location and health of a tree. + +## Additional information + +There are two main approaches for identifying the physical location of tapped point: + +* **WorldScaleSceneView.onSingleTapGesture** - uses plane detection provided by ARKit to determine where _in the real world_ the tapped point is. +* **SceneView.onSingleTapGesture** - determines where the tapped point is _in the virtual scene_. This is problematic when the opacity is set to 0 and you can't see where on the scene that is. Real-world objects aren't accounted for by the scene view's calculation to find the tapped location; for example tapping on a tree might result in a point on the basemap many meters away behind the tree. + +This sample only uses the `WorldScaleSceneView.onSingleTapGesture` approach, as it is the only way to get accurate positions for features not directly on the ground in real-scale AR. + +Note that unlike other scene samples, a basemap isn't shown most of the time, because the real world provides the context. Only while calibrating is the basemap displayed at 50% opacity, to give the user a visual reference to compare to. + +**World-scale AR** is one of three main patterns for working with geographic information in augmented reality. Augmented reality is made possible with the ArcGIS Runtime Toolkit. See [Augmented reality](https://developers.arcgis.com/ios/scenes-3d/display-scenes-in-augmented-reality/) in the guide for more information about augmented reality and adding it to your app. + +See the 'Edit feature attachments' sample for more specific information about the attachment editing workflow. + +## Tags + +attachment, augmented reality, capture, collection, collector, data, field, field worker, full-scale, mixed reality, survey, world-scale diff --git a/Shared/Samples/Augment reality to collect data/README.metadata.json b/Shared/Samples/Augment reality to collect data/README.metadata.json new file mode 100644 index 000000000..3764a25f9 --- /dev/null +++ b/Shared/Samples/Augment reality to collect data/README.metadata.json @@ -0,0 +1,37 @@ +{ + "category": "Augmented Reality", + "description": "Tap on real-world objects to collect data.", + "ignore": false, + "images": [ + "augment-reality-to-collect-data.png" + ], + "keywords": [ + "attachment", + "augmented reality", + "capture", + "collection", + "collector", + "data", + "field", + "field worker", + "full-scale", + "mixed reality", + "survey", + "world-scale", + "GraphicsOverlay", + "SceneView", + "Surface", + "WorldScaleSceneView" + ], + "redirect_from": [], + "relevant_apis": [ + "GraphicsOverlay", + "SceneView", + "Surface", + "WorldScaleSceneView" + ], + "snippets": [ + "AugmentRealityToCollectDataView.swift" + ], + "title": "Augment reality to collect data" +} diff --git a/Shared/Samples/Augment reality to collect data/augment-reality-to-collect-data.png b/Shared/Samples/Augment reality to collect data/augment-reality-to-collect-data.png new file mode 100644 index 000000000..e24bd65fa Binary files /dev/null and b/Shared/Samples/Augment reality to collect data/augment-reality-to-collect-data.png differ diff --git a/Shared/Samples/Augment reality to fly over scene/AugmentRealityToFlyOverSceneView.swift b/Shared/Samples/Augment reality to fly over scene/AugmentRealityToFlyOverSceneView.swift index 546289a72..2bb499aa8 100644 --- a/Shared/Samples/Augment reality to fly over scene/AugmentRealityToFlyOverSceneView.swift +++ b/Shared/Samples/Augment reality to fly over scene/AugmentRealityToFlyOverSceneView.swift @@ -58,3 +58,7 @@ private extension URL { .init(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")! } } + +#Preview { + AugmentRealityToFlyOverSceneView() +} diff --git a/Shared/Samples/Augment reality to navigate route/AugmentRealityToNavigateRouteView.RoutePlannerView.swift b/Shared/Samples/Augment reality to navigate route/AugmentRealityToNavigateRouteView.RoutePlannerView.swift new file mode 100644 index 000000000..1329c76b5 --- /dev/null +++ b/Shared/Samples/Augment reality to navigate route/AugmentRealityToNavigateRouteView.RoutePlannerView.swift @@ -0,0 +1,216 @@ +// 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 ArcGIS +import ArcGISToolkit +import CoreLocation +import SwiftUI + +extension AugmentRealityToNavigateRouteView { + @MainActor + struct RoutePlannerView: View { + /// The view model for this sample. + @StateObject private var model = Model() + /// A Boolean value indicating whether the view is showing. + @Binding var isShowing: Bool + /// The status text displayed to the user. + @State private var statusText = "" + /// User defined action to be performed when the slider delta value changes. + var selectRouteAction: ((Graphic, RouteResult) -> Void)? + /// A Boolean value indicating whether a route stop is selected. + var didSelectRouteStop: Bool { + model.startPoint != nil || model.endPoint != nil + } + /// A Boolean value indicating whether a route is selected. + @State private var didSelectRoute = false + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + MapView( + map: model.map, + graphicsOverlays: model.graphicsOverlays + ) + .onSingleTapGesture { _, mapPoint in + if model.startPoint == nil { + model.startPoint = mapPoint + statusText = "Tap to place destination." + } else if model.endPoint == nil { + model.endPoint = mapPoint + model.routeDataModel.routeParameters.setStops(model.makeStops()) + Task { + let routeResult = try await model.routeDataModel.routeTask.solveRoute( + using: model.routeDataModel.routeParameters + ) + if let firstRoute = routeResult.routes.first { + let routeGraphic = Graphic(geometry: firstRoute.geometry) + model.routeGraphicsOverlay.addGraphic(routeGraphic) + model.routeDataModel.routeResult = routeResult + didSelectRoute = true + statusText = "Tap camera to start navigation." + } else { + self.error = error + } + } + } + } + .locationDisplay(model.locationDisplay) + .overlay(alignment: .top) { + Text(statusText) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(8) + .background(.regularMaterial, ignoresSafeAreaEdges: .horizontal) + } + .onChange(of: didSelectRoute) { didSelectRoute in + guard didSelectRoute else { return } + if let onDidSelectRoute = selectRouteAction, + let routeResult = model.routeDataModel.routeResult { + onDidSelectRoute(model.routeGraphic, routeResult) + } + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Spacer() + Button { + isShowing = false + } label: { + Image(systemName: "camera") + .imageScale(.large) + } + .disabled(!didSelectRoute) + Spacer() + Button { + model.reset() + statusText = "Tap to place a start point." + } label: { + Image(systemName: "trash") + .imageScale(.large) + } + .disabled(!didSelectRouteStop && !didSelectRoute) + } + } + .onAppear { + statusText = "Tap to place a start point." + } + .onDisappear { + Task { await model.locationDataSource.stop() } + } + } + + /// Sets an action to perform when the route is selected + /// - Parameter action: The action to perform when the route is selected. + func onDidSelectRoute( + perform action: @escaping (Graphic, RouteResult) -> Void + ) -> RoutePlannerView { + var copy = self + copy.selectRouteAction = action + return copy + } + } +} + +private extension AugmentRealityToNavigateRouteView.RoutePlannerView { + /// A view model for this example. + @MainActor + class Model: ObservableObject { + /// The data model for the selected route. + @ObservedObject var routeDataModel = AugmentRealityToNavigateRouteView.RouteDataModel() + /// A map with an imagery basemap style. + let map = Map(basemapStyle: .arcGISImagery) + /// The data source to track device location and provide updates to route tracker. + let locationDataSource = SystemLocationDataSource() + /// The graphic (with solid yellow 3D tube symbol) to represent the route. + let routeGraphic = Graphic() + /// The map's location display. + let locationDisplay: LocationDisplay = { + let locationDisplay = LocationDisplay() + locationDisplay.autoPanMode = .recenter + return locationDisplay + }() + /// The graphics overlay for the stops. + let stopGraphicsOverlay = GraphicsOverlay() + /// A graphic overlay for route graphics. + let routeGraphicsOverlay: GraphicsOverlay = { + let overlay = GraphicsOverlay() + overlay.renderer = SimpleRenderer( + symbol: SimpleLineSymbol(style: .solid, color: .yellow, width: 5) + ) + return overlay + }() + /// The map's graphics overlays. + var graphicsOverlays: [GraphicsOverlay] { + return [stopGraphicsOverlay, routeGraphicsOverlay] + } + /// A point representing the start of navigation. + var startPoint: Point? { + didSet { + let stopSymbol = PictureMarkerSymbol(image: UIImage(named: "StopA")!) + let startStopGraphic = Graphic(geometry: startPoint, symbol: stopSymbol) + stopGraphicsOverlay.addGraphic(startStopGraphic) + } + } + /// A point representing the destination of navigation. + var endPoint: Point? { + didSet { + let stopSymbol = PictureMarkerSymbol(image: UIImage(named: "StopB")!) + let endStopGraphic = Graphic(geometry: endPoint, symbol: stopSymbol) + stopGraphicsOverlay.addGraphic(endStopGraphic) + } + } + + init() { + // Request when-in-use location authorization. + let locationManager = CLLocationManager() + if locationManager.authorizationStatus == .notDetermined { + locationManager.requestWhenInUseAuthorization() + } + + locationDisplay.dataSource = locationDataSource + + Task { + try await locationDataSource.start() + + let parameters = try await routeDataModel.routeTask.makeDefaultParameters() + + if let walkMode = routeDataModel.routeTask.info.travelModes.first(where: { $0.name.contains("Walking") }) { + parameters.travelMode = walkMode + parameters.returnsStops = true + parameters.returnsDirections = true + parameters.returnsRoutes = true + routeDataModel.routeParameters = parameters + } + } + } + + /// Creates the start and destination stops for the navigation. + func makeStops() -> [Stop] { + guard let startPoint, let endPoint else { return [] } + let stop1 = Stop(point: startPoint) + stop1.name = "Start" + let stop2 = Stop(point: endPoint) + stop2.name = "Destination" + return [stop1, stop2] + } + + /// Resets the start and destination stops for the navigation. + func reset() { + routeGraphicsOverlay.removeAllGraphics() + stopGraphicsOverlay.removeAllGraphics() + routeDataModel.routeParameters.clearStops() + startPoint = nil + endPoint = nil + } + } +} diff --git a/Shared/Samples/Augment reality to navigate route/AugmentRealityToNavigateRouteView.swift b/Shared/Samples/Augment reality to navigate route/AugmentRealityToNavigateRouteView.swift new file mode 100644 index 000000000..732983998 --- /dev/null +++ b/Shared/Samples/Augment reality to navigate route/AugmentRealityToNavigateRouteView.swift @@ -0,0 +1,255 @@ +// 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 ArcGIS +import ArcGISToolkit +import AVFoundation +import SwiftUI + +struct AugmentRealityToNavigateRouteView: View { + /// The data model for the selected route. + @StateObject private var routeDataModel = RouteDataModel() + /// A Boolean value indicating whether the route planner is showing. + @State private var isShowingRoutePlanner = true + /// The location datasource that is used to access the device location. + @State private var locationDataSource = SystemLocationDataSource() + /// A scene with an imagery basemap. + @State private var scene = Scene(basemapStyle: .arcGISImagery) + /// The elevation surface set to the base surface of the scene. + @State private var elevationSurface: Surface = { + let elevationSurface = Surface() + elevationSurface.navigationConstraint = .unconstrained + elevationSurface.opacity = 0 + elevationSurface.backgroundGrid.isVisible = false + return elevationSurface + }() + /// The elevation source with elevation service URL. + @State private var elevationSource = ArcGISTiledElevationSource(url: URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")!) + /// The graphics overlay containing a graphic. + @State private var graphicsOverlay = GraphicsOverlay() + /// The status text displayed to the user. + @State private var statusText = "Adjust calibration before starting." + /// A Boolean value indicating whether the use is navigatig the route. + @State private var isNavigating = false + /// The result of the route selected in the route planner view. + @State private var routeResult: RouteResult? + + init() { + elevationSurface.addElevationSource(elevationSource) + scene.baseSurface = elevationSurface + } + + var body: some View { + if isShowingRoutePlanner { + RoutePlannerView(isShowing: $isShowingRoutePlanner) + .onDidSelectRoute { routeGraphic, routeResult in + self.routeResult = routeResult + graphicsOverlay = makeRouteOverlay( + routeResult: routeResult, + routeGraphic: routeGraphic + ) + } + .task { + try? await elevationSource.load() + } + } else { + VStack(spacing: 0) { + WorldScaleSceneView { _ in + SceneView(scene: scene, graphicsOverlays: [graphicsOverlay]) + } + .calibrationButtonAlignment(.bottomLeading) + .onCalibratingChanged { isPresented in + scene.baseSurface.opacity = isPresented ? 0.6 : 0 + } + .task { + try? await locationDataSource.start() + + for await location in locationDataSource.locations { + try? await routeDataModel.routeTracker?.track(location) + } + } + .overlay(alignment: .top) { + Text(statusText) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(8) + .background(.regularMaterial, ignoresSafeAreaEdges: .horizontal) + } + .onDisappear { + Task { await locationDataSource.stop() } + } + Divider() + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Button("Start") { + isNavigating = true + Task { + do { + try await startNavigation() + } catch { + print("Failed to start navigation.") + } + } + } + .disabled(isNavigating) + } + } + } + } + + /// Creates a graphics overlay and adds a graphic (with solid yellow 3D tube symbol) + /// to represent the route. + private func makeRouteOverlay(routeResult: RouteResult, routeGraphic: Graphic) -> GraphicsOverlay { + let graphicsOverlay = GraphicsOverlay() + graphicsOverlay.sceneProperties.surfacePlacement = .absolute + let strokeSymbolLayer = SolidStrokeSymbolLayer( + width: 1.0, + color: .yellow, + lineStyle3D: .tube + ) + let polylineSymbol = MultilayerPolylineSymbol(symbolLayers: [strokeSymbolLayer]) + let polylineRenderer = SimpleRenderer(symbol: polylineSymbol) + graphicsOverlay.renderer = polylineRenderer + + if let originalPolyline = routeResult.routes.first?.geometry { + addingElevation(3, to: originalPolyline) { polyline in + routeGraphic.geometry = polyline + graphicsOverlay.addGraphic(routeGraphic) + } + } + + return graphicsOverlay + } + + /// Densify the polyline geometry so the elevation can be adjusted every 0.3 meters, + /// and add an elevation to the geometry. + /// + /// - Parameters: + /// - z: A `Double` value representing z elevation. + /// - polyline: The polyline geometry of the route. + /// - completion: A completion closure to execute after the polyline is generated with success or not. + private func addingElevation( + _ z: Double, + to polyline: Polyline, + completion: @escaping (Polyline) -> Void + ) { + if let densifiedPolyline = GeometryEngine.densify(polyline, maxSegmentLength: 0.3) as? Polyline { + let polylineBuilder = PolylineBuilder(spatialReference: densifiedPolyline.spatialReference) + Task { + for part in densifiedPolyline.parts { + for point in part.points { + async let elevation = try await elevationSurface.elevation(at: point) + let newPoint = await GeometryEngine.makeGeometry(from: point, z: try elevation + z) + // Put the new point 3 meters above the ground elevation. + polylineBuilder.add(newPoint) + } + } + completion(polylineBuilder.toGeometry()) + } + } else { + completion(polyline) + } + } + + /// Starts navigating the route. + private func startNavigation() async throws { + guard let routeResult else { return } + let routeTracker = RouteTracker( + routeResult: routeResult, + routeIndex: 0, + skipsCoincidentStops: true + ) + guard let routeTracker else { return } + + routeTracker.voiceGuidanceUnitSystem = Locale.current.usesMetricSystem ? .metric : .imperial + + routeDataModel.routeTracker = routeTracker + + do { + try await routeDataModel.routeTask.load() + } catch { + throw error + } + + if routeDataModel.routeTask.info.supportsRerouting, + let reroutingParameters = ReroutingParameters( + routeTask: routeDataModel.routeTask, + routeParameters: routeDataModel.routeParameters + ) { + do { + try await routeTracker.enableRerouting(using: reroutingParameters) + } catch { + throw error + } + } + + statusText = "Navigation will start." + await startTracking() + } + + /// Starts monitoring multiple asynchronous streams of information. + private func startTracking() async { + await withTaskGroup(of: Void.self) { group in + group.addTask { await trackStatus() } + group.addTask { await routeDataModel.trackVoiceGuidance() } + } + } + + /// Monitors the asynchronous stream of tracking statuses. + /// + /// When new statuses are delivered, update the route's traversed and remaining graphics. + private func trackStatus() async { + guard let routeTracker = routeDataModel.routeTracker else { return } + for await status in routeTracker.$trackingStatus { + guard let status else { continue } + switch status.destinationStatus { + case .notReached, .approaching: + if let route = routeResult?.routes.first { + let currentManeuver = route.directionManeuvers[status.currentManeuverIndex] + statusText = currentManeuver.text + } + case .reached: + statusText = "You have arrived!" + @unknown default: + break + } + } + } +} + +extension AugmentRealityToNavigateRouteView { + @MainActor + class RouteDataModel: ObservableObject { + /// An AVSpeechSynthesizer for text to speech. + let speechSynthesizer = AVSpeechSynthesizer() + /// The route task that solves the route using the online routing service, using API key authentication. + let routeTask = RouteTask(url: URL(string: "https://route-api.arcgis.com/arcgis/rest/services/World/Route/NAServer/Route_World")!) + /// The parameters for route task to solve a route. + var routeParameters = RouteParameters() + /// The route tracker. + @Published var routeTracker: RouteTracker? + /// The route result. + @Published var routeResult: RouteResult? + + /// Monitors the asynchronous stream of voice guidances. + func trackVoiceGuidance() async { + guard let routeTracker = routeTracker else { return } + for try await voiceGuidance in routeTracker.voiceGuidances { + speechSynthesizer.stopSpeaking(at: .word) + speechSynthesizer.speak(AVSpeechUtterance(string: voiceGuidance.text)) + } + } + } +} diff --git a/Shared/Samples/Augment reality to navigate route/README.md b/Shared/Samples/Augment reality to navigate route/README.md new file mode 100644 index 000000000..6b9562e5d --- /dev/null +++ b/Shared/Samples/Augment reality to navigate route/README.md @@ -0,0 +1,66 @@ +# Augment reality to navigate route + +Use a route displayed in the real world to navigate. + +![Image of augment reality to navigate route sample](augment-reality-to-navigate-route.png) + +## Use case + +It can be hard to navigate using 2D maps in unfamiliar environments. You can use full-scale AR to show a route overlaid on the real-world for easier navigation. + +## How to use the sample + +The sample opens with a map centered on the current location. Tap the map to add an origin and a destination; the route will be shown as a line. + +When ready, tap the camera button to start the AR navigation. Calibrate the heading before starting to navigate. + +When you start, route instructions will be displayed and spoken. As you proceed through the route, new directions will be provided until you arrive. + +## How it works + +1. The map page is used to plan the route before starting the AR experience. See *Navigate route*, *Find route*, and *Offline routing* samples for a more focused demonstration of that workflow. +2. Pass the resulting `RouteResult` and the input `RouteTask` and `RouteParameters` to the view used for the AR portion of the navigation experience. + * The route task and parameters are used to support a rerouting capability where routes are recalculated on-the-fly if you deviate. Due to service limitations, this sample doesn't support on-the-fly rerouting. You can incorporate offline routing to support rerouting in your app. +3. Start ARKit/ARCore tracking with continuous location updates when the AR view is shown. +4. Get the route geometry from the first route in the `RouteResult`. Use the scene's base surface to apply elevation to the line so that it will follow the terrain. + * First, densify the polyline to ensure that the elevation adjustment can be applied smoothly along the line with `GeometryEngine.densify(_:maxSegmentLength:)` + * Next, create a polyline builder with a spatial reference matching the input route geometry + * Get a list of all points in the polyline by iterating through parts and points along each part + * For each point in the polyline, use `surface.elevation(for: point)` to get the elevation for that point. Then create a new point with the *x* and *y* of the input and *z* as the returned elevation value. This sample adds 3 meters to that value so that the route line is visible above the road. Add the new point to the polyline builder with `builder.add(newPoint)` + * Once all points have been given an elevation and added to the polyline builder, call `toGeometry()` on the polyline builder to get the elevation-adjusted route line. +5. Add the route geometry to a graphics overlay and add a renderer to the graphics overlay. This sample uses a `MultilayerPolylineSymbol` with a `SolidStrokeSymbolLayer` to visualize a tube along the route line. +6. The `WorldScaleSceneView` has a calibration view that uses sliders to manipulate the heading (direction you are facing) and elevation. Because of limitations in on-device compasses, calibration is often necessary; small errors in heading cause big problems with the placement of scene content in the world. + * The calibration view slider in the sample implements a 'joystick' interaction; the heading is adjusted faster the further you move from the center of the slider. +7. When the user starts navigating, create a `RouteTracker`, providing a `RouteResult` and the index of the route you want to use; this sample always picks the first returned result. +8. Create a location data source and listen for location change events. When the location changes, call `track(_:)` on the route tracker with the updated location. +9. Keep the calibration view accessible throughout the navigation experience. As the user walks, small heading errors may become more noticeable and require recalibration. + +## Relevant API + +* GeometryEngine +* LocationDataSource +* RouteResult +* RouteTask +* RouteTracker +* Surface +* WorldScaleSceneView + +## About the data + +This sample uses Esri's [world elevation service](https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer) to ensure that route lines are placed appropriately in 3D space. It uses Esri's [world routing service](https://www.arcgis.com/home/item.html?id=1feb41652c5c4bd2ba5c60df2b4ea2c4) to calculate routes. The world routing service requires authentication and does consume ArcGIS Online credits. + +## Additional information + +This sample requires a device that is compatible with ARKit 1 on iOS. + +Unlike other scene samples, there's no need for a basemap while navigating, because context is provided by the camera feed showing the real environment. The base surface's opacity is set to zero to prevent it from interfering with the AR experience. + +A digital elevation model is used to ensure that the displayed route is positioned appropriately relative to the terrain of the route. If you don't want to display the route line floating, you could show the line draped on the surface instead. + +**World-scale AR** is one of three main patterns for working with geographic information in augmented reality. Augmented reality is made possible with the ArcGIS Runtime Toolkit. See [Augmented reality](https://developers.arcgis.com/ios/scenes-3d/display-scenes-in-augmented-reality/) in the guide for more information about augmented reality and adding it to your app. + +Because most navigation scenarios involve traveling beyond the accurate range for ARKit/ARCore positioning, this sample relies on **continuous location updates** from the location data source. Because the origin camera is constantly being reset by the location data source, the sample doesn't allow the user to pan to calibrate or adjust the altitude with a slider. The location data source doesn't provide a heading, so it isn't overwritten when the location refreshes. + +## Tags + +augmented reality, directions, full-scale, guidance, mixed reality, navigate, navigation, real-scale, route, routing, world-scale diff --git a/Shared/Samples/Augment reality to navigate route/README.metadata.json b/Shared/Samples/Augment reality to navigate route/README.metadata.json new file mode 100644 index 000000000..f478cfac0 --- /dev/null +++ b/Shared/Samples/Augment reality to navigate route/README.metadata.json @@ -0,0 +1,43 @@ +{ + "category": "Augmented Reality", + "description": "Use a route displayed in the real world to navigate.", + "ignore": false, + "images": [ + "augment-reality-to-navigate-route.png" + ], + "keywords": [ + "augmented reality", + "directions", + "full-scale", + "guidance", + "mixed reality", + "navigate", + "navigation", + "real-scale", + "route", + "routing", + "world-scale", + "GeometryEngine", + "LocationDataSource", + "RouteResult", + "RouteTask", + "RouteTracker", + "Surface", + "WorldScaleSceneView" + ], + "redirect_from": [], + "relevant_apis": [ + "GeometryEngine", + "LocationDataSource", + "RouteResult", + "RouteTask", + "RouteTracker", + "Surface", + "WorldScaleSceneView" + ], + "snippets": [ + "AugmentRealityToNavigateRouteView.swift", + "AugmentRealityToNavigateRouteView.RoutePlannerView.swift" + ], + "title": "Augment reality to navigate route" +} diff --git a/Shared/Samples/Augment reality to navigate route/augment-reality-to-navigate-route.png b/Shared/Samples/Augment reality to navigate route/augment-reality-to-navigate-route.png new file mode 100644 index 000000000..669a3f174 Binary files /dev/null and b/Shared/Samples/Augment reality to navigate route/augment-reality-to-navigate-route.png differ diff --git a/Shared/Samples/Augment reality to show hidden infrastructure/AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift b/Shared/Samples/Augment reality to show hidden infrastructure/AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift new file mode 100644 index 000000000..a793b3427 --- /dev/null +++ b/Shared/Samples/Augment reality to show hidden infrastructure/AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift @@ -0,0 +1,181 @@ +// 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 ArcGIS +import ArcGISToolkit +import SwiftUI + +extension AugmentRealityToShowHiddenInfrastructureView { + /// A world scale scene view displaying pipe graphics from a given model. + struct ARPipesSceneView: View { + /// The view model for scene view in the sample. + @ObservedObject var model: SceneModel + + /// A Boolean value indicating whether the shadow graphics are visible. + @State private var shadowsAreVisible = true + + /// A Boolean value indicating whether the leader line graphics are visible. + @State private var leadersAreVisible = true + + var body: some View { + VStack(spacing: 0) { + WorldScaleSceneView { _ in + SceneView(scene: model.scene, graphicsOverlays: [ + model.pipeGraphicsOverlay, + model.shadowGraphicsOverlay, + model.leaderGraphicsOverlay + ]) + } + .calibrationButtonAlignment(.bottomLeading) + .onCalibratingChanged { newCalibrating in + model.scene.baseSurface.opacity = newCalibrating ? 0.6 : 0 + } + + Divider() + } + .toolbar { + ToolbarItem(placement: .bottomBar) { + settingsMenu + } + } + } + + /// The settings menu. + private var settingsMenu: some View { + Menu("Settings") { + Toggle("Shadows", isOn: $shadowsAreVisible) + .onChange(of: shadowsAreVisible) { newValue in + model.shadowGraphicsOverlay.isVisible = newValue + } + Toggle("Leaders", isOn: $leadersAreVisible) + .onChange(of: leadersAreVisible) { newValue in + model.leaderGraphicsOverlay.isVisible = newValue + } + } + } + } +} + +extension AugmentRealityToShowHiddenInfrastructureView { + // MARK: Scene Model + + /// The view model for scene view in the sample. + class SceneModel: ObservableObject { + /// A scene with an imagery basemap style and an elevation surface. + let scene: ArcGIS.Scene = { + let scene = Scene(basemapStyle: .arcGISImageryStandard) + + // Create a surface with an elevation source and set it to the scene's base surface. + let surface = Surface() + surface.navigationConstraint = .unconstrained + surface.opacity = 0 + surface.backgroundGrid.isVisible = false + + let elevationSource = ArcGISTiledElevationSource(url: .worldElevationService) + surface.addElevationSource(elevationSource) + scene.baseSurface = surface + + return scene + }() + + /// The graphics overlay for the pipe graphics. + let pipeGraphicsOverlay: GraphicsOverlay = { + let graphicsOverlay = GraphicsOverlay() + graphicsOverlay.sceneProperties.surfacePlacement = .absolute + + let strokeSymbolLayer = SolidStrokeSymbolLayer( + width: 0.3, + color: .red, + lineStyle3D: .tube + ) + let polylineSymbol = MultilayerPolylineSymbol(symbolLayers: [strokeSymbolLayer]) + graphicsOverlay.renderer = SimpleRenderer(symbol: polylineSymbol) + + return graphicsOverlay + }() + + /// The graphics overlay for the shadow graphics of the underground pipes. + let shadowGraphicsOverlay: GraphicsOverlay = { + let graphicsOverlay = GraphicsOverlay() + graphicsOverlay.sceneProperties.surfacePlacement = .drapedFlat + + let yellowLineSymbol = SimpleLineSymbol(style: .solid, color: .systemYellow, width: 0.3) + graphicsOverlay.renderer = SimpleRenderer(symbol: yellowLineSymbol) + + return graphicsOverlay + }() + + /// The graphics overlay for the pipe leader line graphics. + let leaderGraphicsOverlay: GraphicsOverlay = { + let graphicsOverlay = GraphicsOverlay() + graphicsOverlay.sceneProperties.surfacePlacement = .absolute + + let dashedRedLineSymbol = SimpleLineSymbol(style: .dash, color: .systemRed, width: 0.3) + graphicsOverlay.renderer = SimpleRenderer(symbol: dashedRedLineSymbol) + + return graphicsOverlay + }() + + init() { + Task { + try? await scene.load() + } + } + + /// Adds graphics created from a given polyline and elevation offset to the graphics overlays. + /// - Parameters: + /// - polyline: The polyline representing a pipe. + /// - elevationOffset: An elevation to offset the pipe with. + func addGraphics(for polyline: Polyline, elevationOffset: Double) async { + guard let firstPoint = polyline.parts.first?.startPoint, + let elevation = try? await scene.baseSurface.elevation(at: firstPoint) else { return } + + // Add the elevation with the offset to the polyline. + let elevatedPolyline = GeometryEngine.makeGeometry( + from: polyline, + z: elevation + elevationOffset + ) + + // Add a pipe graphic using the elevated polyline. + let pipeGraphic = Graphic(geometry: elevatedPolyline) + pipeGraphicsOverlay.addGraphic(pipeGraphic) + + // Add graphics for the leader lines. + let leaderLineGraphics = elevatedPolyline.parts.map { part in + part.points.map { point in + let offsetPoint = GeometryEngine.makeGeometry( + from: point, + z: (point.z ?? 0) - elevationOffset + ) + let leaderLine = Polyline(points: [point, offsetPoint]) + return Graphic(geometry: leaderLine) + } + } + leaderGraphicsOverlay.addGraphics(Array(leaderLineGraphics.joined())) + + // Add a shadow graphic for the pipe if it is below ground. + if elevationOffset < 0 { + let shadowGraphic = Graphic(geometry: polyline) + shadowGraphicsOverlay.addGraphic(shadowGraphic) + } + } + } +} + +private extension URL { + /// The URL of the Terrain 3D ArcGIS REST Service. + static var worldElevationService: URL { + URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")! + } +} diff --git a/Shared/Samples/Augment reality to show hidden infrastructure/AugmentRealityToShowHiddenInfrastructureView.swift b/Shared/Samples/Augment reality to show hidden infrastructure/AugmentRealityToShowHiddenInfrastructureView.swift new file mode 100644 index 000000000..bdfa4c2ff --- /dev/null +++ b/Shared/Samples/Augment reality to show hidden infrastructure/AugmentRealityToShowHiddenInfrastructureView.swift @@ -0,0 +1,262 @@ +// 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 ArcGIS +import CoreLocation +import SwiftUI + +struct AugmentRealityToShowHiddenInfrastructureView: View { + /// The view model for the map view in the sample. + @StateObject private var model = MapModel() + + /// The status message in the overlay. + @State private var statusMessage = "Tap the map to add pipe points." + + /// A Boolean value indicating whether there are graphics to be deleted. + @State private var canDelete = false + + /// A Boolean value indicating whether the current geometry edits can be added as a pipe. + @State private var canApplyEdits = false + + /// A Boolean value indicating whether the geometry editor can undo. + @State private var geometryEditorCanUndo = false + + /// A Boolean value indicating whether the alert for entering an elevation offset is showing. + @State private var elevationAlertIsPresented = false + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + NavigationView { + MapView(map: model.map, graphicsOverlays: [model.pipesGraphicsOverlay]) + .locationDisplay(model.locationDisplay) + .geometryEditor(model.geometryEditor) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + toolbarButtons + } + } + } + .overlay(alignment: .top) { + instructionText + } + .elevationOffsetAlert(isPresented: $elevationAlertIsPresented) { elevationOffset in + model.addPipe(elevationOffset: elevationOffset) + canDelete = true + + if elevationOffset < 0 { + statusMessage = "Pipe added \(elevationOffset.formatted()) meter(s) below surface." + } else if elevationOffset.isZero { + statusMessage = "Pipe added at ground level." + } else { + statusMessage = "Pipe added \(elevationOffset.formatted()) meter(s) above surface." + } + statusMessage.append("\nTap the camera to view the pipe(s) in AR.") + + model.geometryEditor.start(withType: Polyline.self) + } + .task { + do { + try await model.startLocationDisplay() + } catch { + self.error = error + } + + // Start the geometry editor and listen for its geometry updates. + model.geometryEditor.start(withType: Polyline.self) + + for await geometry in model.geometryEditor.$geometry { + let polyline = geometry as? Polyline + canApplyEdits = polyline?.parts.contains { $0.points.count >= 2 } ?? false + if canApplyEdits { + statusMessage = "Tap the check mark to add the pipe." + } + + geometryEditorCanUndo = model.geometryEditor.canUndo + } + } + .errorAlert(presentingError: $error) + } + + /// The buttons in the bottom toolbar. + @ViewBuilder private var toolbarButtons: some View { + Button { + if geometryEditorCanUndo { + model.geometryEditor.undo() + } else { + model.removeAllGraphics() + canDelete = false + statusMessage = "Tap the map to add pipe points." + } + } label: { + Image(systemName: geometryEditorCanUndo ? "arrow.uturn.backward" : "trash") + } + .disabled(!geometryEditorCanUndo && !canDelete) + Spacer() + + NavigationLink { + ARPipesSceneView(model: model.sceneModel) + } label: { + Image(systemName: "camera") + } + .disabled(geometryEditorCanUndo || !canDelete) + Spacer() + + Button("Done", systemImage: "checkmark") { + elevationAlertIsPresented = true + } + .disabled(!canApplyEdits) + } + + /// The instruction text in the overlay. + private var instructionText: some View { + Text(statusMessage) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(8) + .background(.thinMaterial, ignoresSafeAreaEdges: .horizontal) + } +} + +private extension AugmentRealityToShowHiddenInfrastructureView { + // MARK: Map Model + + /// The view model for the map view in the sample. + class MapModel: ObservableObject { + /// A map with an imagery basemap style. + let map = Map(basemapStyle: .arcGISImagery) + + /// The graphics overlay for the 2D pipe graphics. + let pipesGraphicsOverlay: GraphicsOverlay = { + let graphicsOverlay = GraphicsOverlay() + let redLineSymbol = SimpleLineSymbol(style: .solid, color: .red, width: 2) + graphicsOverlay.renderer = SimpleRenderer(symbol: redLineSymbol) + return graphicsOverlay + }() + + /// The location display for showing the user's current location. + let locationDisplay: LocationDisplay = { + let locationDisplay = LocationDisplay(dataSource: SystemLocationDataSource()) + locationDisplay.autoPanMode = .recenter + locationDisplay.initialZoomScale = 1000 + return locationDisplay + }() + + /// The geometry editor for creating polylines representing pipes. + let geometryEditor = GeometryEditor() + + /// The view model for scene view in the sample. + let sceneModel = SceneModel() + + /// Starts the location display to show user's location on the map. + func startLocationDisplay() async throws { + // Request location permission if it has not yet been determined. + let locationManager = CLLocationManager() + if locationManager.authorizationStatus == .notDetermined { + locationManager.requestWhenInUseAuthorization() + } + + // Start the location display to zoom to the user's current location. + try await locationDisplay.dataSource.start() + } + + /// Adds pipe graphics to the map and scene using the current geometry editor edits. + /// - Parameter elevationOffset: The elevation to offset the pipe with in the scene. + func addPipe(elevationOffset: Double) { + guard let polyline = geometryEditor.stop() as? Polyline else { return } + + let pipeGraphic = Graphic(geometry: polyline) + pipesGraphicsOverlay.addGraphic(pipeGraphic) + + Task { + await sceneModel.addGraphics(for: polyline, elevationOffset: elevationOffset) + } + } + + /// Removes the graphics from the map and scene graphics overlays. + func removeAllGraphics() { + pipesGraphicsOverlay.removeAllGraphics() + + sceneModel.pipeGraphicsOverlay.removeAllGraphics() + sceneModel.shadowGraphicsOverlay.removeAllGraphics() + sceneModel.leaderGraphicsOverlay.removeAllGraphics() + } + } + + // MARK: Elevation Alert + + /// An alert that allows the user to enter an elevation offset for a pipe. + struct ElevationOffsetAlert: ViewModifier { + /// A binding to a Boolean value that determines whether to present the alert. + @Binding var isPresented: Bool + + /// The action to perform when the user presses "Done". + let action: (Double) -> Void + + /// The text in the text field. + @State private var text = "" + + /// A Boolean value indicating whether the invalid elevation alert is showing. + @State private var invalidAlertIsPresented = false + + func body(content: Content) -> some View { + content + .alert("Enter an Elevation", isPresented: $isPresented) { + TextField("Enter elevation", text: $text) + .keyboardType(.numbersAndPunctuation) + + Button("Cancel", role: .cancel, action: {}) + + Button("Done") { + if let elevationOffset = Double(text), + -10...10 ~= elevationOffset { + action(elevationOffset) + text.removeAll() + } else { + invalidAlertIsPresented = true + } + } + } message: { + Text("Enter a pipe elevation offset in meters between -10 and 10.") + } + .alert("Invalid Elevation", isPresented: $invalidAlertIsPresented) { + Button("OK") { + isPresented = true + } + } message: { + Text("\"\(text)\" is not a valid elevation offset.\nEnter a value between -10 and 10.") + } + } + } +} + +private extension View { + /// Presents an alert that allows the user to enter an elevation offset for a pipe. + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the alert. + /// - action: The action to perform when the user presses "Done". + /// - Returns: A new `View`. + func elevationOffsetAlert( + isPresented: Binding, + action: @escaping (Double) -> Void + ) -> some View { + self.modifier( + AugmentRealityToShowHiddenInfrastructureView.ElevationOffsetAlert( + isPresented: isPresented, + action: action + ) + ) + } +} diff --git a/Shared/Samples/Augment reality to show hidden infrastructure/README.md b/Shared/Samples/Augment reality to show hidden infrastructure/README.md new file mode 100644 index 000000000..ef21f09a3 --- /dev/null +++ b/Shared/Samples/Augment reality to show hidden infrastructure/README.md @@ -0,0 +1,49 @@ +# Augment reality to show hidden infrastructure + +Visualize hidden infrastructure in its real-world location using augmented reality. + +![Image of Augment reality to show hidden infrastructure 1](augment-reality-to-show-hidden-infrastructure-1.png) +![Image of Augment reality to show hidden infrastructure 2](augment-reality-to-show-hidden-infrastructure-2.png) + +## Use case + +You can use AR to "x-ray" the ground to see pipes, wiring, or other infrastructure that isn't otherwise visible. For example, you could use this feature to trace the flow of water through a building to help identify the source of a leak. + +## How to use the sample + +When you open the sample, you'll see a map centered on your current location. Tap on the map to draw pipes around your location. After drawing the pipes, input an elevation value to place the drawn infrastructure above or below ground. When you are ready, tap the camera button to view the infrastructure you drew in AR. + +## How it works + +1. Draw pipes on the map. See the "Create and edit geometries" sample to learn how to use the geometry editor for creating graphics. +2. When you start the AR visualization experience, create and show the `WorldScaleSceneView`. +3. Pass a `SceneView` into the world scale scene view and set the space effect `transparent` and the atmosphere effect to `off`. +4. Create an `ArcGISTiledElevationSource` and add it to the scene's base surface. Set the navigation constraint to `unconstrained` to allow going underground if needed. +5. Configure a graphics overlay and renderer for showing the drawn pipes. This sample uses a `SolidStrokeSymbolLayer` with a `MultilayerPolylineSymbol` to draw the pipes as tubes. Add the drawn pipes to the overlay. + +## Relevant API + +* GeometryEditor +* GraphicsOverlay +* MultilayerPolylineSymbol +* SolidStrokeSymbolLayer +* Surface +* WorldScaleSceneView + +## About the data + +This sample uses Esri's [world elevation service](https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer) to ensure that the infrastructure you create is accurately placed beneath the ground. + +Real-scale AR relies on having data in real-world locations near the user. It isn't practical to provide pre-made data like other ArcGIS Maps SDKs for Native Apps samples, so you must draw your own nearby sample "pipe infrastructure" prior to starting the AR experience. + +## Additional information + +Note that unlike other scene samples, a basemap isn't shown most of the time, because the real world provides the context. Only while calibrating is the basemap displayed at 50% opacity, to give the user a visual reference to compare to. + +You may notice that pipes you draw underground appear to float more than you would expect. That floating is a normal result of the parallax effect that looks unnatural because you're not used to being able to see underground/obscured objects. Compare the behavior of underground pipes with equivalent pipes drawn above the surface - the behavior is the same, but probably feels more natural above ground because you see similar scenes day-to-day (e.g. utility wires). + +**World-scale AR** is one of three main patterns for working with geographic information in augmented reality. Augmented reality is made possible with the ArcGIS Maps SDK Toolkit. See [Augmented reality](https://developers.arcgis.com/ios/scenes-3d/display-scenes-in-augmented-reality/) in the guide for more information about augmented reality and adding it to your app. + +## Tags + +augmented reality, full-scale, infrastructure, lines, mixed reality, pipes, real-scale, underground, visualization, visualize, world-scale diff --git a/Shared/Samples/Augment reality to show hidden infrastructure/README.metadata.json b/Shared/Samples/Augment reality to show hidden infrastructure/README.metadata.json new file mode 100644 index 000000000..9a2c90017 --- /dev/null +++ b/Shared/Samples/Augment reality to show hidden infrastructure/README.metadata.json @@ -0,0 +1,42 @@ +{ + "category": "Augmented Reality", + "description": "Visualize hidden infrastructure in its real-world location using augmented reality.", + "ignore": false, + "images": [ + "augment-reality-to-show-hidden-infrastructure-1.png", + "augment-reality-to-show-hidden-infrastructure-2.png" + ], + "keywords": [ + "augmented reality", + "full-scale", + "infrastructure", + "lines", + "mixed reality", + "pipes", + "real-scale", + "underground", + "visualization", + "visualize", + "world-scale", + "GeometryEditor", + "GraphicsOverlay", + "MultilayerPolylineSymbol", + "SolidStrokeSymbolLayer", + "Surface", + "WorldScaleSceneView" + ], + "redirect_from": [], + "relevant_apis": [ + "GeometryEditor", + "GraphicsOverlay", + "MultilayerPolylineSymbol", + "SolidStrokeSymbolLayer", + "Surface", + "WorldScaleSceneView" + ], + "snippets": [ + "AugmentRealityToShowHiddenInfrastructureView.swift", + "AugmentRealityToShowHiddenInfrastructureView.ARSceneView.swift" + ], + "title": "Augment reality to show hidden infrastructure" +} diff --git a/Shared/Samples/Augment reality to show hidden infrastructure/augment-reality-to-show-hidden-infrastructure-1.png b/Shared/Samples/Augment reality to show hidden infrastructure/augment-reality-to-show-hidden-infrastructure-1.png new file mode 100644 index 000000000..a4683424e Binary files /dev/null and b/Shared/Samples/Augment reality to show hidden infrastructure/augment-reality-to-show-hidden-infrastructure-1.png differ diff --git a/Shared/Samples/Augment reality to show hidden infrastructure/augment-reality-to-show-hidden-infrastructure-2.png b/Shared/Samples/Augment reality to show hidden infrastructure/augment-reality-to-show-hidden-infrastructure-2.png new file mode 100644 index 000000000..cc2e9f9f1 Binary files /dev/null and b/Shared/Samples/Augment reality to show hidden infrastructure/augment-reality-to-show-hidden-infrastructure-2.png differ diff --git a/Shared/Samples/Authenticate with OAuth/README.md b/Shared/Samples/Authenticate with OAuth/README.md index fdd922d30..6763749fc 100644 --- a/Shared/Samples/Authenticate with OAuth/README.md +++ b/Shared/Samples/Authenticate with OAuth/README.md @@ -30,9 +30,9 @@ Upon launch, a web map containing premium content will load. You will be prompte ## Additional information -The workflow presented in this sample works for all SAML based enterprise (IWA, PKI, Okta, etc.) & social (Facebook, Google, etc.) identity providers for ArcGIS Online or Portal. For more information, see the topic [Set up enterprise logins](https://doc.arcgis.com/en/arcgis-online/administer/enterprise-logins.htm). +The workflow presented in this sample works for all SAML based enterprise (IWA, PKI, Okta, etc.) & social (Facebook, Google, etc.) identity providers for ArcGIS Online or Portal. For more information, see the topic [Set up enterprise logins](https://doc.arcgis.com/en/arcgis-online/administer/saml-logins.htm). -For additional information on using OAuth in your app, see the topic [OAuth 2.0](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/oauth-2.0/) and [Serverless native and mobile app workflow](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/arcgis-identity/serverless-native-apps/). +For additional information on using OAuth in your app, see the topic [OAuth 2.0](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/oauth-2/) and [Serverless native and mobile app workflow](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/user-authentication/serverless-native-flow/). ## Tags diff --git a/Shared/Samples/Browse building floors/BrowseBuildingFloorsView.swift b/Shared/Samples/Browse building floors/BrowseBuildingFloorsView.swift index c0c3b236c..0a39ac0a2 100644 --- a/Shared/Samples/Browse building floors/BrowseBuildingFloorsView.swift +++ b/Shared/Samples/Browse building floors/BrowseBuildingFloorsView.swift @@ -75,3 +75,7 @@ private extension PortalItem.ID { /// A portal item of Building L's floors on the Esri Redlands campus. static var esriBuildingL: Self { Self("f133a698536f44c8884ad81f80b6cfc7")! } } + +#Preview { + BrowseBuildingFloorsView() +} diff --git a/Shared/Samples/Change camera controller/ChangeCameraControllerView.swift b/Shared/Samples/Change camera controller/ChangeCameraControllerView.swift index d0a9af20d..8aa881fc3 100644 --- a/Shared/Samples/Change camera controller/ChangeCameraControllerView.swift +++ b/Shared/Samples/Change camera controller/ChangeCameraControllerView.swift @@ -80,9 +80,9 @@ struct ChangeCameraControllerView: View { /// A human-readable label for the camera controller kind. var label: String { switch self { - case .globe: return "Free pan round the globe" - case .plane: return "Orbit camera around plane" - case .crater: return "Orbit camera around crater" + case .globe: return "Pan Around Globe" + case .plane: return "Orbit Around Plane" + case .crater: return "Orbit Around Crater" } } } @@ -98,16 +98,14 @@ struct ChangeCameraControllerView: View { ) .toolbar { ToolbarItem(placement: .bottomBar) { - Menu("Camera Controllers") { - Picker("Choose a camera controller for the scene view.", selection: $cameraControllerKind) { - ForEach(CameraControllerKind.allCases, id: \.self) { kind in - Text(kind.label) - } - } - .task(id: cameraControllerKind) { - cameraController = makeCameraController(kind: cameraControllerKind) + Picker("Camera Controller", selection: $cameraControllerKind) { + ForEach(CameraControllerKind.allCases, id: \.self) { kind in + Text(kind.label) } } + .task(id: cameraControllerKind) { + cameraController = makeCameraController(kind: cameraControllerKind) + } } } } diff --git a/Shared/Samples/Change camera controller/change-camera-controller.png b/Shared/Samples/Change camera controller/change-camera-controller.png index 42f029be8..a00e2e6ae 100644 Binary files a/Shared/Samples/Change camera controller/change-camera-controller.png and b/Shared/Samples/Change camera controller/change-camera-controller.png differ diff --git a/Shared/Samples/Change map view background/ChangeMapViewBackgroundView.SettingsView.swift b/Shared/Samples/Change map view background/ChangeMapViewBackgroundView.SettingsView.swift index af25e1c89..ec15693cd 100644 --- a/Shared/Samples/Change map view background/ChangeMapViewBackgroundView.SettingsView.swift +++ b/Shared/Samples/Change map view background/ChangeMapViewBackgroundView.SettingsView.swift @@ -17,7 +17,7 @@ import SwiftUI extension ChangeMapViewBackgroundView { struct SettingsView: View { /// The view model for the sample. - @EnvironmentObject private var model: ChangeMapViewBackgroundView.Model + @ObservedObject var model: Model var body: some View { List { diff --git a/Shared/Samples/Change map view background/ChangeMapViewBackgroundView.swift b/Shared/Samples/Change map view background/ChangeMapViewBackgroundView.swift index 18492457f..a26160e08 100644 --- a/Shared/Samples/Change map view background/ChangeMapViewBackgroundView.swift +++ b/Shared/Samples/Change map view background/ChangeMapViewBackgroundView.swift @@ -27,16 +27,20 @@ struct ChangeMapViewBackgroundView: View { MapView(map: model.map) .backgroundGrid(model.backgroundGrid) .toolbar { - ToolbarItemGroup(placement: .bottomBar) { - Spacer() + ToolbarItem(placement: .bottomBar) { Button("Background Grid Settings") { isShowingSettings = true } .sheet(isPresented: $isShowingSettings, detents: [.medium], dragIndicatorVisibility: .visible) { - SettingsView() - .environmentObject(model) + SettingsView(model: model) } } } } } + +#Preview { + NavigationView { + ChangeMapViewBackgroundView() + } +} diff --git a/Shared/Samples/Change viewpoint/ChangeViewpointView.swift b/Shared/Samples/Change viewpoint/ChangeViewpointView.swift index e724ebc1c..3dd1c0426 100644 --- a/Shared/Samples/Change viewpoint/ChangeViewpointView.swift +++ b/Shared/Samples/Change viewpoint/ChangeViewpointView.swift @@ -128,3 +128,7 @@ extension ChangeViewpointView { } } } + +#Preview { + ChangeViewpointView() +} diff --git a/Shared/Samples/Clip geometry/ClipGeometryView.swift b/Shared/Samples/Clip geometry/ClipGeometryView.swift index 11a356414..26bea57d1 100644 --- a/Shared/Samples/Clip geometry/ClipGeometryView.swift +++ b/Shared/Samples/Clip geometry/ClipGeometryView.swift @@ -174,3 +174,9 @@ private extension Envelope { return builder.toGeometry() } } + +#Preview { + NavigationView { + ClipGeometryView() + } +} diff --git a/Shared/Samples/Configure basemap style parameters/ConfigureBasemapStyleParametersView.swift b/Shared/Samples/Configure basemap style parameters/ConfigureBasemapStyleParametersView.swift new file mode 100644 index 000000000..e3657a8c4 --- /dev/null +++ b/Shared/Samples/Configure basemap style parameters/ConfigureBasemapStyleParametersView.swift @@ -0,0 +1,145 @@ +// 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 ArcGIS +import SwiftUI + +struct ConfigureBasemapStyleParametersView: View { + /// The view model for the sample. + @StateObject private var model = Model() + + /// The selected basemap style language strategy. + @State private var selectedLanguage: BasemapStyleLanguage = .global + + /// The selected locale. + @State private var selectedLocale: Locale = .current + + var body: some View { + MapView(map: model.map) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Spacer() + languageMenu + .onChange(of: selectedLanguage) { newLanguage in + model.setBasemapLanguage(newLanguage) + } + Spacer() + } + } + } + + private var languageMenu: some View { + Menu("Language Settings") { + Section("Language Strategy") { + // A picker for specific languages. + Menu("Specific Language") { + Picker(selectedLanguage.label, selection: $selectedLocale) { + ForEach(model.languages, id: \.1) { label, code in + Text(label).tag(Locale(identifier: code)) + } + } + .onChange(of: selectedLocale) { newLocale in + selectedLanguage = .specific(newLocale) + } + } + + // A series of buttons for general language strategies. + ForEach( + [ + // Use the default language setting for the basemap style. + BasemapStyleLanguage.default, + // Use the global language (English) for basemap labels. + BasemapStyleLanguage.global, + // Uses country-local language for basemap labels. + BasemapStyleLanguage.local, + // Use the system locale language for basemap labels. + BasemapStyleLanguage.applicationLocale + ], + id: \.label + ) { basemapStyleLanguage in + Button { + selectedLanguage = basemapStyleLanguage + } label: { + HStack { + Text(basemapStyleLanguage.label) + Spacer() + if selectedLanguage == basemapStyleLanguage { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + } +} + +private extension ConfigureBasemapStyleParametersView { + /// The model used to store the geo model and other expensive objects + /// used in this view. + class Model: ObservableObject { + /// A map with OpenStreetMap light gray basemap. + let map: Map = { + let map = Map( + basemap: Basemap( + // An OpenStreetMap basemap style is used to support localization. + style: .osmLightGray, + // Set the language strategy to global to use English. + parameters: BasemapStyleParameters(language: .global) + ) + ) + // Start with a viewpoint over Bulgaria, Greece, and Turkey. + // They use three different alphabets: Cyrillic, Greek, and Latin, respectively. + map.initialViewpoint = Viewpoint(center: Point(x: 3_000_000, y: 4_500_000), scale: 1e7) + return map + }() + + /// The language label and Esri language code for the basemap style parameters. + let languages: KeyValuePairs = [ + "🇧🇬 Bulgarian": "bg", + "🇬🇷 Greek": "el", + "🇹🇷 Turkish": "tr" + ] + + /// Sets the basemap style parameter with a language strategy. + /// - Parameter language: The language setting for the basemap. + func setBasemapLanguage(_ language: BasemapStyleLanguage) { + let parameters = BasemapStyleParameters(language: language) + map.basemap = Basemap(style: .osmLightGray, parameters: parameters) + } + } +} + +private extension BasemapStyleLanguage { + /// A human-readable label for the basemap style language. + var label: String { + switch self { + case .default: + return "Default Language" + case .global: + return "Global" + case .local: + return "Local" + case .applicationLocale: + return "System Locale" + case .specific(let locale): + return "Specific: \(locale.identifier)" + @unknown default: + fatalError("Unknown basemap style language option") + } + } +} diff --git a/Shared/Samples/Configure basemap style parameters/README.md b/Shared/Samples/Configure basemap style parameters/README.md new file mode 100644 index 000000000..2c49ab446 --- /dev/null +++ b/Shared/Samples/Configure basemap style parameters/README.md @@ -0,0 +1,41 @@ +# Configure basemap style parameters + +Apply basemap style parameters customization for a basemap, such as displaying all labels in a specific language or displaying every label in their corresponding local language. + +![Image of Configure basemap style parameters](configure-basemap-style-parameters.png) + +## Use case + +When creating an application that is used in multiple countries, basemaps can reflect the languages and cultures of the users' location. For example, if an application user is in Greece, displaying the labels on a basemap in Greek reflects the local language. Customizing the language setting on the basemap can be controlled by an application user (such as by setting preferences) or implicitly managed within the application logic (by querying the locale of the platform running the application). + +## How to use the sample + +This sample showcases the workflow of configuring basemap style parameters by displaying a basemap with labels in different languages and launches with a `Viewpoint` set over Bulgaria, Greece, and Turkey, as they use three different alphabets: Cyrillic, Greek, and Latin, respectively. By default, the `BasemapStyleLanguage` is set to `local` which displays all labels in their corresponding local language. This can be changed to `global`, which displays all labels in English. The `specific` option sets all labels to a selected language and overrides the `BasemapStyleLanguage` settings. + +Pan and zoom to navigate the map and see how different labels are displayed in these countries depending on the selected `BasemapStyleLanguage`: all English, all Greek, all Bulgarian, all Turkish, or each their own. + +## How it works + +1. Create a `BasemapStyleParameters` object. +2. Configure customization preferences on the `BasemapStyleParameters` object, for instance: + * setting the `BasemapStyleLanguage` to `local` or + * `specific("el")` changes the label language to Greek. +3. Create a basemap using a `Basemap.Style` and the `BasemapStyleParameters`. +4. Assign the configured basemap to the `Map`'s `basemap` property. +5. To modify the basemap style, for example if you want to change your preferences, repeat the above steps. + +## Relevant API + +* Basemap +* BasemapStyleLanguage +* BasemapStyleParameters +* Map +* MapView + +## About the data + +The main data for this sample is the basemap style which includes basemaps that support both language localization and global language setting. The supported languages, along with their language code, can be found in the [API's documentation](https://developers.arcgis.com/rest/basemap-styles/#languages). + +## Tags + +basemap style, language, language strategy, map, point, viewpoint diff --git a/Shared/Samples/Configure basemap style parameters/README.metadata.json b/Shared/Samples/Configure basemap style parameters/README.metadata.json new file mode 100644 index 000000000..d63f47d9e --- /dev/null +++ b/Shared/Samples/Configure basemap style parameters/README.metadata.json @@ -0,0 +1,33 @@ +{ + "category": "Maps", + "description": "Apply basemap style parameters customization for a basemap, such as displaying all labels in a specific language or displaying every label in their corresponding local language.", + "ignore": false, + "images": [ + "configure-basemap-style-parameters.png" + ], + "keywords": [ + "basemap style", + "language", + "language strategy", + "map", + "point", + "viewpoint", + "Basemap", + "BasemapStyleLanguage", + "BasemapStyleParameters", + "Map", + "MapView" + ], + "redirect_from": [], + "relevant_apis": [ + "Basemap", + "BasemapStyleLanguage", + "BasemapStyleParameters", + "Map", + "MapView" + ], + "snippets": [ + "ConfigureBasemapStyleParametersView.swift" + ], + "title": "Configure basemap style parameters" +} diff --git a/Shared/Samples/Configure basemap style parameters/configure-basemap-style-parameters.png b/Shared/Samples/Configure basemap style parameters/configure-basemap-style-parameters.png new file mode 100644 index 000000000..73493c73b Binary files /dev/null and b/Shared/Samples/Configure basemap style parameters/configure-basemap-style-parameters.png differ diff --git a/Shared/Samples/Add clustering feature reduction to a point feature layer/AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift b/Shared/Samples/Configure clusters/ConfigureClustersView.Model.swift similarity index 99% rename from Shared/Samples/Add clustering feature reduction to a point feature layer/AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift rename to Shared/Samples/Configure clusters/ConfigureClustersView.Model.swift index 781621d1f..18cdc6012 100644 --- a/Shared/Samples/Add clustering feature reduction to a point feature layer/AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift +++ b/Shared/Samples/Configure clusters/ConfigureClustersView.Model.swift @@ -15,7 +15,7 @@ import ArcGIS import UIKit.UIColor -extension AddClusteringFeatureReductionToAPointFeatureLayerView { +extension ConfigureClustersView { /// The model used to store the geo model and other expensive objects /// used in this view. class Model: ObservableObject { diff --git a/Shared/Samples/Configure clusters/ConfigureClustersView.SettingsView.swift b/Shared/Samples/Configure clusters/ConfigureClustersView.SettingsView.swift new file mode 100644 index 000000000..ce596c986 --- /dev/null +++ b/Shared/Samples/Configure clusters/ConfigureClustersView.SettingsView.swift @@ -0,0 +1,82 @@ +// Copyright 2023 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 ConfigureClustersView { + struct SettingsView: View { + /// The model for the sample. + @ObservedObject var model: Model + + /// The action to dismiss the settings sheet. + @Environment(\.dismiss) private var dismiss: DismissAction + + /// The map view's scale. + let mapViewScale: Double + + /// The radius of feature clusters selected by the user. + @State private var selectedRadius = 60 + + /// The maximum scale of feature clusters selected by the user. + @State private var selectedMaxScale = 0 + + var body: some View { + NavigationView { + Form { + Section("Cluster Labels Visibility") { + Toggle("Show Labels", isOn: $model.showsLabels) + .toggleStyle(.switch) + } + + Section("Clustering Properties") { + Picker("Cluster Radius", selection: $selectedRadius) { + ForEach([30, 45, 60, 75, 90], id: \.self) { radius in + Text("\(radius)") + } + } + .onChange(of: selectedRadius) { newRadius in + model.radius = Double(newRadius) + } + + Picker("Cluster Max Scale", selection: $selectedMaxScale) { + ForEach([0, 1000, 5000, 10000, 50000, 100000, 500000], id: \.self) { scale in + Text(("\(scale)")) + } + } + .onChange(of: selectedMaxScale) { newMaxScale in + model.maxScale = Double(newMaxScale) + } + + HStack { + Text("Current Map Scale") + Spacer() + Text(mapViewScale, format: .number.precision(.fractionLength(0))) + .foregroundColor(.secondary) + } + } + } + .navigationTitle("Clustering Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } + } + .navigationViewStyle(.stack) + } + } +} diff --git a/Shared/Samples/Add clustering feature reduction to a point feature layer/AddClusteringFeatureReductionToAPointFeatureLayerView.swift b/Shared/Samples/Configure clusters/ConfigureClustersView.swift similarity index 94% rename from Shared/Samples/Add clustering feature reduction to a point feature layer/AddClusteringFeatureReductionToAPointFeatureLayerView.swift rename to Shared/Samples/Configure clusters/ConfigureClustersView.swift index be3cd0bff..cfdfeb13e 100644 --- a/Shared/Samples/Add clustering feature reduction to a point feature layer/AddClusteringFeatureReductionToAPointFeatureLayerView.swift +++ b/Shared/Samples/Configure clusters/ConfigureClustersView.swift @@ -16,7 +16,7 @@ import ArcGIS import ArcGISToolkit import SwiftUI -struct AddClusteringFeatureReductionToAPointFeatureLayerView: View { +struct ConfigureClustersView: View { /// The model for the sample. @StateObject private var model = Model() @@ -58,7 +58,7 @@ struct AddClusteringFeatureReductionToAPointFeatureLayerView: View { } .toolbar { ToolbarItem(placement: .bottomBar) { - Button("Settings") { + Button("Clustering Settings") { showsSettings = true } .sheet(isPresented: $showsSettings, detents: [.medium], dragIndicatorVisibility: .visible) { @@ -73,3 +73,9 @@ struct AddClusteringFeatureReductionToAPointFeatureLayerView: View { } } } + +#Preview { + NavigationView { + ConfigureClustersView() + } +} diff --git a/Shared/Samples/Add clustering feature reduction to a point feature layer/README.md b/Shared/Samples/Configure clusters/README.md similarity index 92% rename from Shared/Samples/Add clustering feature reduction to a point feature layer/README.md rename to Shared/Samples/Configure clusters/README.md index 9c4b2aec9..14f7f2034 100644 --- a/Shared/Samples/Add clustering feature reduction to a point feature layer/README.md +++ b/Shared/Samples/Configure clusters/README.md @@ -1,8 +1,8 @@ -# Add clustering feature reduction to a point feature layer +# Configure clusters Add client side feature reduction on a point feature layer that is not pre-configured with clustering. -![Image of Add clustering feature reduction to a point feature layer sample](add-clustering-feature-reduction-to-a-point-feature-layer.png) +![Image of configure clusters](configure-clusters.png) ## Use case diff --git a/Shared/Samples/Add clustering feature reduction to a point feature layer/README.metadata.json b/Shared/Samples/Configure clusters/README.metadata.json similarity index 67% rename from Shared/Samples/Add clustering feature reduction to a point feature layer/README.metadata.json rename to Shared/Samples/Configure clusters/README.metadata.json index 173be5e19..cb9470c46 100644 --- a/Shared/Samples/Add clustering feature reduction to a point feature layer/README.metadata.json +++ b/Shared/Samples/Configure clusters/README.metadata.json @@ -3,7 +3,7 @@ "description": "Add client side feature reduction on a point feature layer that is not pre-configured with clustering.", "ignore": false, "images": [ - "add-clustering-feature-reduction-to-a-point-feature-layer.png" + "configure-clusters.png" ], "keywords": [ "aggregate", @@ -24,7 +24,9 @@ "IdentifyLayerResult", "Popup" ], - "redirect_from": [], + "redirect_from": [ + "/swift/sample-code/add-clustering-feature-reduction-to-a-point-feature-layer/" + ], "relevant_apis": [ "AggregateGeoElement", "ClassBreaksRenderer", @@ -35,9 +37,9 @@ "Popup" ], "snippets": [ - "AddClusteringFeatureReductionToAPointFeatureLayerView.swift", - "AddClusteringFeatureReductionToAPointFeatureLayerView.Model.swift", - "AddClusteringFeatureReductionToAPointFeatureLayerView.SettingsView.swift" + "ConfigureClustersView.swift", + "ConfigureClustersView.Model.swift", + "ConfigureClustersView.SettingsView.swift" ], - "title": "Add clustering feature reduction to a point feature layer" + "title": "Configure clusters" } diff --git a/Shared/Samples/Configure clusters/configure-clusters.png b/Shared/Samples/Configure clusters/configure-clusters.png new file mode 100644 index 000000000..d99aa0c0c Binary files /dev/null and b/Shared/Samples/Configure clusters/configure-clusters.png differ diff --git a/Shared/Samples/Create and edit geometries/CreateAndEditGeometriesView.swift b/Shared/Samples/Create and edit geometries/CreateAndEditGeometriesView.swift index 9a90ee2d4..c71713350 100644 --- a/Shared/Samples/Create and edit geometries/CreateAndEditGeometriesView.swift +++ b/Shared/Samples/Create and edit geometries/CreateAndEditGeometriesView.swift @@ -363,3 +363,9 @@ class GeometryEditorMenuModel: ObservableObject { isStarted = true } } + +#Preview { + NavigationView { + CreateAndEditGeometriesView() + } +} diff --git a/Shared/Samples/Create and edit geometries/README.metadata.json b/Shared/Samples/Create and edit geometries/README.metadata.json index eea19dee2..04ec9f1b7 100644 --- a/Shared/Samples/Create and edit geometries/README.metadata.json +++ b/Shared/Samples/Create and edit geometries/README.metadata.json @@ -20,7 +20,9 @@ "GraphicsOverlay", "MapView" ], - "redirect_from": [], + "redirect_from": [ + "/swift/sample-code/sketch-on-map/" + ], "relevant_apis": [ "Geometry", "GeometryBuilder", diff --git a/Shared/Samples/Create and save KML file/CreateAndSaveKMLView.swift b/Shared/Samples/Create and save KML file/CreateAndSaveKMLView.swift index b5a80010e..9bf7a03b6 100644 --- a/Shared/Samples/Create and save KML file/CreateAndSaveKMLView.swift +++ b/Shared/Samples/Create and save KML file/CreateAndSaveKMLView.swift @@ -25,27 +25,27 @@ struct CreateAndSaveKMLView: View { .geometryEditor(model.geometryEditor) .errorAlert(presentingError: $model.error) .toolbar { - ToolbarItem(placement: .primaryAction) { - HStack { - Menu { - if !model.isStarted { - // If the geometry editor is not started, show the main menu. - mainMenuContent - } else { - // If the geometry editor is started, show the edit menu. - editMenuContent - } - } label: { - Label("Geometry Editor", systemImage: "pencil.tip.crop.circle") + ToolbarItemGroup(placement: .bottomBar) { + Menu { + if !model.isStarted { + // If the geometry editor is not started, show the main menu. + mainMenuContent + } else { + // If the geometry editor is started, show the edit menu. + editMenuContent } - - Button { - model.showingFileExporter = true - } label: { - Label("Export File", systemImage: "square.and.arrow.up") - } - .disabled(model.fileExporterButtonIsDisabled) + } label: { + Label("Geometry Editor", systemImage: "pencil.tip.crop.circle") + } + + Spacer() + + Button { + model.showingFileExporter = true + } label: { + Label("Export File", systemImage: "square.and.arrow.up") } + .disabled(model.fileExporterButtonIsDisabled) } } .task { @@ -135,3 +135,9 @@ private extension UTType { /// A type that represents a KMZ file. static let kmz = UTType(filenameExtension: "kmz")! } + +#Preview { + NavigationView { + CreateAndSaveKMLView() + } +} diff --git a/Shared/Samples/Create and save KML file/create-save-kml-1.png b/Shared/Samples/Create and save KML file/create-save-kml-1.png index 60dd994f2..564816af1 100644 Binary files a/Shared/Samples/Create and save KML file/create-save-kml-1.png and b/Shared/Samples/Create and save KML file/create-save-kml-1.png differ diff --git a/Shared/Samples/Create and save KML file/create-save-kml-2.png b/Shared/Samples/Create and save KML file/create-save-kml-2.png index 2cdcbaa18..21ed8393f 100644 Binary files a/Shared/Samples/Create and save KML file/create-save-kml-2.png and b/Shared/Samples/Create and save KML file/create-save-kml-2.png differ diff --git a/Shared/Samples/Create buffers around points/CreateBuffersAroundPointsView.swift b/Shared/Samples/Create buffers around points/CreateBuffersAroundPointsView.swift index 6dbda805a..3eeffd89a 100644 --- a/Shared/Samples/Create buffers around points/CreateBuffersAroundPointsView.swift +++ b/Shared/Samples/Create buffers around points/CreateBuffersAroundPointsView.swift @@ -55,8 +55,7 @@ struct CreateBuffersAroundPointsView: View { .toolbar { ToolbarItemGroup(placement: .bottomBar) { // Union toggle switch. - Toggle("Union", isOn: $shouldUnion) - .toggleStyle(.switch) + Toggle(shouldUnion ? "Union Enabled" : "Union Disabled", isOn: $shouldUnion) .onChange(of: shouldUnion) { _ in if !model.bufferPoints.isEmpty { model.drawBuffers(unioned: shouldUnion) @@ -287,3 +286,9 @@ private extension CreateBuffersAroundPointsView { } } } + +#Preview { + NavigationView { + CreateBuffersAroundPointsView() + } +} diff --git a/Shared/Samples/Create buffers around points/create-buffers-around-points.png b/Shared/Samples/Create buffers around points/create-buffers-around-points.png index 14f3b9582..c276c981e 100644 Binary files a/Shared/Samples/Create buffers around points/create-buffers-around-points.png and b/Shared/Samples/Create buffers around points/create-buffers-around-points.png differ diff --git a/Shared/Samples/Create convex hull around geometries/CreateConvexHullAroundGeometriesView.swift b/Shared/Samples/Create convex hull around geometries/CreateConvexHullAroundGeometriesView.swift index 4bfb6932e..e6b307478 100644 --- a/Shared/Samples/Create convex hull around geometries/CreateConvexHullAroundGeometriesView.swift +++ b/Shared/Samples/Create convex hull around geometries/CreateConvexHullAroundGeometriesView.swift @@ -46,8 +46,8 @@ struct CreateConvexHullAroundGeometriesView: View { MapView(map: map, graphicsOverlays: [convexHullGraphicsOverlay, geometriesGraphicsOverlay]) .toolbar { ToolbarItemGroup(placement: .bottomBar) { - Toggle("Union", isOn: $shouldUnion) - .toggleStyle(.switch) + Toggle(shouldUnion ? "Union Enabled" : "Union Disabled", isOn: $shouldUnion) + .disabled(convexHullGraphicsOverlay.graphics.isEmpty) .onChange(of: shouldUnion) { _ in if !createIsOn { convexHullGraphicsOverlay.removeAllGraphics() @@ -141,3 +141,9 @@ private extension Geometry { spatialReference: .webMercator ) } + +#Preview { + NavigationView { + CreateConvexHullAroundGeometriesView() + } +} diff --git a/Shared/Samples/Create convex hull around geometries/create-convex-hull-around-geometries.png b/Shared/Samples/Create convex hull around geometries/create-convex-hull-around-geometries.png index 118a2a087..55acd9cbe 100644 Binary files a/Shared/Samples/Create convex hull around geometries/create-convex-hull-around-geometries.png and b/Shared/Samples/Create convex hull around geometries/create-convex-hull-around-geometries.png differ diff --git a/Shared/Samples/Create convex hull around points/CreateConvexHullAroundPointsView.swift b/Shared/Samples/Create convex hull around points/CreateConvexHullAroundPointsView.swift index 2b2cf63e7..6e74c6199 100644 --- a/Shared/Samples/Create convex hull around points/CreateConvexHullAroundPointsView.swift +++ b/Shared/Samples/Create convex hull around points/CreateConvexHullAroundPointsView.swift @@ -128,3 +128,9 @@ private extension CreateConvexHullAroundPointsView { } } } + +#Preview { + NavigationView { + CreateConvexHullAroundPointsView() + } +} diff --git a/Shared/Samples/Create load report/CreateLoadReportView.swift b/Shared/Samples/Create load report/CreateLoadReportView.swift index 77c715b4b..d353421bb 100644 --- a/Shared/Samples/Create load report/CreateLoadReportView.swift +++ b/Shared/Samples/Create load report/CreateLoadReportView.swift @@ -26,7 +26,7 @@ struct CreateLoadReportView: View { } .errorAlert(presentingError: $model.error) .toolbar { - ToolbarItemGroup(placement: .bottomBar) { + ToolbarItem(placement: .bottomBar) { Button("Run") { Task { await model.createLoadReport() @@ -53,3 +53,9 @@ struct CreateLoadReportView: View { } } } + +#Preview { + NavigationView { + CreateLoadReportView() + } +} diff --git a/Shared/Samples/Create mobile geodatabase/CreateMobileGeodatabaseView.swift b/Shared/Samples/Create mobile geodatabase/CreateMobileGeodatabaseView.swift index c96c547da..5a7506f51 100644 --- a/Shared/Samples/Create mobile geodatabase/CreateMobileGeodatabaseView.swift +++ b/Shared/Samples/Create mobile geodatabase/CreateMobileGeodatabaseView.swift @@ -171,3 +171,9 @@ private extension FormatStyle where Self == Date.VerbatimFormatStyle { ) } } + +#Preview { + NavigationView { + CreateMobileGeodatabaseView() + } +} diff --git a/Shared/Samples/Create planar and geodetic buffers/CreatePlanarAndGeodeticBuffersView.swift b/Shared/Samples/Create planar and geodetic buffers/CreatePlanarAndGeodeticBuffersView.swift index 49271fa5b..2718fa356 100644 --- a/Shared/Samples/Create planar and geodetic buffers/CreatePlanarAndGeodeticBuffersView.swift +++ b/Shared/Samples/Create planar and geodetic buffers/CreatePlanarAndGeodeticBuffersView.swift @@ -16,8 +16,8 @@ import SwiftUI import ArcGIS struct CreatePlanarAndGeodeticBuffersView: View { - /// A Boolean value indicating whether to show options. - @State private var isShowingOptions = false + /// A Boolean value indicating whether the settings are showing. + @State private var isShowingSettings = false /// The possible radii for buffers in miles. private let bufferRadii = Measurement.rMin...Measurement.rMax @@ -36,8 +36,17 @@ struct CreatePlanarAndGeodeticBuffersView: View { // Adds a buffer at the given map point. model.addBuffer(at: mapPoint, bufferDistance: bufferDistance) } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Toggle("Settings", isOn: $isShowingSettings.animation()) + Spacer() + Button("Clear") { + model.removeAllBufferGraphics() + } + } + } - if isShowingOptions { + if isShowingSettings { VStack { Slider(value: $bufferDistance.value, in: bufferRadii.doubleRange) { Text("Buffer Radius") @@ -51,20 +60,6 @@ struct CreatePlanarAndGeodeticBuffersView: View { } .padding([.horizontal, .top]) } - - HStack { - Spacer() - Toggle(isOn: $isShowingOptions.animation(.spring())) { - Text("Options") - } - .toggleStyle(.button) - Spacer() - Button("Clear All") { - model.removeAllBufferGraphics() - } - Spacer() - } - .padding() } } } @@ -179,3 +174,7 @@ private extension Measurement where UnitType == UnitLength { /// The maximum radius. static var rMax: Self { Measurement(value: 2_000, unit: UnitLength.miles) } } + +#Preview { + CreatePlanarAndGeodeticBuffersView() +} diff --git a/Shared/Samples/Create planar and geodetic buffers/create-planar-and-geodetic-buffers.png b/Shared/Samples/Create planar and geodetic buffers/create-planar-and-geodetic-buffers.png index 9ee532ad6..425e99d08 100644 Binary files a/Shared/Samples/Create planar and geodetic buffers/create-planar-and-geodetic-buffers.png and b/Shared/Samples/Create planar and geodetic buffers/create-planar-and-geodetic-buffers.png differ diff --git a/Shared/Samples/Create symbol styles from web styles/CreateSymbolStylesFromWebStylesView.swift b/Shared/Samples/Create symbol styles from web styles/CreateSymbolStylesFromWebStylesView.swift index b06475bd8..8a5ae147d 100644 --- a/Shared/Samples/Create symbol styles from web styles/CreateSymbolStylesFromWebStylesView.swift +++ b/Shared/Samples/Create symbol styles from web styles/CreateSymbolStylesFromWebStylesView.swift @@ -276,3 +276,9 @@ private extension URL { URL(string: "http://services.arcgis.com/V6ZHFr6zdgNZuVG0/arcgis/rest/services/LA_County_Points_of_Interest/FeatureServer/0")! } } + +#Preview { + NavigationView { + CreateSymbolStylesFromWebStylesView() + } +} diff --git a/Shared/Samples/Cut geometry/CutGeometryView.swift b/Shared/Samples/Cut geometry/CutGeometryView.swift index e9fd56a2c..8db517a28 100644 --- a/Shared/Samples/Cut geometry/CutGeometryView.swift +++ b/Shared/Samples/Cut geometry/CutGeometryView.swift @@ -152,3 +152,9 @@ private extension Geometry { ) } } + +#Preview { + NavigationView { + CutGeometryView() + } +} diff --git a/Shared/Samples/Densify and generalize geometry/DensifyAndGeneralizeGeometryView.SettingsView.swift b/Shared/Samples/Densify and generalize geometry/DensifyAndGeneralizeGeometryView.SettingsView.swift index 0604a67a6..c136cecc3 100644 --- a/Shared/Samples/Densify and generalize geometry/DensifyAndGeneralizeGeometryView.SettingsView.swift +++ b/Shared/Samples/Densify and generalize geometry/DensifyAndGeneralizeGeometryView.SettingsView.swift @@ -16,7 +16,7 @@ import ArcGIS import SwiftUI extension DensifyAndGeneralizeGeometryView { - struct OptionsView: View { + struct SettingsView: View { /// The view model for the sample. @ObservedObject var model: Model diff --git a/Shared/Samples/Densify and generalize geometry/DensifyAndGeneralizeGeometryView.swift b/Shared/Samples/Densify and generalize geometry/DensifyAndGeneralizeGeometryView.swift index e60dc2d94..d5ebf2fe6 100644 --- a/Shared/Samples/Densify and generalize geometry/DensifyAndGeneralizeGeometryView.swift +++ b/Shared/Samples/Densify and generalize geometry/DensifyAndGeneralizeGeometryView.swift @@ -19,18 +19,18 @@ struct DensifyAndGeneralizeGeometryView: View { /// The view model for the sample. @StateObject private var model = Model() - /// A Boolean value indicate whether the option sheet is showing. - @State private var isShowingOptions = false + /// A Boolean value indicating whether the geometry settings sheet is showing. + @State private var isShowingSettings = false var body: some View { MapView(map: model.map, graphicsOverlays: [model.graphicsOverlay]) .toolbar { ToolbarItem(placement: .bottomBar) { - Button("Options") { - isShowingOptions = true + Button("Geometry Settings") { + isShowingSettings = true } - .sheet(isPresented: $isShowingOptions, detents: [.medium], dragIndicatorVisibility: .visible) { - OptionsView(model: model) + .sheet(isPresented: $isShowingSettings, detents: [.medium], dragIndicatorVisibility: .visible) { + SettingsView(model: model) } } } @@ -180,3 +180,9 @@ extension DensifyAndGeneralizeGeometryView { } } } + +#Preview { + NavigationView { + DensifyAndGeneralizeGeometryView() + } +} diff --git a/Shared/Samples/Display annotation/DisplayAnnotationView.swift b/Shared/Samples/Display annotation/DisplayAnnotationView.swift index c60bcd0b6..d5d9f1b82 100644 --- a/Shared/Samples/Display annotation/DisplayAnnotationView.swift +++ b/Shared/Samples/Display annotation/DisplayAnnotationView.swift @@ -62,3 +62,7 @@ private extension URL { URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/RiversAnnotation/FeatureServer/0")! } } + +#Preview { + DisplayAnnotationView() +} diff --git a/Shared/Samples/Display points using clustering feature reduction/DisplayPointsUsingClusteringFeatureReductionView.swift b/Shared/Samples/Display clusters/DisplayClustersView.swift similarity index 60% rename from Shared/Samples/Display points using clustering feature reduction/DisplayPointsUsingClusteringFeatureReductionView.swift rename to Shared/Samples/Display clusters/DisplayClustersView.swift index 0cf2544a4..ddbfa4283 100644 --- a/Shared/Samples/Display points using clustering feature reduction/DisplayPointsUsingClusteringFeatureReductionView.swift +++ b/Shared/Samples/Display clusters/DisplayClustersView.swift @@ -16,7 +16,7 @@ import ArcGIS import SwiftUI import ArcGISToolkit -struct DisplayPointsUsingClusteringFeatureReductionView: View { +struct DisplayClustersView: View { /// A map of global power plants. @State private var map = { let portalItem = PortalItem( @@ -34,6 +34,9 @@ struct DisplayPointsUsingClusteringFeatureReductionView: View { /// The screen point to perform an identify operation. @State private var identifyScreenPoint: CGPoint? + /// The geoelements in the selected cluster. + @State private var geoElements: [GeoElement] = [] + /// The popup to be shown as the result of the layer identify operation. @State private var popup: Popup? @@ -53,6 +56,9 @@ struct DisplayPointsUsingClusteringFeatureReductionView: View { identifyScreenPoint = screenPoint } .task(id: identifyScreenPoint) { + layer?.clearSelection() + geoElements.removeAll() + guard let identifyScreenPoint, let layer, let identifyResult = try? await proxy.identify( @@ -63,6 +69,15 @@ struct DisplayPointsUsingClusteringFeatureReductionView: View { else { return } self.popup = identifyResult.popups.first self.showsPopup = self.popup != nil + + guard let identifyGeoElement = identifyResult.geoElements.first else { return } + if let aggregateGeoElement = identifyGeoElement as? AggregateGeoElement { + aggregateGeoElement.isSelected = true + let geoElements = try? await aggregateGeoElement.geoElements + self.geoElements = geoElements ?? [] + } else if let feature = identifyGeoElement as? Feature { + layer.selectFeature(feature) + } } .floatingPanel( selectedDetent: .constant(.half), @@ -71,15 +86,36 @@ struct DisplayPointsUsingClusteringFeatureReductionView: View { ) { [popup] in PopupView(popup: popup!, isPresented: $showsPopup) .showCloseButton(true) - .padding() + .padding([.top, .horizontal]) + + if !geoElements.isEmpty { + List { + Section { + ForEach(Array(geoElements.enumerated()), + id: \.offset + ) { offset, geoElement in + let name = geoElement.attributes["name"] as? String + Text(name ?? "Geoelement: \(offset)") + } + } header: { + Text("Geoelements") + .font(.title3) + .bold() + .foregroundColor(.primary) + } + } + .listStyle(.inset) + } } .toolbar { ToolbarItem(placement: .bottomBar) { - Toggle("Feature clustering", isOn: $showsFeatureReduction) - .toggleStyle(.switch) - .onChange(of: showsFeatureReduction) { isEnabled in - layer?.featureReduction?.isEnabled = isEnabled - } + Toggle( + showsFeatureReduction ? "Feature Clustering Enabled" : "Feature Clustering Disabled", + isOn: $showsFeatureReduction + ) + .onChange(of: showsFeatureReduction) { isEnabled in + layer?.featureReduction?.isEnabled = isEnabled + } } } .task { @@ -95,3 +131,9 @@ struct DisplayPointsUsingClusteringFeatureReductionView: View { } } } + +#Preview { + NavigationView { + DisplayClustersView() + } +} diff --git a/Shared/Samples/Display points using clustering feature reduction/README.md b/Shared/Samples/Display clusters/README.md similarity index 78% rename from Shared/Samples/Display points using clustering feature reduction/README.md rename to Shared/Samples/Display clusters/README.md index 398f289c9..c0f527c7d 100644 --- a/Shared/Samples/Display points using clustering feature reduction/README.md +++ b/Shared/Samples/Display clusters/README.md @@ -1,8 +1,8 @@ -# Display points using clustering feature reduction +# Display clusters Display a web map with a point feature layer that has feature reduction enabled to aggregate points into clusters. -![Image of display points using clustering feature reduction sample](display-points-using-clustering-feature-reduction.png) +![Image of display clusters](display-clusters.png) ## Use case @@ -10,7 +10,7 @@ Feature clustering can be used to dynamically aggregate groups of points that ar ## How to use the sample -Pan and zoom the map to view how clustering is dynamically updated. Toggle clustering off to view the original point features that make up the clustered elements. When clustering is toggled on, you can tap on a clustered geoelement to view aggregated information and summary statistics for that cluster. When clustering is toggled off and you tap on the original feature you get access to information about individual power plant features. +Pan and zoom the map to view how clustering is dynamically updated. Toggle clustering off to view the original point features that make up the clustered elements. When clustering is toggled on, you can tap on a clustered geoelement to view aggregated information and summary statistics for that cluster as well as a list of containing geoelements. When clustering is disabled and you tap on the original feature you get access to information about individual power plant features. ## How it works @@ -20,7 +20,8 @@ Pan and zoom the map to view how clustering is dynamically updated. Toggle clust 4. Use the `onSingleTapGesture` modifier to listen for tap events on the map view. 5. Identify tapped features on the map using `identify(on:screenPoint:tolerance:returnPopupsOnly:maximumResults:)` on the feature layer and pass in the map screen point location. 6. Get the `Popup` from the resulting `IdentifyLayerResult` and use it to construct a `PopupView`. -7. Use a `FloatingPanel` to display the popup information from the `PopupView`. +7. Get the `AggregateGeoElement` from the `IdentifyLayerResult` and use `geoElements` to retrieve the contained `GeoElement` objects. +8. Use a `FloatingPanel` to display the popup information from the `PopupView` and the list containing the `GeoElement` objects. ## Relevant API diff --git a/Shared/Samples/Display points using clustering feature reduction/README.metadata.json b/Shared/Samples/Display clusters/README.metadata.json similarity index 73% rename from Shared/Samples/Display points using clustering feature reduction/README.metadata.json rename to Shared/Samples/Display clusters/README.metadata.json index c9682e9f8..c8eec2e1d 100644 --- a/Shared/Samples/Display points using clustering feature reduction/README.metadata.json +++ b/Shared/Samples/Display clusters/README.metadata.json @@ -1,9 +1,9 @@ { - "category": "Visualization", + "category": "Layers", "description": "Display a web map with a point feature layer that has feature reduction enabled to aggregate points into clusters.", "ignore": false, "images": [ - "display-points-using-clustering-feature-reduction.png" + "display-clusters.png" ], "keywords": [ "aggregate", @@ -20,7 +20,9 @@ "GeoElement", "IdentifyLayerResult" ], - "redirect_from": [], + "redirect_from": [ + "/swift/sample-code/display-points-using-clustering-feature-reduction/" + ], "relevant_apis": [ "AggregateGeoElement", "FeatureLayer", @@ -29,7 +31,7 @@ "IdentifyLayerResult" ], "snippets": [ - "DisplayPointsUsingClusteringFeatureReductionView.swift" + "DisplayClustersView.swift" ], - "title": "Display points using clustering feature reduction" + "title": "Display clusters" } diff --git a/Shared/Samples/Display clusters/display-clusters.png b/Shared/Samples/Display clusters/display-clusters.png new file mode 100644 index 000000000..3cef26463 Binary files /dev/null and b/Shared/Samples/Display clusters/display-clusters.png differ diff --git a/Shared/Samples/Display content of utility network container/DisplayContentOfUtilityNetworkContainerView.swift b/Shared/Samples/Display content of utility network container/DisplayContentOfUtilityNetworkContainerView.swift index bf78756eb..de8b22e8d 100644 --- a/Shared/Samples/Display content of utility network container/DisplayContentOfUtilityNetworkContainerView.swift +++ b/Shared/Samples/Display content of utility network container/DisplayContentOfUtilityNetworkContainerView.swift @@ -163,3 +163,9 @@ struct DisplayContentOfUtilityNetworkContainerView: View { .frame(idealWidth: 320, idealHeight: 428) } } + +#Preview { + NavigationView { + DisplayContentOfUtilityNetworkContainerView() + } +} diff --git a/Shared/Samples/Display dimensions/DisplayDimensionsView.swift b/Shared/Samples/Display dimensions/DisplayDimensionsView.swift index 2d11f8dd6..e358f1f3c 100644 --- a/Shared/Samples/Display dimensions/DisplayDimensionsView.swift +++ b/Shared/Samples/Display dimensions/DisplayDimensionsView.swift @@ -22,6 +22,12 @@ struct DisplayDimensionsView: View { /// The dimensional layer added to the map. @State private var dimensionLayer: DimensionLayer? + /// A Boolean value indicating whether the dimension layer's content is visible. + @State private var dimensionLayerIsVisible = true + + /// A Boolean value indicating whether the dimension layer's definition expression is set. + @State private var definitionExpressionIsSet = false + /// The error shown in the error alert. @State private var error: Error? @@ -38,14 +44,18 @@ struct DisplayDimensionsView: View { VStack { Spacer() Group { - Toggle("Dimension Layer", isOn: Binding( - get: { dimensionLayer?.isVisible ?? false }, - set: { dimensionLayer?.isVisible = $0 } - )) - Toggle("Definition Expression:\nDimensions >= 450m", isOn: Binding( - get: { dimensionLayer?.definitionExpression == "DIMLENGTH >= 450" }, - set: { dimensionLayer?.definitionExpression = $0 ? "DIMLENGTH >= 450" : "" } - )) + Toggle("Dimension Layer", isOn: $dimensionLayerIsVisible) + .onChange(of: dimensionLayerIsVisible) { newValue in + dimensionLayer?.isVisible = newValue + } + + Toggle( + "Definition Expression:\nDimensions >= 450m", + isOn: $definitionExpressionIsSet + ) + .onChange(of: definitionExpressionIsSet) { newValue in + dimensionLayer?.definitionExpression = newValue ? "DIMLENGTH >= 450" : "" + } } .padding(8) .background(.ultraThinMaterial) diff --git a/Shared/Samples/Display dimensions/README.md b/Shared/Samples/Display dimensions/README.md index 154a268bf..2db55ae7f 100644 --- a/Shared/Samples/Display dimensions/README.md +++ b/Shared/Samples/Display dimensions/README.md @@ -33,7 +33,7 @@ This sample shows a subset of the network of pylons, substations, and power line ## Additional information -Dimension layers can be taken offline from a feature service hosted on ArcGIS Enterprise 10.9 or later, using the [GeodatabaseSyncTask](https://developers.arcgis.com/java/api-reference/reference/com/esri/arcgisruntime/tasks/geodatabase/GeodatabaseSyncTask.html). Dimension layers are also supported in mobile map packages or mobile geodatabases created in ArcGIS Pro 2.9 or later. +Dimension layers can be taken offline from a feature service hosted on ArcGIS Enterprise 10.9 or later, using the [GeodatabaseSyncTask](https://developers.arcgis.com/swift/api-reference/documentation/arcgis/geodatabasesynctask). Dimension layers are also supported in mobile map packages or mobile geodatabases created in ArcGIS Pro 2.9 or later. ## Tags diff --git a/Shared/Samples/Display map from portal item/DisplayMapFromPortalItemView.swift b/Shared/Samples/Display map from portal item/DisplayMapFromPortalItemView.swift index 36d54bd60..958d01c1d 100644 --- a/Shared/Samples/Display map from portal item/DisplayMapFromPortalItemView.swift +++ b/Shared/Samples/Display map from portal item/DisplayMapFromPortalItemView.swift @@ -43,7 +43,7 @@ struct DisplayMapFromPortalItemView: View { .toolbar { ToolbarItem(placement: .bottomBar) { Menu("Maps") { - Picker("", selection: $currentMap) { + Picker("Portal Item Map", selection: $currentMap) { ForEach(DisplayMapFromPortalItemView.mapOptions) { mapOption in Text(mapOption.title).tag(mapOption) } @@ -107,3 +107,9 @@ private extension PortalItem.ID { /// The portal item ID of the Geology of United States map. static var usGeology: Self { Self("92ad152b9da94dee89b9e387dfe21acd")! } } + +#Preview { + NavigationView { + DisplayMapFromPortalItemView() + } +} diff --git a/Shared/Samples/Display map/DisplayMapView.swift b/Shared/Samples/Display map/DisplayMapView.swift index 32803d6bb..e0901b0c5 100644 --- a/Shared/Samples/Display map/DisplayMapView.swift +++ b/Shared/Samples/Display map/DisplayMapView.swift @@ -24,3 +24,7 @@ struct DisplayMapView: View { MapView(map: map) } } + +#Preview { + DisplayMapView() +} diff --git a/Shared/Samples/Display overview map/DisplayOverviewMapView.swift b/Shared/Samples/Display overview map/DisplayOverviewMapView.swift index 56d166ee5..44cf857f5 100644 --- a/Shared/Samples/Display overview map/DisplayOverviewMapView.swift +++ b/Shared/Samples/Display overview map/DisplayOverviewMapView.swift @@ -57,3 +57,7 @@ struct DisplayOverviewMapView: View { private extension PortalItem.ID { static var northAmericaTouristAttractions: Self { Self("97ceed5cfc984b4399e23888f6252856")! } } + +#Preview { + DisplayOverviewMapView() +} diff --git a/Shared/Samples/Display points using clustering feature reduction/display-points-using-clustering-feature-reduction.png b/Shared/Samples/Display points using clustering feature reduction/display-points-using-clustering-feature-reduction.png deleted file mode 100644 index c5cc674be..000000000 Binary files a/Shared/Samples/Display points using clustering feature reduction/display-points-using-clustering-feature-reduction.png and /dev/null differ diff --git a/Shared/Samples/Display scene/DisplaySceneView.swift b/Shared/Samples/Display scene/DisplaySceneView.swift index b991ea003..3d9c19096 100644 --- a/Shared/Samples/Display scene/DisplaySceneView.swift +++ b/Shared/Samples/Display scene/DisplaySceneView.swift @@ -56,3 +56,7 @@ struct DisplaySceneView: View { SceneView(scene: scene) } } + +#Preview { + DisplaySceneView() +} diff --git a/Shared/Samples/Display web scene from portal item/DisplayWebSceneFromPortalItemView.swift b/Shared/Samples/Display web scene from portal item/DisplayWebSceneFromPortalItemView.swift index d426d10d0..afe7dda34 100644 --- a/Shared/Samples/Display web scene from portal item/DisplayWebSceneFromPortalItemView.swift +++ b/Shared/Samples/Display web scene from portal item/DisplayWebSceneFromPortalItemView.swift @@ -40,3 +40,7 @@ private extension PortalItem.ID { .init("c6f90b19164c4283884361005faea852")! } } + +#Preview { + DisplayWebSceneFromPortalItemView() +} diff --git a/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.MapPicker.swift b/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.MapPicker.swift index 70c423559..52ff3a9ce 100644 --- a/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.MapPicker.swift +++ b/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.MapPicker.swift @@ -21,7 +21,7 @@ extension DownloadPreplannedMapAreaView { @Environment(\.dismiss) private var dismiss /// The view model for the download preplanned map area view. - @EnvironmentObject private var model: Model + @ObservedObject var model: Model var body: some View { NavigationView { diff --git a/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.swift b/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.swift index cc9ca058f..9260ed5ac 100644 --- a/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.swift +++ b/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.swift @@ -38,8 +38,7 @@ struct DownloadPreplannedMapAreaView: View { isShowingSelectMapView.toggle() } .sheet(isPresented: $isShowingSelectMapView, detents: [.medium]) { - MapPicker() - .environmentObject(model) + MapPicker(model: model) } Spacer() @@ -84,3 +83,9 @@ private extension Viewpoint { return Viewpoint(boundingGeometry: zoomEnvelope) } } + +#Preview { + NavigationView { + DownloadPreplannedMapAreaView() + } +} diff --git a/Shared/Samples/Download vector tiles to local cache/DownloadVectorTilesToLocalCacheView.swift b/Shared/Samples/Download vector tiles to local cache/DownloadVectorTilesToLocalCacheView.swift index 4ae59a60b..266702e6a 100644 --- a/Shared/Samples/Download vector tiles to local cache/DownloadVectorTilesToLocalCacheView.swift +++ b/Shared/Samples/Download vector tiles to local cache/DownloadVectorTilesToLocalCacheView.swift @@ -19,12 +19,15 @@ struct DownloadVectorTilesToLocalCacheView: View { /// A Boolean value indicating whether to download vector tiles. @State private var isDownloading = false - /// A Boolean value indicating whether to cancel and the job. + /// A Boolean value indicating whether to cancel the job. @State private var isCancellingJob = false /// A Boolean value indicating whether to show the result map. @State private var isShowingResults = false + /// The map view's scale. + @State private var mapViewScale = Double.zero + /// The error shown in the error alert. @State private var error: Error? @@ -33,10 +36,10 @@ struct DownloadVectorTilesToLocalCacheView: View { var body: some View { GeometryReader { geometry in - MapViewReader { mapView in + MapViewReader { mapViewProxy in MapView(map: model.map) .interactionModes(isDownloading ? [] : [.pan, .zoom]) - .onScaleChanged { model.maxScale = $0 * 0.1 } + .onScaleChanged { mapViewScale = $0 } .errorAlert(presentingError: $error) .task { do { @@ -87,7 +90,7 @@ struct DownloadVectorTilesToLocalCacheView: View { Button("Download Vector Tiles") { isDownloading = true } - .disabled(model.exportVectorTilesTask == nil || isDownloading) + .disabled(!model.allowsDownloadingVectorTiles || isDownloading) .task(id: isDownloading) { // Ensures downloading is true. guard isDownloading else { return } @@ -103,20 +106,21 @@ struct DownloadVectorTilesToLocalCacheView: View { ) // Creates an envelope from the rectangle. - guard let extent = mapView.envelope(fromViewRect: viewRect) else { return } + guard let extent = mapViewProxy.envelope(fromViewRect: viewRect) else { return } // Downloads the vector tiles. do { - try await model.downloadVectorTiles(extent: extent) - // Sets show results to true. + // Sets downloading to false when the download + // finishes or errors occur. + defer { isDownloading = false } + // Sets the max scale to 10% of the map's scale to limit + // the number of tiles exported. + try await model.downloadVectorTiles(extent: extent, maxScale: mapViewScale * 0.1) + // Shows results when the download finishes. isShowingResults = true - // Sets downloading to false when the download finishes. - isDownloading = false } catch { // Shows an alert if any errors occur. self.error = error - // Sets downloading to false when the download finishes. - isDownloading = false } } .sheet(isPresented: $isShowingResults) { @@ -156,13 +160,13 @@ private extension DownloadVectorTilesToLocalCacheView { @MainActor class Model: ObservableObject { /// A map with a basemap from the vector tiled layer results. - var downloadedVectorTilesMap: Map! + private(set) var downloadedVectorTilesMap: Map! /// The export vector tiles job. - @Published var exportVectorTilesJob: ExportVectorTilesJob! + @Published private(set) var exportVectorTilesJob: ExportVectorTilesJob! /// The export vector tiles task. - @Published var exportVectorTilesTask: ExportVectorTilesTask! + @Published private(set) var exportVectorTilesTask: ExportVectorTilesTask! /// The vector tiled layer from the downloaded result. private var vectorTiledLayerResults: ArcGISVectorTiledLayer! @@ -176,17 +180,29 @@ private extension DownloadVectorTilesToLocalCacheView { /// A URL to the temporary directory to store the style item resources. private let styleTemporaryURL: URL - /// The max scale for the export vector tiles job. - var maxScale: Double? + /// A Boolean value indicating whether the export task can be started. + var allowsDownloadingVectorTiles: Bool { + if let exportVectorTilesTask, + // Only allows downloading when the task is loaded. + exportVectorTilesTask.loadStatus == .loaded, + // Ensures that the service allows exporting vector tiles. + let vectorTileSourceInfo = exportVectorTilesTask.vectorTileSourceInfo { + return vectorTileSourceInfo.allowsExportingTiles + } else { + return false + } + } /// A map with a night streets basemap style and an initial viewpoint. - let map: Map + let map: Map = { + let map = Map(basemapStyle: .arcGISStreetsNight) + map.initialViewpoint = Viewpoint(latitude: 34.049, longitude: -117.181, scale: 1e4) + // Sets the min scale to avoid requesting a huge download. + map.minScale = 1e4 + return map + }() init() { - // Initializes the map. - map = Map(basemapStyle: .arcGISStreetsNight) - map.initialViewpoint = Viewpoint(latitude: 34.049, longitude: -117.181, scale: 1e4) - // Initializes the URL for the directory containing vector tile packages. vtpkTemporaryURL = temporaryDirectory .appendingPathComponent("myTileCache") @@ -217,50 +233,48 @@ private extension DownloadVectorTilesToLocalCacheView { self.exportVectorTilesTask = exportVectorTilesTask } - /// Downloads the vector tiles within the area of interest. - /// - Parameter extent: The area of interest's envelope to download vector tiles. - func downloadVectorTiles(extent: Envelope) async throws { - // Ensures that exporting vector tiles is allowed. - if let vectorTileSourceInfo = exportVectorTilesTask.vectorTileSourceInfo, - vectorTileSourceInfo.allowsExportingTiles, - let maxScale = maxScale { - // Creates the parameters for the export vector tiles job. - let parameters = try await exportVectorTilesTask.makeDefaultExportVectorTilesParameters( - areaOfInterest: extent, - maxScale: maxScale - ) - - // Creates the export vector tiles job based on the parameters - // and temporary URLs. - exportVectorTilesJob = exportVectorTilesTask.makeExportVectorTilesJob( - parameters: parameters, - vectorTileCacheURL: vtpkTemporaryURL, - itemResourceCacheURL: styleTemporaryURL + /// Downloads the vector tiles within the area of interest at given scale. + /// - Parameters: + /// - extent: The area of interest's envelope to export vector tiles. + /// - maxScale: The map scale which determines how far in to export + /// the vector tiles. Set to `0` to include all levels of detail. + func downloadVectorTiles(extent: Envelope, maxScale: Double) async throws { + // Creates the parameters for the export vector tiles job. + let parameters = try await exportVectorTilesTask.makeDefaultExportVectorTilesParameters( + areaOfInterest: extent, + maxScale: maxScale + ) + + // Creates the export vector tiles job based on the parameters + // and temporary URLs. + exportVectorTilesJob = exportVectorTilesTask.makeExportVectorTilesJob( + parameters: parameters, + vectorTileCacheURL: vtpkTemporaryURL, + itemResourceCacheURL: styleTemporaryURL + ) + + // Starts the job. + exportVectorTilesJob.start() + + defer { exportVectorTilesJob = nil } + + // Awaits the output of the job. + let output = try await exportVectorTilesJob.output + + // Gets the vector tile and item resource cache from the output. + if let vectorTileCache = output.vectorTileCache, + let itemResourceCache = output.itemResourceCache { + // Creates a vector tiled layer from the caches. + vectorTiledLayerResults = ArcGISVectorTiledLayer( + vectorTileCache: vectorTileCache, + itemResourceCache: itemResourceCache ) - // Starts the job. - exportVectorTilesJob.start() + // Creates a map with a basemap from the vector tiled layer results. + downloadedVectorTilesMap = Map(basemap: Basemap(baseLayer: vectorTiledLayerResults)) - defer { exportVectorTilesJob = nil } - - // Awaits the output of the job. - let output = try await exportVectorTilesJob.output - - // Gets the vector tile and item resource cache from the output. - if let vectorTileCache = output.vectorTileCache, - let itemResourceCache = output.itemResourceCache { - // Creates a vector tiled layer from the caches. - vectorTiledLayerResults = ArcGISVectorTiledLayer( - vectorTileCache: vectorTileCache, - itemResourceCache: itemResourceCache - ) - - // Creates a map with a basemap from the vector tiled layer results. - downloadedVectorTilesMap = Map(basemap: Basemap(baseLayer: vectorTiledLayerResults)) - - // Sets the initial viewpoint of the result map. - downloadedVectorTilesMap.initialViewpoint = Viewpoint(boundingGeometry: extent.expanded(by: 0.9)) - } + // Sets the initial viewpoint of the result map. + downloadedVectorTilesMap.initialViewpoint = Viewpoint(boundingGeometry: extent.expanded(by: 0.9)) } } @@ -290,19 +304,6 @@ private extension DownloadVectorTilesToLocalCacheView { } } -private extension MapViewProxy { - /// Creates an envelope from the given rectangle. - /// - Parameter viewRect: The rectangle to create an envelope of. - /// - Returns: An envelope of the given rectangle. - func envelope(fromViewRect viewRect: CGRect) -> Envelope? { - guard let min = location(fromScreenPoint: CGPoint(x: viewRect.minX, y: viewRect.minY)), - let max = location(fromScreenPoint: CGPoint(x: viewRect.maxX, y: viewRect.maxY)) else { - return nil - } - return Envelope(min: min, max: max) - } -} - private extension Envelope { /// Expands the envelope by a given factor. func expanded(by factor: Double) -> Envelope { @@ -311,3 +312,9 @@ private extension Envelope { return builder.toGeometry() } } + +#Preview { + NavigationView { + DownloadVectorTilesToLocalCacheView() + } +} diff --git a/Shared/Samples/Filter features in scene/FilterFeaturesInSceneView.swift b/Shared/Samples/Filter features in scene/FilterFeaturesInSceneView.swift index 02921f48e..b872576c0 100644 --- a/Shared/Samples/Filter features in scene/FilterFeaturesInSceneView.swift +++ b/Shared/Samples/Filter features in scene/FilterFeaturesInSceneView.swift @@ -32,7 +32,7 @@ struct FilterFeaturesInSceneView: View { } } .toolbar { - ToolbarItemGroup(placement: .bottomBar) { + ToolbarItem(placement: .bottomBar) { Button(model.filterState.label) { model.handleFilterState() } @@ -234,3 +234,9 @@ private extension Viewpoint { ) ) } + +#Preview { + NavigationView { + FilterFeaturesInSceneView() + } +} diff --git a/Shared/Samples/Find address with reverse geocode/FindAddressWithReverseGeocodeView.swift b/Shared/Samples/Find address with reverse geocode/FindAddressWithReverseGeocodeView.swift index 2acd3253d..e95088fa1 100644 --- a/Shared/Samples/Find address with reverse geocode/FindAddressWithReverseGeocodeView.swift +++ b/Shared/Samples/Find address with reverse geocode/FindAddressWithReverseGeocodeView.swift @@ -155,3 +155,7 @@ private extension URL { URL(string: "https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer")! } } + +#Preview { + FindAddressWithReverseGeocodeView() +} diff --git a/Shared/Samples/Find closest facility from point/FindClosestFacilityFromPointView.swift b/Shared/Samples/Find closest facility from point/FindClosestFacilityFromPointView.swift index fc5c9dad6..939323300 100644 --- a/Shared/Samples/Find closest facility from point/FindClosestFacilityFromPointView.swift +++ b/Shared/Samples/Find closest facility from point/FindClosestFacilityFromPointView.swift @@ -228,3 +228,9 @@ private extension URL { URL(string: "https://static.arcgis.com/images/Symbols/SafetyHealth/esriCrimeMarker_56_Gradient.png")! } } + +#Preview { + NavigationView { + FindClosestFacilityFromPointView() + } +} diff --git a/Shared/Samples/Find closest facility to multiple points/FindClosestFacilityToMultiplePointsView.swift b/Shared/Samples/Find closest facility to multiple points/FindClosestFacilityToMultiplePointsView.swift index c54615b81..f2c2b5910 100644 --- a/Shared/Samples/Find closest facility to multiple points/FindClosestFacilityToMultiplePointsView.swift +++ b/Shared/Samples/Find closest facility to multiple points/FindClosestFacilityToMultiplePointsView.swift @@ -183,3 +183,7 @@ private extension URL { URL(string: "https://static.arcgis.com/images/Symbols/SafetyHealth/Hospital.png")! } } + +#Preview { + FindClosestFacilityToMultiplePointsView() +} diff --git a/Shared/Samples/Find nearest vertex/FindNearestVertexView.swift b/Shared/Samples/Find nearest vertex/FindNearestVertexView.swift index 21cb27a30..d8298be61 100644 --- a/Shared/Samples/Find nearest vertex/FindNearestVertexView.swift +++ b/Shared/Samples/Find nearest vertex/FindNearestVertexView.swift @@ -188,3 +188,7 @@ private extension PortalItem.ID { /// The ID used in the "US States Generalized" portal item. static var usStatesGeneralized: Self { Self("8c2d6d7df8fa4142b0a1211c8dd66903")! } } + +#Preview { + FindNearestVertexView() +} diff --git a/Shared/Samples/Find route around barriers/FindRouteAroundBarriersView.Views.swift b/Shared/Samples/Find route around barriers/FindRouteAroundBarriersView.Views.swift index 08b3bc194..62a4ac8ba 100644 --- a/Shared/Samples/Find route around barriers/FindRouteAroundBarriersView.Views.swift +++ b/Shared/Samples/Find route around barriers/FindRouteAroundBarriersView.Views.swift @@ -16,43 +16,47 @@ import ArcGIS import SwiftUI extension FindRouteAroundBarriersView { - /// The list of settings for the sample. - struct SettingsList: View { - /// The view model for the sample. - @EnvironmentObject private var model: Model + /// A list of settings for modifying route parameters. + struct RouteParametersSettings: View { + /// The route parameters to modify. + private let routeParameters: RouteParameters /// A Boolean value indicating whether routing will find the best sequence. - @State private var routingFindsBestSequence = false + @State private var routingFindsBestSequence: Bool + + /// A Boolean value indicating whether routing will preserve the first stop. + @State private var routePreservesFirstStop: Bool + + /// A Boolean value indicating whether routing will preserve the last stop. + @State private var routePreservesLastStop: Bool + + init(for routeParameters: RouteParameters) { + self.routeParameters = routeParameters + self.routingFindsBestSequence = routeParameters.findsBestSequence + self.routePreservesFirstStop = routeParameters.preservesFirstStop + self.routePreservesLastStop = routeParameters.preservesLastStop + } var body: some View { List { - Toggle(isOn: $routingFindsBestSequence) { - Text("Find Best Sequence") - } - .onChange(of: routingFindsBestSequence) { newValue in - model.routeParameters.findsBestSequence = newValue - } + Toggle("Find Best Sequence", isOn: $routingFindsBestSequence) + .onChange(of: routingFindsBestSequence) { newValue in + routeParameters.findsBestSequence = newValue + } Section { - Toggle(isOn: Binding( - get: { model.routeParameters.preservesFirstStop }, - set: { model.routeParameters.preservesFirstStop = $0 } - )) { - Text("Preserve First Stop") - } + Toggle("Preserve First Stop", isOn: $routePreservesFirstStop) + .onChange(of: routePreservesFirstStop) { newValue in + routeParameters.preservesFirstStop = newValue + } - Toggle(isOn: Binding( - get: { model.routeParameters.preservesLastStop }, - set: { model.routeParameters.preservesLastStop = $0 } - )) { - Text("Preserve Last Stop") - } + Toggle("Preserve Last Stop", isOn: $routePreservesLastStop) + .onChange(of: routePreservesLastStop) { newValue in + routeParameters.preservesLastStop = newValue + } } .disabled(!routingFindsBestSequence) } - .onAppear { - routingFindsBestSequence = model.routeParameters.findsBestSequence - } } } diff --git a/Shared/Samples/Find route around barriers/FindRouteAroundBarriersView.swift b/Shared/Samples/Find route around barriers/FindRouteAroundBarriersView.swift index 3f04a4d53..911ff7d80 100644 --- a/Shared/Samples/Find route around barriers/FindRouteAroundBarriersView.swift +++ b/Shared/Samples/Find route around barriers/FindRouteAroundBarriersView.swift @@ -118,9 +118,8 @@ struct FindRouteAroundBarriersView: View { .labelsHidden() Spacer() - SheetButton(title: "Settings") { - SettingsList() - .environmentObject(model) + SheetButton(title: "Route Settings") { + RouteParametersSettings(for: model.routeParameters) } label: { Image(systemName: "gear") } @@ -150,3 +149,9 @@ struct FindRouteAroundBarriersView: View { .errorAlert(presentingError: $error) } } + +#Preview { + NavigationView { + FindRouteAroundBarriersView() + } +} diff --git a/Shared/Samples/Find route in mobile map package/FindRouteInMobileMapPackageView.swift b/Shared/Samples/Find route in mobile map package/FindRouteInMobileMapPackageView.swift index 89827397d..3873d4e0e 100644 --- a/Shared/Samples/Find route in mobile map package/FindRouteInMobileMapPackageView.swift +++ b/Shared/Samples/Find route in mobile map package/FindRouteInMobileMapPackageView.swift @@ -39,7 +39,7 @@ struct FindRouteInMobileMapPackageView: View { } .toolbar { // The button used to import mobile map packages. - ToolbarItemGroup(placement: .bottomBar) { + ToolbarItem(placement: .bottomBar) { Button("Add Package") { fileImporterIsShowing = true } @@ -101,26 +101,30 @@ private extension FindRouteInMobileMapPackageView { .resizable() .scaledToFit() .frame(height: 50) - .overlay { - // The symbols indicating the map's functionality. - VStack { - HStack { - if !map.transportationNetworks.isEmpty { - // The symbol indicating whether the map can route. - Image(systemName: "arrow.triangle.turn.up.right.circle") - } - Spacer() - if mapPackage.locatorTask != nil { - // The symbol indicating whether the map can geocode. + + VStack(alignment: .leading, spacing: 2) { + Text(mapName) + + HStack { + // The symbol indicating whether the map can geocode. + if mapPackage.locatorTask != nil { + HStack(spacing: 2) { Image(systemName: "mappin.circle") + Text("Geocoding") + } + } + + // The symbol indicating whether the map can route. + if !map.transportationNetworks.isEmpty { + HStack(spacing: 2) { + Image(systemName: "arrow.triangle.turn.up.right.circle") + Text("Routing") } } - .padding(2) - Spacer() } + .font(.caption2) + .foregroundStyle(.secondary) } - - Text(mapName) } } } diff --git a/Shared/Samples/Find route in mobile map package/find-route-in-mobile-map-package-1.png b/Shared/Samples/Find route in mobile map package/find-route-in-mobile-map-package-1.png index d4fca3bca..40f14ac3d 100644 Binary files a/Shared/Samples/Find route in mobile map package/find-route-in-mobile-map-package-1.png and b/Shared/Samples/Find route in mobile map package/find-route-in-mobile-map-package-1.png differ diff --git a/Shared/Samples/Find route/FindRouteView.swift b/Shared/Samples/Find route/FindRouteView.swift index e59975e79..0400db5ec 100644 --- a/Shared/Samples/Find route/FindRouteView.swift +++ b/Shared/Samples/Find route/FindRouteView.swift @@ -215,3 +215,9 @@ private extension URL { URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/NetworkAnalysis/SanDiego/NAServer/Route")! } } + +#Preview { + NavigationView { + FindRouteView() + } +} diff --git a/Shared/Samples/Generate offline map/GenerateOfflineMapView.swift b/Shared/Samples/Generate offline map/GenerateOfflineMapView.swift index 510fcc99c..8103690b3 100644 --- a/Shared/Samples/Generate offline map/GenerateOfflineMapView.swift +++ b/Shared/Samples/Generate offline map/GenerateOfflineMapView.swift @@ -22,6 +22,9 @@ struct GenerateOfflineMapView: View { /// 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: Error? + /// The view model for this sample. @StateObject private var model = Model() @@ -30,9 +33,13 @@ struct GenerateOfflineMapView: View { MapViewReader { mapView in MapView(map: model.offlineMap ?? model.onlineMap) .interactionModes(isGeneratingOfflineMap ? [] : [.pan, .zoom]) - .errorAlert(presentingError: $model.error) + .errorAlert(presentingError: $error) .task { - await model.initializeOfflineMapTask() + do { + try await model.initializeOfflineMapTask() + } catch { + self.error = error + } } .onDisappear { Task { await model.cancelJob() } @@ -103,8 +110,12 @@ struct GenerateOfflineMapView: View { // Creates an envelope from the rectangle. guard let extent = mapView.envelope(fromViewRect: viewRect) else { return } - // Generates an offline map. - await model.generateOfflineMap(extent: extent) + do { + // Generates an offline map. + try await model.generateOfflineMap(extent: extent) + } catch { + self.error = error + } // Sets generating an offline map to false. isGeneratingOfflineMap = false @@ -122,16 +133,13 @@ private extension GenerateOfflineMapView { @MainActor class Model: ObservableObject { /// The offline map that is generated. - @Published var offlineMap: Map! + @Published private(set) var offlineMap: Map! /// A Boolean value indicating whether the generate button is disabled. - @Published var isGenerateDisabled = true - - /// The error shown in the error alert. - @Published var error: Error? + @Published private(set) var isGenerateDisabled = true /// The generate offline map job. - @Published var generateOfflineMapJob: GenerateOfflineMapJob! + @Published private(set) var generateOfflineMapJob: GenerateOfflineMapJob! /// The offline map task. private var offlineMapTask: OfflineMapTask! @@ -151,6 +159,8 @@ private extension GenerateOfflineMapView { init() { // Initializes the online map. onlineMap = Map(item: napervillePortalItem) + // Sets the min scale to avoid requesting a huge download. + onlineMap.minScale = 1e4 } deinit { @@ -159,41 +169,29 @@ private extension GenerateOfflineMapView { } /// Initializes the offline map task. - func initializeOfflineMapTask() async { - do { - // Waits for the online map to load. - try await onlineMap.load() - offlineMapTask = OfflineMapTask(onlineMap: onlineMap) - isGenerateDisabled = false - } catch { - self.error = error - } + 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. Otherwise, it returns `nil`, - private func makeGenerateOfflineMapParameters(areaOfInterest: Envelope) async -> GenerateOfflineMapParameters? { - do { - // Returns the default parameters for the offline map task. - return try await offlineMapTask.makeDefaultGenerateOfflineMapParameters(areaOfInterest: areaOfInterest) - } catch { - self.error = error - return nil - } + /// - 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) } /// Generates the offline map. /// - Parameter extent: The area of interest's envelope to generate an offline map for. - func generateOfflineMap(extent: Envelope) async { + func generateOfflineMap(extent: Envelope) async throws { // Disables the generate offline map button. isGenerateDisabled = true // Creates the default parameters for the offline map task. - guard let parameters = await makeGenerateOfflineMapParameters(areaOfInterest: extent) else { - isGenerateDisabled = false - return - } + let parameters = try await makeGenerateOfflineMapParameters(areaOfInterest: extent) // Creates the generate offline map job based on the parameters. generateOfflineMapJob = offlineMapTask.makeGenerateOfflineMapJob( @@ -209,17 +207,12 @@ private extension GenerateOfflineMapView { isGenerateDisabled = offlineMap != nil } - do { - // 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)) - } catch { - // Shows an alert with the error if the job fails. - self.error = error - } + // 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. @@ -242,19 +235,6 @@ private extension GenerateOfflineMapView { } } -private extension MapViewProxy { - /// Creates an envelope from the given rectangle. - /// - Parameter viewRect: The rectangle to create an envelope of. - /// - Returns: An envelope of the given rectangle. - func envelope(fromViewRect viewRect: CGRect) -> Envelope? { - guard let min = location(fromScreenPoint: CGPoint(x: viewRect.minX, y: viewRect.minY)), - let max = location(fromScreenPoint: CGPoint(x: viewRect.maxX, y: viewRect.maxY)) else { - return nil - } - return Envelope(min: min, max: max) - } -} - private extension Envelope { /// Expands the envelope by a given factor. func expanded(by factor: Double) -> Envelope { @@ -263,3 +243,9 @@ private extension Envelope { return builder.toGeometry() } } + +#Preview { + NavigationView { + GenerateOfflineMapView() + } +} diff --git a/Shared/Samples/Geocode offline/GeocodeOfflineView.swift b/Shared/Samples/Geocode offline/GeocodeOfflineView.swift index 5ae47e498..3e8265e7e 100644 --- a/Shared/Samples/Geocode offline/GeocodeOfflineView.swift +++ b/Shared/Samples/Geocode offline/GeocodeOfflineView.swift @@ -44,8 +44,7 @@ struct GeocodeOfflineView: View { ] var body: some View { - GeocodeMapView(viewpoint: $viewpoint) - .environmentObject(model) + GeocodeMapView(model: model, viewpoint: $viewpoint) .searchable(text: $searchText, prompt: "Type in an address") .onSubmit(of: .search) { submittedSearchText = searchText @@ -90,7 +89,7 @@ private extension GeocodeOfflineView { /// The map view for the sample. struct GeocodeMapView: View { /// The view model for the sample. - @EnvironmentObject private var model: Model + @ObservedObject var model: Model /// The action that ends the current search interaction. @Environment(\.dismissSearch) private var dismissSearch diff --git a/Shared/Samples/Get elevation at point on surface/GetElevationAtPointOnSurfaceView.swift b/Shared/Samples/Get elevation at point on surface/GetElevationAtPointOnSurfaceView.swift index b802777a5..e48f0ca77 100644 --- a/Shared/Samples/Get elevation at point on surface/GetElevationAtPointOnSurfaceView.swift +++ b/Shared/Samples/Get elevation at point on surface/GetElevationAtPointOnSurfaceView.swift @@ -129,3 +129,7 @@ private extension ElevationSource { .init(url: URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")!) } } + +#Preview { + GetElevationAtPointOnSurfaceView() +} diff --git a/Shared/Samples/Group layers together/GroupLayersTogetherView.GroupLayerListView.swift b/Shared/Samples/Group layers together/GroupLayersTogetherView.GroupLayerListView.swift index d702ba14b..1104fbbd4 100644 --- a/Shared/Samples/Group layers together/GroupLayersTogetherView.GroupLayerListView.swift +++ b/Shared/Samples/Group layers together/GroupLayersTogetherView.GroupLayerListView.swift @@ -34,9 +34,7 @@ extension GroupLayersTogetherView { case .independent: // Toggles for sublayers that can change their visibility independently. ForEach(groupLayer.layers, id: \.name) { layer in - Toggle(isOn: isVisibleBinding(for: layer)) { - Text(formatLayerName(of: layer.name)) - } + LayerVisibilityToggle(formatLayerName(of: layer.name), layer: layer) } case .exclusive: @@ -64,10 +62,7 @@ extension GroupLayersTogetherView { } .disabled(isDisabled) } header: { - // The group layer visibility toggle. - Toggle(isOn: isVisibleBinding(for: groupLayer)) { - Text(groupLayer.name) - } + LayerVisibilityToggle(groupLayer.name, layer: groupLayer) } .task { // Listen for changes to is visible to disable the section @@ -86,16 +81,6 @@ extension GroupLayersTogetherView { } } - /// Creates a custom binding for toggling a layers's visibility. - /// - Parameter layer: The `Layer` to create the `Binding` from. - /// - Returns: The new custom `Binding` object. - private func isVisibleBinding(for layer: Layer) -> Binding { - return Binding( - get: { layer.isVisible }, - set: { layer.isVisible = $0 } - ) - } - /// Formats a layer's name to be more human readable. /// - Parameter name: The original `String` name of the layer. /// - Returns: A `String` with the modified name or the original if the name is not found. @@ -116,4 +101,33 @@ extension GroupLayersTogetherView { } } } + + /// A toggle for changing a given layer's visibility. + private struct LayerVisibilityToggle: View { + /// The title of the toggle. + private let title: String + + /// The layer with the visibility to change. + private let layer: Layer + + /// A Boolean value indicating whether the layer's content is visible. + @State private var isVisible: Bool + + /// Creates the toggle for changing a given layer's visibility. + /// - Parameters: + /// - title: A string for the title of the toggle. + /// - layer: The layer with the visibility to change. + init(_ title: String, layer: Layer) { + self.title = title + self.layer = layer + self.isVisible = layer.isVisible + } + + var body: some View { + Toggle(title, isOn: $isVisible) + .onChange(of: isVisible) { newValue in + layer.isVisible = newValue + } + } + } } diff --git a/Shared/Samples/Group layers together/GroupLayersTogetherView.swift b/Shared/Samples/Group layers together/GroupLayersTogetherView.swift index 580785676..7a3445e50 100644 --- a/Shared/Samples/Group layers together/GroupLayersTogetherView.swift +++ b/Shared/Samples/Group layers together/GroupLayersTogetherView.swift @@ -185,3 +185,9 @@ private extension URL { URL(string: "https://tiles.arcgis.com/tiles/P3ePLMYs2RVChkJx/arcgis/rest/services/DevB_BuildingShells/SceneServer")! } } + +#Preview { + NavigationView { + GroupLayersTogetherView() + } +} diff --git a/Shared/Samples/Identify KML features/IdentifyKMLFeaturesView.swift b/Shared/Samples/Identify KML features/IdentifyKMLFeaturesView.swift index d9ad2b4dc..6cd7f6cce 100644 --- a/Shared/Samples/Identify KML features/IdentifyKMLFeaturesView.swift +++ b/Shared/Samples/Identify KML features/IdentifyKMLFeaturesView.swift @@ -134,3 +134,7 @@ private extension URL { URL(string: "https://www.wpc.ncep.noaa.gov/kml/noaa_chart/WPC_Day1_SigWx_latest.kml")! } } + +#Preview { + IdentifyKMLFeaturesView() +} diff --git a/Shared/Samples/Identify graphics/IdentifyGraphicsView.swift b/Shared/Samples/Identify graphics/IdentifyGraphicsView.swift index 756e8355d..6b2583e9f 100644 --- a/Shared/Samples/Identify graphics/IdentifyGraphicsView.swift +++ b/Shared/Samples/Identify graphics/IdentifyGraphicsView.swift @@ -103,3 +103,7 @@ private extension Collection { !self.isEmpty } } + +#Preview { + IdentifyGraphicsView() +} diff --git a/Shared/Samples/Identify layer features/IdentifyLayerFeaturesView.swift b/Shared/Samples/Identify layer features/IdentifyLayerFeaturesView.swift index 63dff2957..0323a94df 100644 --- a/Shared/Samples/Identify layer features/IdentifyLayerFeaturesView.swift +++ b/Shared/Samples/Identify layer features/IdentifyLayerFeaturesView.swift @@ -134,3 +134,7 @@ private extension URL { URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/DamageAssessment/FeatureServer/0")! } } + +#Preview { + IdentifyLayerFeaturesView() +} diff --git a/Shared/Samples/List spatial reference transformations/ListSpatialReferenceTransformationsView.Model.swift b/Shared/Samples/List spatial reference transformations/ListSpatialReferenceTransformationsView.Model.swift new file mode 100644 index 000000000..fc25d5c41 --- /dev/null +++ b/Shared/Samples/List spatial reference transformations/ListSpatialReferenceTransformationsView.Model.swift @@ -0,0 +1,141 @@ +// 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 ArcGIS +import SwiftUI + +extension ListSpatialReferenceTransformationsView { + /// The view model for the sample. + class Model: ObservableObject { + // MARK: Properties + + /// A map with a light grey basemap centered on Royal Observatory, Greenwich, UK. + let map: Map = { + let map = Map(basemapStyle: .arcGISLightGray) + map.initialViewpoint = Viewpoint(center: .originalGeometry, scale: 5e3) + return map + }() + + /// The graphics overlay containing the graphics for the geometries. + let graphicsOverlay: GraphicsOverlay = { + // Create a red square graphic for the original geometry. + let redSquareSymbol = SimpleMarkerSymbol(style: .square, color: .red, size: 20) + let originalGraphic = Graphic(geometry: .originalGeometry, symbol: redSquareSymbol) + + // Create a blue cross graphic for the projected geometry. + let blueCrossSymbol = SimpleMarkerSymbol(style: .cross, color: .blue, size: 20) + let projectedGraphic = Graphic(symbol: blueCrossSymbol) + + return GraphicsOverlay(graphics: [originalGraphic, projectedGraphic]) + }() + + /// The geometry of the projected graphic, i.e., the last graphic in the graphics overlay. + private var projectedGeometry: Geometry? { + get { graphicsOverlay.graphics.last!.geometry } + set { graphicsOverlay.graphics.last!.geometry = newValue } + } + + /// The list of transformations suitable for projecting between the original geometry's and the map's spatial references. + @Published private(set) var transformations: [GeographicTransformation] = [] + + /// The transformation selected by the user. + @Published private(set) var selectedTransformation: GeographicTransformation? + + // MARK: Methods + + /// Selects a given transformation and projects the geometry accordingly. + /// - Parameter transformation: The transformation. + func selectTransformation(_ transformation: GeographicTransformation) { + // Project the original geometry using the transformation. + let outputSpatialReference = map.spatialReference! + + projectedGeometry = GeometryEngine.project( + .originalGeometry, + into: outputSpatialReference, + datumTransformation: transformation + ) + selectedTransformation = transformation + } + + /// Removes the current transformation selection and projection graphic. + func removeSelection() { + selectedTransformation = nil + projectedGeometry = nil + } + + /// The list of Projection Engine files that are missing from the local file system for a given transformation. + /// - Parameter transformation: The transformation. + /// - Returns: The filenames. + func missingProjectionEngineFilenames( + for transformation: GeographicTransformation + ) -> [String] { + // Get the missing projection engine filenames for each step. + let missingFilenames = transformation.steps.compactMap { step in + step.isMissingProjectionEngineFiles + ? step.projectionEngineFilenames.joined(separator: ", ") + : nil + } + + return missingFilenames + } + + /// Updates the transformations list using the transformation catalog. + /// - Parameter extent: The bounding box of coordinates to be transformed. + func updateTransformationsList(withExtent extent: Envelope? = nil) { + // Get the input and output spatial references. + let inputSpatialReference = Geometry.originalGeometry.spatialReference! + let outputSpatialReference = map.spatialReference! + + // Get the transformations from the transformation catalog. + transformations = TransformationCatalog.transformations( + from: inputSpatialReference, + to: outputSpatialReference, + areaOfInterest: extent, + ignoreVertical: true + ) as! [GeographicTransformation] + + // Remove the selection if it is not in the new list. + guard let selectedTransformation, + !transformations.contains(selectedTransformation) else { return } + + removeSelection() + } + + /// Sets the URL to the directory of the Projection Engine files to be used by the transformation catalog. + /// - Parameter url: The path to the directory. + func setProjectionEngineDataURL(_ url: URL) throws { + // Start accessing the URL. + guard url.startAccessingSecurityScopedResource() else { return } + + // Stop accessing the last URL. + TransformationCatalog.projectionEngineDirectoryURL?.stopAccessingSecurityScopedResource() + + // Set the transformation catalog's projection engine directory URL. + // Normally, this method would be called immediately upon application startup before any + // other API method calls, but for the purposes of this sample, it is being called here. + try TransformationCatalog.setProjectionEngineDirectoryURL(url) + + // Update the transformations list. + removeSelection() + updateTransformationsList() + } + } +} + +private extension Geometry { + /// The starting point for the spatial reference projections. + static var originalGeometry: Point { + Point(x: 538_985, y: 177_329, spatialReference: .init(wkid: WKID(27700)!)) + } +} diff --git a/Shared/Samples/List spatial reference transformations/ListSpatialReferenceTransformationsView.swift b/Shared/Samples/List spatial reference transformations/ListSpatialReferenceTransformationsView.swift new file mode 100644 index 000000000..6bcabf46e --- /dev/null +++ b/Shared/Samples/List spatial reference transformations/ListSpatialReferenceTransformationsView.swift @@ -0,0 +1,172 @@ +// 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 ArcGIS +import SwiftUI + +struct ListSpatialReferenceTransformationsView: View { + /// The view model for the sample. + @StateObject private var model = Model() + + /// The visible area of the map view. + @State private var visibleArea: ArcGIS.Polygon? + + /// A Boolean value indicating whether the transformations list should be filtered using the current map extent. + @State private var filterByMapExtent = false + + /// A Boolean value indicating whether the file importer interface is presented. + @State private var fileImporterIsPresented = false + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + VStack(spacing: 0) { + MapView(map: model.map, graphicsOverlays: [model.graphicsOverlay]) + .onVisibleAreaChanged { visibleArea = $0 } + .task { + // Set the transformations list once the map's spatial reference has loaded. + do { + try await model.map.load() + model.updateTransformationsList() + } catch { + self.error = error + } + } + .errorAlert(presentingError: $error) + + NavigationView { + TransformationsList(model: model) + .navigationTitle("Transformations") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + transformationsMenu + } + } + } + .navigationViewStyle(.stack) + } + } + + /// A menu containing actions relating to the list of transformations. + private var transformationsMenu: some View { + Menu("Transformations", systemImage: "ellipsis") { + Picker("Filter Transformations", selection: $filterByMapExtent) { + Label("All Transformations", systemImage: "square.grid.2x2") + .tag(false) + Label("Suitable for Extent", systemImage: "line.3.horizontal.decrease.circle") + .tag(true) + } + .pickerStyle(.inline) + .onChange(of: filterByMapExtent) { newValue in + model.updateTransformationsList(withExtent: newValue ? visibleArea?.extent : nil) + } + + Link(destination: .projectionEngineDataDownloads) { + Label("Download Data", systemImage: "arrow.down.circle") + } + + Button("Set Data Directory", systemImage: "folder") { + fileImporterIsPresented = true + } + } + .fileImporter( + isPresented: $fileImporterIsPresented, + allowedContentTypes: [.folder] + ) { result in + do { + switch result { + case .success(let fileURL): + try model.setProjectionEngineDataURL(fileURL) + case .failure(let error): + throw error + } + } catch { + self.error = error + } + } + } +} + +private extension ListSpatialReferenceTransformationsView { + struct TransformationsList: View { + /// The view model for the sample. + @ObservedObject var model: Model + + /// The missing Projection Engine filenames for the tapped transformation. + @State private var missingFilenames: [String] = [] + + var body: some View { + List(model.transformations, id: \.self) { transformation in + Button { + if transformation.isMissingProjectionEngineFiles { + missingFilenames = model.missingProjectionEngineFilenames( + for: transformation + ) + } else { + model.selectTransformation(transformation) + } + } label: { + VStack(alignment: .leading) { + HStack { + Text(transformation.name.replacingOccurrences(of: "_", with: " ")) + Spacer() + if transformation == model.selectedTransformation { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + + if transformation.isMissingProjectionEngineFiles { + Text("Missing Grid Files") + .font(.caption) + .opacity(0.75) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .alert( + "Missing Grid Files:", + isPresented: Binding( + get: { !missingFilenames.isEmpty }, + set: { _ in missingFilenames = [] }), + presenting: missingFilenames, + actions: { _ in }, + message: { filenames in + let message = """ + \(filenames.joined(separator: ",\n")) + + See the README file for instructions on adding Projection Engine data to the app. + """ + + Text(message) + } + ) + } + } +} + +private extension URL { + /// A URL to the Projection Engine Data Downloads on ArcGIS for Developers. + static var projectionEngineDataDownloads: URL { + URL(string: "https://developers.arcgis.com/downloads/#pedata")! + } +} + +#Preview { + ListSpatialReferenceTransformationsView() +} diff --git a/Shared/Samples/List spatial reference transformations/README.md b/Shared/Samples/List spatial reference transformations/README.md new file mode 100644 index 000000000..b00a7c12b --- /dev/null +++ b/Shared/Samples/List spatial reference transformations/README.md @@ -0,0 +1,49 @@ +# List spatial reference transformations + +Get a list of suitable transformations for projecting a geometry between two spatial references with different horizontal datums. + +![Image of List spatial reference transformations](list-spatial-reference-transformations.png) + +## Use case + +Transformations (sometimes known as datum or geographic transformations) are used when projecting data from one spatial reference to another when there is a difference in the underlying datum of the spatial references. Transformations can be mathematically defined by specific equations (equation-based transformations) or may rely on external supporting files (grid-based transformations). Choosing the most appropriate transformation for a situation can ensure the best possible accuracy for this operation. Some users familiar with transformations may wish to control which transformation is used in an operation. + +## How to use the sample + +Select a transformation from the list to see the result of projecting the point from EPSG:27700 to EPSG:3857 using that transformation. The result is shown as a blue cross; you can visually compare the original red point with the projected blue cross. + +Select "Suitable for Map Extent" to limit the transformations to those that are appropriate for the current extent. + +If the selected transformation is not usable (has missing grid files), then an error is displayed. + +To download projection engine data, tap "Download Data" and then the download button next to the latest release of the `Projection Engine Data`. Unzip the download data in Files, and then tap "Set Data Directory" in the sample. Navigate into the unzipped `Projection Engine Data` directory and tap "Open". + +## How it works + +1. Pass the input and output spatial references to `TransformationCatalog.transformations(from:to:areaOfInterest:ignoreVertical:)` for transformations based on the map's spatial reference OR additionally provide an extent argument to only return transformations suitable to the extent. This returns a list of ranked transformations. +2. Use one of the `DatumTransformation` objects returned to project the input geometry to the output spatial reference. + +## Relevant API + +* DatumTransformation +* GeographicTransformation +* GeographicTransformationStep +* GeometryEngine +* GeometryEngine.project(_:into:datumTransformation:) +* TransformationCatalog + +## About the data + +The map starts out zoomed into the grounds of the Royal Observatory, Greenwich. The initial point is in the British National Grid spatial reference, which was created by the United Kingdom Ordnance Survey. The spatial reference after projection is in Web Mercator. + +## Additional information + +Some transformations aren't available until transformation data is provided. + +This sample uses `GeographicTransformation`, a subclass of `DatumTransformation`. The ArcGIS Maps SDKs also include `HorizontalVerticalTransformation`, another subclass of `DatumTransformation`. The `HorizontalVerticalTransformation` class is used to transform coordinates of z-aware geometries between spatial references that have different geographic and/or vertical coordinate systems. + +This sample can be used with or without provisioning projection engine data to your device. If you do not provision data, a limited number of transformations will be available. + +## Tags + +datum, geodesy, projection, spatial reference, transformation diff --git a/Shared/Samples/List spatial reference transformations/README.metadata.json b/Shared/Samples/List spatial reference transformations/README.metadata.json new file mode 100644 index 000000000..cebbf5a76 --- /dev/null +++ b/Shared/Samples/List spatial reference transformations/README.metadata.json @@ -0,0 +1,35 @@ +{ + "category": "Edit and Manage Data", + "description": "Get a list of suitable transformations for projecting a geometry between two spatial references with different horizontal datums.", + "ignore": false, + "images": [ + "list-spatial-reference-transformations.png" + ], + "keywords": [ + "datum", + "geodesy", + "projection", + "spatial reference", + "transformation", + "DatumTransformation", + "GeographicTransformation", + "GeographicTransformationStep", + "GeometryEngine", + "GeometryEngine.project(_:into:datumTransformation:)", + "TransformationCatalog" + ], + "redirect_from": [], + "relevant_apis": [ + "DatumTransformation", + "GeographicTransformation", + "GeographicTransformationStep", + "GeometryEngine", + "GeometryEngine.project(_:into:datumTransformation:)", + "TransformationCatalog" + ], + "snippets": [ + "ListSpatialReferenceTransformationsView.swift", + "ListSpatialReferenceTransformationsView.Model.swift" + ], + "title": "List spatial reference transformations" +} diff --git a/Shared/Samples/List spatial reference transformations/list-spatial-reference-transformations.png b/Shared/Samples/List spatial reference transformations/list-spatial-reference-transformations.png new file mode 100644 index 000000000..301282e34 Binary files /dev/null and b/Shared/Samples/List spatial reference transformations/list-spatial-reference-transformations.png differ diff --git a/Shared/Samples/Manage bookmarks/ManageBookmarksView.swift b/Shared/Samples/Manage bookmarks/ManageBookmarksView.swift new file mode 100644 index 000000000..f1cef6a1a --- /dev/null +++ b/Shared/Samples/Manage bookmarks/ManageBookmarksView.swift @@ -0,0 +1,250 @@ +// 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 ArcGIS +import SwiftUI + +struct ManageBookmarksView: View { + /// A map with an imagery basemap and a list of bookmarks. + @State private var map: Map = { + // Create a map with a basemap. + let map = Map(basemapStyle: .arcGISImagery) + + // Add a list of bookmarks to the map. + let defaultBookmarks = [ + Bookmark( + name: "Grand Prismatic Spring", + viewpoint: Viewpoint(latitude: 44.525, longitude: -110.838, scale: 6e3) + ), + Bookmark( + name: "Guitar-Shaped Forest", + viewpoint: Viewpoint(latitude: -33.867, longitude: -63.985, scale: 4e4) + ), + Bookmark( + name: "Mysterious Desert Pattern", + viewpoint: Viewpoint(latitude: 27.380, longitude: 33.632, scale: 6e3) + ), + Bookmark( + name: "Strange Symbol", + viewpoint: Viewpoint(latitude: 37.401, longitude: -116.867, scale: 6e3) + ) + ] + map.addBookmarks(defaultBookmarks) + + return map + }() + + /// The current viewpoint of the map view. + @State private var viewpoint: Viewpoint? + + /// A Boolean value indicating whether the bookmarks sheet is presented. + @State private var bookmarksSheetIsPresented = false + + /// A Boolean value indicating whether the new bookmark alert is showing. + @State private var newBookmarkAlertIsPresented = false + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + MapViewReader { mapViewProxy in + MapView(map: map, viewpoint: viewpoint) + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Button("Add Bookmark", systemImage: "plus") { + newBookmarkAlertIsPresented = true + } + + Spacer() + + Button("Bookmarks", systemImage: "book") { + bookmarksSheetIsPresented = true + } + .halfSheet(isPresented: $bookmarksSheetIsPresented) { + BookmarksList(map: map) { bookmark in + do { + try await mapViewProxy.setBookmark(bookmark) + } catch { + self.error = error + } + } + } + } + } + .task { + // Zoom to the map's first bookmark when the view appears. + do { + guard let initialBookmark = map.bookmarks.first else { return } + try await mapViewProxy.setBookmark(initialBookmark) + } catch { + self.error = error + } + } + } + .newBookmarkAlert(isPresented: $newBookmarkAlertIsPresented) { name in + // Create a new bookmark and add it to the map. + guard !name.isEmpty else { return } + let newBookmark = Bookmark(name: name, viewpoint: viewpoint) + map.addBookmark(newBookmark) + } + .errorAlert(presentingError: $error) + } +} + +private extension ManageBookmarksView { + /// A list of the bookmarks for a given map. + struct BookmarksList: View { + /// The map to get the bookmarks from. + let map: Map + + /// The action to perform when a list row is tapped. + let action: (Bookmark) async -> Void + + /// The action to dismiss the view. + @Environment(\.dismiss) private var dismiss: DismissAction + + /// The list of the map's bookmarks. + @State private var bookmarks: [Bookmark] = [] + + var body: some View { + NavigationView { + List { + ForEach(bookmarks, id: \.self) { bookmark in + Button { + dismiss() + Task { + await action(bookmark) + } + } label: { + HStack { + Text(bookmark.name) + Spacer() + } + .contentShape(Rectangle()) + } + } + .onMove { fromOffsets, toOffset in + // Reorder the bookmarks on row move. + bookmarks.move(fromOffsets: fromOffsets, toOffset: toOffset) + map.removeAllBookmarks() + map.addBookmarks(bookmarks) + } + .onDelete { offsets in + // Delete the bookmarks at the given offsets on row deletion. + let bookmarksToRemove = offsets.map { bookmarks[$0] } + map.removeBookmarks(bookmarksToRemove) + bookmarks.remove(atOffsets: offsets) + } + .buttonStyle(.plain) + } + .navigationTitle("Bookmarks") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + // Note: There is a bug in iOS 17 that prevents the `EditButton` from working + // on the first tap when it is embedded in a `NavigationView` in a `popover`. + EditButton() + } + } + } + .navigationViewStyle(.stack) + .onAppear { + bookmarks = map.bookmarks + } + } + } + + /// An alert that allows the user to enter a name for a new bookmark. + struct NewBookmarkAlert: ViewModifier { + /// A binding to a Boolean value that determines whether to present the alert. + @Binding var isPresented: Bool + + /// The action to perform when the save button is pressed. + let onSave: (String) -> Void + + /// The name for the new bookmark in the text field. + @State private var newBookmarkName = "" + + func body(content: Content) -> some View { + content + .alert( + "Add bookmark", + isPresented: $isPresented, + actions: { + TextField("Name", text: $newBookmarkName) + + Button("Cancel", role: .cancel) { + newBookmarkName.removeAll() + } + + Button("Save") { + onSave(newBookmarkName) + newBookmarkName.removeAll() + } + } + ) + } + } +} + +private extension View { + /// Presents an alert to add a new bookmark. + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the alert. + /// - onSave: The action to perform when the save button is pressed. + /// - Returns: A new `View`. + func newBookmarkAlert( + isPresented: Binding, + onSave: @escaping (String) -> Void + ) -> some View { + modifier(ManageBookmarksView.NewBookmarkAlert(isPresented: isPresented, onSave: onSave)) + } + + /// Presents a half sheet when a given binding to a Boolean value is true. + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the sheet. + /// - content: A closure that returns the content of the sheet. + /// - Returns: A new `View`. + func halfSheet( + isPresented: Binding, + @ViewBuilder content: @escaping () -> Content + ) -> some View where Content: View { + Group { + if #available(iOS 16, *) { + self + .popover(isPresented: isPresented, arrowEdge: .bottom) { + content() + .presentationDetents([.medium, .large]) +#if targetEnvironment(macCatalyst) + .frame(minWidth: 300, minHeight: 270) +#else + .frame(minWidth: 320, minHeight: 390) +#endif + } + } else { + self + .sheet(isPresented: isPresented, detents: [.medium, .large]) { + content() + } + } + } + } +} + +#Preview { + NavigationView { + ManageBookmarksView() + } +} diff --git a/Shared/Samples/Manage bookmarks/README.md b/Shared/Samples/Manage bookmarks/README.md new file mode 100644 index 000000000..b7027940e --- /dev/null +++ b/Shared/Samples/Manage bookmarks/README.md @@ -0,0 +1,30 @@ +# Manage bookmarks + +Access and create bookmarks on a map. + +![Image of manage bookmarks 1](manage-bookmarks-1.png) +![Image of manage bookmarks 2](manage-bookmarks-2.png) + +## Use case + +Bookmarks are used for easily storing and accessing saved locations on the map. Bookmarks are of interest in educational apps (e.g. touring historical sites) or more specifically, for a land management company wishing to visually monitor flood levels over time at a particular location. These locations can be saved as bookmarks and revisited easily each time their basemap data has been updated (e.g. working with up to date satellite imagery to monitor water levels). + +## How to use the sample + +The map in the sample comes pre-populated with a set of bookmarks. To access a bookmark and move to that location, tap on a bookmark's name from the list. To add a bookmark, pan and/or zoom to a new location and tap on the "+" button. Enter a unique name for the bookmark and tap "Save", and the bookmark will be added to the list + +## How it works + +1. Instantiate a new `Map`. +2. To create a new bookmark and add it to the bookmark list: + * Instantiate a new `Bookmark` object passing in text (the name of the bookmark) and a `Viewpoint` as parameters. + * Add the new bookmark to the map with `addBookmark(_:)`. + +## Relevant API + +* Bookmark +* Viewpoint + +## Tags + +bookmark, extent, location, zoom diff --git a/Shared/Samples/Manage bookmarks/README.metadata.json b/Shared/Samples/Manage bookmarks/README.metadata.json new file mode 100644 index 000000000..dd223816c --- /dev/null +++ b/Shared/Samples/Manage bookmarks/README.metadata.json @@ -0,0 +1,26 @@ +{ + "category": "Maps", + "description": "Access and create bookmarks on a map.", + "ignore": false, + "images": [ + "manage-bookmarks-1.png", + "manage-bookmarks-2.png" + ], + "keywords": [ + "bookmark", + "extent", + "location", + "zoom", + "Bookmark", + "Viewpoint" + ], + "redirect_from": [], + "relevant_apis": [ + "Bookmark", + "Viewpoint" + ], + "snippets": [ + "ManageBookmarksView.swift" + ], + "title": "Manage bookmarks" +} diff --git a/Shared/Samples/Manage bookmarks/manage-bookmarks-1.png b/Shared/Samples/Manage bookmarks/manage-bookmarks-1.png new file mode 100644 index 000000000..9d0709c9d Binary files /dev/null and b/Shared/Samples/Manage bookmarks/manage-bookmarks-1.png differ diff --git a/Shared/Samples/Manage bookmarks/manage-bookmarks-2.png b/Shared/Samples/Manage bookmarks/manage-bookmarks-2.png new file mode 100644 index 000000000..32cea4464 Binary files /dev/null and b/Shared/Samples/Manage bookmarks/manage-bookmarks-2.png differ diff --git a/Shared/Samples/Manage operational layers/ManageOperationalLayersView.swift b/Shared/Samples/Manage operational layers/ManageOperationalLayersView.swift index ead0162cf..a4c409a8e 100644 --- a/Shared/Samples/Manage operational layers/ManageOperationalLayersView.swift +++ b/Shared/Samples/Manage operational layers/ManageOperationalLayersView.swift @@ -158,3 +158,9 @@ private extension URL { URL(string: "https://sampleserver5.arcgisonline.com/arcgis/rest/services/Census/MapServer")! } } + +#Preview { + NavigationView { + ManageOperationalLayersView() + } +} diff --git a/Shared/Samples/Measure distance in scene/MeasureDistanceInSceneView.swift b/Shared/Samples/Measure distance in scene/MeasureDistanceInSceneView.swift index 207f680e0..f673fae4a 100644 --- a/Shared/Samples/Measure distance in scene/MeasureDistanceInSceneView.swift +++ b/Shared/Samples/Measure distance in scene/MeasureDistanceInSceneView.swift @@ -172,3 +172,7 @@ private extension URL { URL(string: "https://tiles.arcgis.com/tiles/P3ePLMYs2RVChkJx/arcgis/rest/services/Buildings_Brest/SceneServer/layers/0")! } } + +#Preview { + MeasureDistanceInSceneView() +} diff --git a/Shared/Samples/Monitor changes to map load status/MonitorChangesToMapLoadStatusView.swift b/Shared/Samples/Monitor changes to map load status/MonitorChangesToMapLoadStatusView.swift index 51e4009e3..c2048b95f 100644 --- a/Shared/Samples/Monitor changes to map load status/MonitorChangesToMapLoadStatusView.swift +++ b/Shared/Samples/Monitor changes to map load status/MonitorChangesToMapLoadStatusView.swift @@ -55,3 +55,7 @@ private extension LoadStatus { } } } + +#Preview { + MonitorChangesToMapLoadStatusView() +} diff --git a/Shared/Samples/Navigate route with rerouting/NavigateRouteWithReroutingView.Model.swift b/Shared/Samples/Navigate route with rerouting/NavigateRouteWithReroutingView.Model.swift new file mode 100644 index 000000000..e198e787a --- /dev/null +++ b/Shared/Samples/Navigate route with rerouting/NavigateRouteWithReroutingView.Model.swift @@ -0,0 +1,309 @@ +// 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 ArcGIS +import AVFoundation +import Combine + +extension NavigateRouteWithReroutingView { + /// The view model for the sample. + @MainActor + class Model: ObservableObject { + // MARK: Properties + + /// The text representing the current status of the route. + @Published private(set) var statusMessage: String = .initialInstructions + + /// A Boolean value indicating whether the route is being navigated. + @Published private(set) var isNavigating = false + + /// The viewpoint of the map. + @Published var viewpoint: Viewpoint? + + /// A map with a navigation basemap. + let map = Map(basemapStyle: .arcGISNavigation) + + /// The graphics overlay for the route and stop graphics. + let graphicsOverlay: GraphicsOverlay = { + // Create a graphic for the start location. + let greenCrossSymbol = SimpleMarkerSymbol(style: .cross, color: .green, size: 25) + let startGraphic = Graphic(geometry: .startLocation, symbol: greenCrossSymbol) + + // Create a graphic for the destination location. + let redXSymbol = SimpleMarkerSymbol(style: .x, color: .red, size: 20) + let destinationGraphic = Graphic(geometry: .destinationLocation, symbol: redXSymbol) + + // Create a graphics overlay with the graphics. + return GraphicsOverlay(graphics: [startGraphic, destinationGraphic]) + }() + + /// The map's location display. + let locationDisplay = LocationDisplay() + + /// The route tracker for tracking the status and progress of the route navigation. + private(set) var routeTracker: RouteTracker! + + /// The parameters for enabling automatic rerouting on the route tracker. + private var reroutingParameters: ReroutingParameters! + + /// The route result solved by the route task. + private var routeResult: RouteResult! + + /// The data source containing the simulated locations. + private let simulatedDataSource = SimulatedLocationDataSource() + + /// A speech synthesizer for text to speech. + private let speechSynthesizer = AVSpeechSynthesizer() + + /// The graphic representing the route ahead. + private let remainingRouteGraphic: Graphic = { + let dashedPurpleLineSymbol = SimpleLineSymbol(style: .dash, color: .systemPurple, width: 5) + return Graphic(symbol: dashedPurpleLineSymbol) + }() + + /// The graphic representing the route that's been traveled. + private let traversedRouteGraphic: Graphic = { + let solidBlueLineSymbol = SimpleLineSymbol(style: .solid, color: .systemBlue, width: 3) + return Graphic(symbol: solidBlueLineSymbol) + }() + + /// A builder to make a polyline for the traversed route graphic. + private let traversedRouteBuilder = PolylineBuilder(spatialReference: .wgs84) + + init() { + // Add the route graphics to the graphics overlay. + graphicsOverlay.addGraphics([remainingRouteGraphic, traversedRouteGraphic]) + } + + // MARK: Methods + + /// Sets up the route related properties. + func setUp() async throws { + // Create a route task from a local geodatabase to solve a route. + let routeTask = RouteTask( + pathToDatabaseURL: .sanDiegoGeodatabase, + networkName: "Streets_ND" + ) + + // Create the route parameters. + let routeParameters = try await routeTask.makeDefaultParameters() + routeParameters.returnsDirections = true + routeParameters.returnsStops = true + routeParameters.outputSpatialReference = .wgs84 + + // Sets the start and destination stops for the route. + let startStop = Stop(point: .startLocation) + startStop.name = "San Diego Convention Center" + + let destinationStop = Stop(point: .destinationLocation) + destinationStop.name = "RH Fleet Aerospace Museum" + + routeParameters.setStops([startStop, destinationStop]) + + // Solve the route using the parameters and task. + routeResult = try await routeTask.solveRoute(using: routeParameters) + + // Create the rerouting parameters using the route task and parameters. + reroutingParameters = ReroutingParameters( + routeTask: routeTask, + routeParameters: routeParameters + ) + + // Set up the data source's locations using a local JSON file. + let jsonData = try Data(contentsOf: .sanDiegoTourPath) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { return } + let routePolyline = try Polyline.fromJSON(jsonString) + simulatedDataSource.setSimulatedLocations(with: routePolyline) + + try await initializeNavigation() + } + + /// Starts the navigation. + func start() async throws { + try await locationDisplay.dataSource.start() + locationDisplay.autoPanMode = .navigation + + isNavigating = true + } + + /// Stops the navigation. + func stop() async { + // Stop any current speech. + speechSynthesizer.stopSpeaking(at: .immediate) + + // Stop the location display. + locationDisplay.autoPanMode = .off + await locationDisplay.dataSource.stop() + + isNavigating = false + } + + /// Resets the navigation. + func reset() async throws { + await stop() + + // Reset the graphics. + statusMessage = .initialInstructions + traversedRouteGraphic.geometry = nil + traversedRouteBuilder.replaceGeometry(with: nil) + + // Reset the navigation. + simulatedDataSource.currentLocationIndex = 0 + try await initializeNavigation() + } + + /// Updates the status message and route graphics using the progress from a given tracking status. + /// - Parameter status: The `TrackingStatus`. + func updateProgress(using status: TrackingStatus) async { + // Update the route graphics. + remainingRouteGraphic.geometry = status.routeProgress.remainingGeometry + + if let currentPosition = locationDisplay.location?.position { + traversedRouteBuilder.add(currentPosition) + traversedRouteGraphic.geometry = traversedRouteBuilder.toGeometry() + } + + // Update the status message. + switch status.destinationStatus { + case .approaching, .notReached: + // Format the route's remaining distance and time. + let distanceRemainingText = status.routeProgress.remainingDistance.distance.formatted() + + let dateInterval = DateInterval(start: .now, duration: status.routeProgress.remainingTime) + let dateRange = dateInterval.start.. 1 { + statusMessage = "Intermediate stop reached, continue to next stop." + try? await routeTracker.switchToNextDestination() + } else { + await stop() + statusMessage = "Destination reached." + } + + @unknown default: + break + } + } + + /// Speaks a given voice guidance. + /// - Parameter voiceGuidance: The `VoiceGuidance`. + func speakVoiceGuidance(_ voiceGuidance: VoiceGuidance) { + guard !voiceGuidance.text.isEmpty else { return } + + let utterance = AVSpeechUtterance(string: voiceGuidance.text) + speechSynthesizer.stopSpeaking(at: .word) + speechSynthesizer.speak(utterance) + } + + /// Initializes the route tracker, location display, and route graphic. + private func initializeNavigation() async throws { + // Make the route tracker. + routeTracker = try await makeRouteTracker( + routeResult: routeResult, + reroutingParameters: reroutingParameters + ) + + // Create a route tracker location data source to snap the location display to the route. + let routeTrackerLocationDataSource = RouteTrackerLocationDataSource( + routeTracker: routeTracker, + locationDataSource: simulatedDataSource + ) + + // Set location display's data source. + locationDisplay.dataSource = routeTrackerLocationDataSource + + // Update the remaining route graphic and center the map's viewpoint on it. + guard let routeGeometry = routeResult.routes.first?.geometry else { return } + remainingRouteGraphic.geometry = routeGeometry + viewpoint = Viewpoint(center: routeGeometry.extent.center, scale: 23e3, rotation: 0) + } + + /// Makes a route tracker that supports rerouting. + /// - Parameters: + /// - routeResult: A `RouteResult` generated from a route task solve. + /// - reroutingParameters: The `ReroutingParameters` used to enable automatic rerouting. + /// - Returns: A new `RouteTracker`. + private func makeRouteTracker( + routeResult: RouteResult, + reroutingParameters: ReroutingParameters + ) async throws -> RouteTracker { + // Make the route tracker using the route result. + let routeTracker = RouteTracker( + routeResult: routeResult, + routeIndex: 0, + skipsCoincidentStops: true + )! + + // Enable automatic rerouting on the tracker. + try await routeTracker.enableRerouting(using: reroutingParameters) + + // Update the tracker's voice guidance unit system to the current locale's. + routeTracker.voiceGuidanceUnitSystem = Locale.current.usesMetricSystem ? .metric : .imperial + + return routeTracker + } + } +} + +// MARK: Extensions + +private extension String { + /// The text with initial instructions for the sample. + static let initialInstructions = "Press play to start navigating." +} + +private extension Geometry { + /// The starting location of the route, the San Diego Convention Center. + static var startLocation: Point { + Point(latitude: 32.706608, longitude: -117.160386727) + } + + /// The destination location of the route, the Fleet Science Center. + static var destinationLocation: Point { + Point(latitude: 32.730351, longitude: -117.146679) + } +} + +private extension URL { + /// A URL to the local geodatabase file of San Diego, CA, USA. + static var sanDiegoGeodatabase: URL { + Bundle.main.url( + forResource: "sandiego", + withExtension: "geodatabase", + subdirectory: "san_diego_offline_routing" + )! + } + + /// A URL to the local "SanDiegoTourPath" JSON file containing the simulated path. + static var sanDiegoTourPath: URL { + Bundle.main.url(forResource: "SanDiegoTourPath", withExtension: "json")! + } +} diff --git a/Shared/Samples/Navigate route with rerouting/NavigateRouteWithReroutingView.swift b/Shared/Samples/Navigate route with rerouting/NavigateRouteWithReroutingView.swift new file mode 100644 index 000000000..b42cd0706 --- /dev/null +++ b/Shared/Samples/Navigate route with rerouting/NavigateRouteWithReroutingView.swift @@ -0,0 +1,135 @@ +// 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 ArcGIS +import SwiftUI + +struct NavigateRouteWithReroutingView: View { + /// The view model for the sample. + @StateObject private var model = Model() + + /// The navigation action currently being run. + @State private var selectedNavigationAction: NavigationAction? = .setUp + + /// A Boolean value indicating whether the navigation can be reset. + @State private var canReset = false + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + MapView( + map: model.map, + viewpoint: model.viewpoint, + graphicsOverlays: [model.graphicsOverlay] + ) + .onViewpointChanged(kind: .centerAndScale) { model.viewpoint = $0 } + .locationDisplay(model.locationDisplay) + .overlay(alignment: .top) { + Text(model.statusMessage) + .frame(maxWidth: .infinity, alignment: .center) + .padding(8) + .background(.thinMaterial, ignoresSafeAreaEdges: .horizontal) + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Button { + selectedNavigationAction = .reset + } label: { + Image(systemName: "gobackward") + } + .disabled(!canReset) + + Spacer() + Button { + selectedNavigationAction = model.isNavigating ? .stop : .start + } label: { + Image(systemName: model.isNavigating ? "pause.fill" : "play.fill") + } + .disabled( + selectedNavigationAction == .setUp + || model.routeTracker.trackingStatus?.destinationStatus == .reached + ) + + Spacer() + Button { + model.locationDisplay.autoPanMode = .navigation + } label: { + Image(systemName: "location.fill") + } + .disabled(!model.isNavigating || model.locationDisplay.autoPanMode == .navigation) + } + } + .task(id: selectedNavigationAction) { + guard let selectedNavigationAction else { return } + defer { self.selectedNavigationAction = nil } + + do { + // Run the new action. + switch selectedNavigationAction { + case .setUp: + try await model.setUp() + + case .start: + try await model.start() + canReset = true + + case .stop: + await model.stop() + + case .reset: + try await model.reset() + canReset = false + } + } catch { + self.error = error + } + } + .task(id: model.isNavigating) { + guard model.isNavigating, let routeTracker = model.routeTracker else { return } + + await withTaskGroup(of: Void.self) { group in + group.addTask { + // Handle new tracking statuses from the route tracker. + for await trackingStatus in routeTracker.$trackingStatus { + guard let trackingStatus else { continue } + await model.updateProgress(using: trackingStatus) + } + } + + group.addTask { + // Speak new voice guidances from the route tracker. + for await voiceGuidance in routeTracker.voiceGuidances { + await model.speakVoiceGuidance(voiceGuidance) + } + } + } + } + .errorAlert(presentingError: $error) + } +} + +private extension NavigateRouteWithReroutingView { + /// An enumeration representing an action relating to the navigation. + enum NavigationAction { + /// Set up the route. + case setUp + /// Start navigating. + case start + /// Stop navigating. + case stop + /// Reset the route. + case reset + } +} diff --git a/Shared/Samples/Navigate route with rerouting/README.md b/Shared/Samples/Navigate route with rerouting/README.md new file mode 100644 index 000000000..b675d6a44 --- /dev/null +++ b/Shared/Samples/Navigate route with rerouting/README.md @@ -0,0 +1,57 @@ +# Navigate route with rerouting + +Navigate between two points and dynamically recalculate an alternate route when the original route is unavailable. + +![Image of navigate route with rerouting](navigate-route-with-rerouting.png) + +## Use case + +While traveling between destinations, field workers use navigation to get live directions based on their locations. In cases where a field worker makes a wrong turn, or if the route suggested is blocked due to a road closure, it is necessary to calculate an alternate route to the original destination. + +## How to use the sample + +Tap the play button to simulate traveling and to receive directions from a preset starting point to a preset destination. Observe how the route is recalculated when the simulation does not follow the suggested route. Tap the recenter button to reposition the viewpoint. Tap the reset button to start the simulation from the beginning. + +## How it works + +1. Create a `RouteTask` using local network data. +2. Generate default `RouteParameters` using `RouteTask.makeDefaultParameters()`. +3. Set `returnsStops` and `returnsDirections` on the parameters to true. +4. Add `Stop`s to the parameters' `stops` array using `RouteParameters.setStops(_:)`. +5. Solve the route using `RouteTask.solveRoute(using:)` to get a `RouteResult`. +6. Create a `RouteTracker` using the route result and the index of the desired route to take. +7. Enable rerouting on the route tracker with `RouteTracker.enableRerouting(using:)`. +8. Use `RouteTracker.$trackingStatus` to display updated route information and update the route graphics. Tracking status includes a variety of information on the route progress, such as the remaining distance, remaining geometry or traversed geometry (represented by a `Polyline`), or the remaining time (`TimeInterval`), amongst others. +9. You can also query the tracking status for the current `DirectionManeuver` index, retrieve that maneuver from the `Route`, and get its direction text to display. +10. Use `RouteTracker.voiceGuidances` to get the `VoiceGuidance` whenever new instructions are available. From the voice guidance, get the `text` representing the directions and use a text-to-speech engine to output the maneuver directions. +11. To establish whether the destination has been reached, get the `destinationStatus` from the tracking status. If the destination status is `reached`, and the `remainingDestinationCount` is 1, you have arrived at the destination and can stop routing. If there are several destinations in your route, and the remaining destination count is greater than 1, switch the route tracker to the next destination. + +## Relevant API + +* DestinationStatus +* DirectionManeuver +* Location +* LocationDataSource +* ReroutingStrategy +* Route +* RouteParameters +* RouteTask +* RouteTracker +* Stop +* VoiceGuidance + +## Offline data + +The [SanDiegoTourPath](https://www.arcgis.com/home/item.html?id=4caec8c55ea2463982f1af7d9611b8d5) JSON file provides a simulated path for the device to demonstrate routing while traveling. + +## About the data + +The route taken in this sample goes from the San Diego Convention Center, site of the annual Esri User Conference, to the Fleet Science Center, San Diego. + +## Additional information + +The route tracker will start a rerouting calculation automatically as necessary when the device's location indicates that it is off-route. The route tracker also validates that the device is "on" the transportation network. If it is not (e.g., in a parking lot), rerouting will not occur until the device location indicates that it is back "on" the transportation network. + +## Tags + +directions, maneuver, navigation, route, turn-by-turn, voice diff --git a/Shared/Samples/Navigate route with rerouting/README.metadata.json b/Shared/Samples/Navigate route with rerouting/README.metadata.json new file mode 100644 index 000000000..3af337e36 --- /dev/null +++ b/Shared/Samples/Navigate route with rerouting/README.metadata.json @@ -0,0 +1,50 @@ +{ + "category": "Routing and Logistics", + "description": "Navigate between two points and dynamically recalculate an alternate route when the original route is unavailable.", + "ignore": false, + "images": [ + "navigate-route-with-rerouting.png" + ], + "keywords": [ + "directions", + "maneuver", + "navigation", + "route", + "turn-by-turn", + "voice", + "DestinationStatus", + "DirectionManeuver", + "Location", + "LocationDataSource", + "ReroutingStrategy", + "Route", + "RouteParameters", + "RouteTask", + "RouteTracker", + "Stop", + "VoiceGuidance" + ], + "offline_data": [ + "4caec8c55ea2463982f1af7d9611b8d5", + "df193653ed39449195af0c9725701dca" + ], + "redirect_from": [], + "relevant_apis": [ + "DestinationStatus", + "DirectionManeuver", + "Location", + "LocationDataSource", + "ReroutingStrategy", + "Route", + "RouteParameters", + "RouteTask", + "RouteTracker", + "Stop", + "VoiceGuidance" + ], + "snippets": [ + "NavigateRouteWithReroutingView.swift", + "NavigateRouteWithReroutingView.Model.swift" + ], + "title": "Navigate route with rerouting" +} diff --git a/Shared/Samples/Navigate route with rerouting/navigate-route-with-rerouting.png b/Shared/Samples/Navigate route with rerouting/navigate-route-with-rerouting.png new file mode 100644 index 000000000..3648596cc Binary files /dev/null and b/Shared/Samples/Navigate route with rerouting/navigate-route-with-rerouting.png differ diff --git a/Shared/Samples/Navigate route/NavigateRouteView.swift b/Shared/Samples/Navigate route/NavigateRouteView.swift index 82fd32f0d..cc59ff5a6 100644 --- a/Shared/Samples/Navigate route/NavigateRouteView.swift +++ b/Shared/Samples/Navigate route/NavigateRouteView.swift @@ -154,7 +154,7 @@ private extension NavigateRouteView { autoPanMode = .off // Creates the graphics for each stop. - let stopGraphics = stops.map { + let stopGraphics = Self.stops.map { Graphic( geometry: $0.geometry, symbol: SimpleMarkerSymbol(style: .diamond, color: .orange, size: 20) @@ -183,7 +183,7 @@ private extension NavigateRouteView { parameters.outputSpatialReference = .wgs84 // Sets the stops on the parameters. - parameters.setStops(stops) + parameters.setStops(Self.stops) // Solves the route based on the parameters. routeResult = try await routeTask.solveRoute(using: parameters) @@ -323,7 +323,9 @@ private extension NavigateRouteView { isResettingRoute = false } } - +} + +private extension NavigateRouteView.Model { /// The stops for this sample. static var stops: [Stop] { let one = Stop(point: Point(x: -117.160386727, y: 32.706608, spatialReference: .wgs84)) @@ -348,3 +350,9 @@ private extension URL { URL(string: "http://sampleserver7.arcgisonline.com/server/rest/services/NetworkAnalysis/SanDiego/NAServer/Route")! } } + +#Preview { + NavigationView { + NavigateRouteView() + } +} diff --git a/Shared/Samples/Orbit camera around object/OrbitCameraAroundObjectView.Model.swift b/Shared/Samples/Orbit camera around object/OrbitCameraAroundObjectView.Model.swift new file mode 100644 index 000000000..4d0ceba99 --- /dev/null +++ b/Shared/Samples/Orbit camera around object/OrbitCameraAroundObjectView.Model.swift @@ -0,0 +1,172 @@ +// 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 ArcGIS +import SwiftUI + +extension OrbitCameraAroundObjectView { + /// The view model for the sample. + class Model: ObservableObject { + // MARK: Properties + + /// A scene with an imagery basemap and world elevation surface. + let scene: ArcGIS.Scene = { + let scene = Scene(basemapStyle: .arcGISImagery) + let elevationSource = ArcGISTiledElevationSource(url: .worldElevationService) + scene.baseSurface.addElevationSource(elevationSource) + return scene + }() + + /// The graphics overlay for the plane graphic. + let graphicsOverlay: GraphicsOverlay = { + let graphicsOverlay = GraphicsOverlay() + graphicsOverlay.sceneProperties.surfacePlacement = .relative + + let renderer = SimpleRenderer() + renderer.sceneProperties.headingExpression = "[HEADING]" + renderer.sceneProperties.pitchExpression = "[PITCH]" + graphicsOverlay.renderer = renderer + + return graphicsOverlay + }() + + /// The plane graphic created from a local URL. + let planeGraphic: Graphic = { + let planeSymbol = ModelSceneSymbol(url: .bristol, scale: 1) + let planePosition = Point(x: 6.637, y: 45.399, z: 100, spatialReference: .wgs84) + let planeGraphic = Graphic( + geometry: planePosition, + attributes: ["HEADING": 45.0, "PITCH": 0.0], + symbol: planeSymbol + ) + return planeGraphic + }() + + /// The camera controller for the scene. + let cameraController: OrbitGeoElementCameraController + + init() { + graphicsOverlay.addGraphic(planeGraphic) + + // Create an orbit geo element camera controller targeted on the plane graphic. + cameraController = OrbitGeoElementCameraController( + target: planeGraphic, + distance: 50 + ) + // Restrict the camera's heading to stay behind the plane. + cameraController.minCameraHeadingOffset = -45 + cameraController.maxCameraHeadingOffset = 45 + + // Restrict the camera to stay within 100 meters of the plane. + cameraController.maxCameraDistance = 100 + + // Position the plane a third from the top of the screen, + // so it isn't covered by the settings sheet. + cameraController.targetVerticalScreenFactor = 0.66 + + // Don't pitch the camera when the plane pitches. + cameraController.autoPitchIsEnabled = false + } + + // MARK: Methods + + /// Moves the camera controller to center the plane in it's view. + func moveToPlaneView() async throws { + cameraController.cameraDistanceIsInteractive = true + + // Animate the camera to center the plane graphic with a + // 45° pitch and facing forward (0° heading). + if !cameraController.targetOffsetIsZero { + // Unlock the camera pitch for the rotation animation. + cameraController.minCameraPitchOffset = -180 + cameraController.maxCameraPitchOffset = 180 + + let pitchDelta = pitchDelta(for: 45) + cameraController.autoPitchIsEnabled = false + + await cameraController.moveCamera( + distanceDelta: 50 - cameraController.cameraDistance, + headingDelta: -cameraController.cameraHeadingOffset, + pitchDelta: pitchDelta, + duration: 1 + ) + _ = try await cameraController.setTargetOffsets(x: 0, y: 0, z: 0, duration: 1) + } + + // Restrict the camera's pitch so it doesn't collide with the ground. + cameraController.minCameraPitchOffset = 10 + cameraController.maxCameraPitchOffset = 100 + + cameraController.minCameraDistance = 10 + } + + /// Moves the view of the camera controller to the cockpit of the plane. + func moveToCockpit() async throws { + cameraController.cameraDistanceIsInteractive = false + cameraController.minCameraDistance = 0.1 + + // Unlock the camera pitch for the rotation animation. + cameraController.minCameraPitchOffset = -180 + cameraController.maxCameraPitchOffset = 180 + + // Animate the camera to the cockpit, facing forward (0° heading), + // and aligned with the horizon (90° pitch). + _ = try await cameraController.setTargetOffsets(x: 0, y: -1.4, z: 1.3, duration: 1) + await cameraController.moveCamera( + distanceDelta: 0.1 - cameraController.cameraDistance, + headingDelta: -cameraController.cameraHeadingOffset, + pitchDelta: pitchDelta(for: 90), + duration: 1 + ) + + // Lock the camera pitch when the animation finishes. + cameraController.minCameraPitchOffset = 90 + cameraController.maxCameraPitchOffset = 90 + cameraController.autoPitchIsEnabled = true + } + + /// The camera pitch delta for a given angle and the current plane pitch. + /// - Parameter angle: The angle in degrees. + /// - Returns: The change in pitch. + private func pitchDelta(for angle: Double) -> Double { + let planePitch = planeGraphic.attributes["PITCH"] as? Double ?? 0 + let cameraPitchOffset = cameraController.cameraPitchOffset + + if cameraController.autoPitchIsEnabled { + return angle - cameraPitchOffset + } else { + return angle + planePitch - cameraPitchOffset + } + } + } +} + +private extension OrbitGeoElementCameraController { + /// A Boolean value indicating whether all the target offset values are zero. + var targetOffsetIsZero: Bool { + return [targetOffsetX, targetOffsetY, targetOffsetZ].allSatisfy(\.isZero) + } +} + +private extension URL { + /// A URL to the local Bristol 3D model files. + static var bristol: URL { + Bundle.main.url(forResource: "Bristol", withExtension: "dae", subdirectory: "Bristol")! + } + + /// A world elevation service from Terrain3D ArcGIS REST service. + static var worldElevationService: URL { + URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")! + } +} diff --git a/Shared/Samples/Orbit camera around object/OrbitCameraAroundObjectView.swift b/Shared/Samples/Orbit camera around object/OrbitCameraAroundObjectView.swift new file mode 100644 index 000000000..15f1768a0 --- /dev/null +++ b/Shared/Samples/Orbit camera around object/OrbitCameraAroundObjectView.swift @@ -0,0 +1,192 @@ +// 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 ArcGIS +import SwiftUI + +struct OrbitCameraAroundObjectView: View { + /// The view model for the sample. + @StateObject private var model = Model() + + /// The camera view selection. + @State private var selectedCameraView = CameraView.center + + /// A Boolean value indicating whether the settings sheet is presented. + @State private var settingsSheetIsPresented = false + + /// A Boolean value indicating whether scene interaction is disabled. + @State private var sceneIsDisabled = false + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + SceneView( + scene: model.scene, + cameraController: model.cameraController, + graphicsOverlays: [model.graphicsOverlay] + ) + .disabled(sceneIsDisabled) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + cameraViewPicker + settingsButton + } + } + .errorAlert(presentingError: $error) + } + + /// The picker for selecting the camera view. + private var cameraViewPicker: some View { + Picker("Camera View", selection: $selectedCameraView) { + Text("Center").tag(CameraView.center) + Text("Cockpit").tag(CameraView.cockpit) + } + .pickerStyle(.segmented) + .task(id: selectedCameraView) { + // Move the camera to the new view selection. + do { + // Disable scene interaction while the camera is moving. + sceneIsDisabled = true + defer { sceneIsDisabled = false } + + switch selectedCameraView { + case .center: + try await model.moveToPlaneView() + case .cockpit: + try await model.moveToCockpit() + } + } catch { + self.error = error + } + } + } + + /// The button that brings up the settings sheet. + @ViewBuilder private var settingsButton: some View { + let button = Button("Settings") { + settingsSheetIsPresented = true + } + let settingsContent = SettingsView(model: model) + + if #available(iOS 16, *) { + button + .popover(isPresented: $settingsSheetIsPresented, arrowEdge: .bottom) { + settingsContent + .presentationDetents([.fraction(0.5)]) +#if targetEnvironment(macCatalyst) + .frame(minWidth: 300, minHeight: 270) +#else + .frame(minWidth: 320, minHeight: 390) +#endif + } + } else { + button + .sheet(isPresented: $settingsSheetIsPresented, detents: [.medium]) { + settingsContent + } + } + } +} + +private extension OrbitCameraAroundObjectView { + /// The camera and plane settings for the sample. + struct SettingsView: View { + /// The view model for the sample. + @ObservedObject var model: Model + + /// The action to dismiss the view. + @Environment(\.dismiss) private var dismiss: DismissAction + + /// The heading offset of the camera controller. + @State private var cameraHeading = Measurement(value: 0, unit: .degrees) + + /// The pitch of the plane in the scene. + @State private var planePitch = Measurement(value: 0, unit: .degrees) + + /// A Boolean value indicating whether the camera distance is interactive. + @State private var cameraDistanceIsInteractive = false + + var body: some View { + NavigationView { + List { + VStack { + Text("Camera Heading") + .badge( + Text(cameraHeading, format: .degrees) + ) + + Slider(value: $cameraHeading.value, in: -45...45) + .onChange(of: cameraHeading.value) { newValue in + model.cameraController.cameraHeadingOffset = newValue + } + } + + VStack { + Text("Plane Pitch") + .badge( + Text(planePitch, format: .degrees) + ) + + Slider(value: $planePitch.value, in: -90...90) + .onChange(of: planePitch.value) { newValue in + model.planeGraphic.setAttributeValue(newValue, forKey: "PITCH") + } + } + + Toggle("Allow Camera Distance Interaction", isOn: $cameraDistanceIsInteractive) + .toggleStyle(.switch) + .disabled(model.cameraController.autoPitchIsEnabled) + .onChange(of: cameraDistanceIsInteractive) { newValue in + model.cameraController.cameraDistanceIsInteractive = newValue + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } + } + .navigationViewStyle(.stack) + .onAppear { + planePitch.value = model.planeGraphic.attributes["PITCH"] as! Double + cameraHeading.value = model.cameraController.cameraHeadingOffset + cameraDistanceIsInteractive = model.cameraController.cameraDistanceIsInteractive + } + } + } + + /// An enumeration representing a camera controller view. + enum CameraView: CaseIterable { + /// The view with the plane centered. + case center + /// The view from the plane's cockpit. + case cockpit + } +} + +private extension FormatStyle where Self == Measurement.FormatStyle { + /// The format style for degrees. + static var degrees: Self { + .measurement( + width: .narrow, + usage: .asProvided, + numberFormatStyle: .number.precision(.fractionLength(0)) + ) + } +} diff --git a/Shared/Samples/Orbit camera around object/README.md b/Shared/Samples/Orbit camera around object/README.md new file mode 100644 index 000000000..66b4af5ba --- /dev/null +++ b/Shared/Samples/Orbit camera around object/README.md @@ -0,0 +1,48 @@ +# Orbit camera around object + +Fix the camera to point at and rotate around a target object. + +![Image of orbit camera around object](orbit-camera-around-object.png) + +## Use case + +The orbit geoelement camera controller provides control over the following camera behaviors: + +* Automatically track the target +* Stay near the target by setting a minimum and maximum distance offset +* Restrict where you can rotate around the target +* Automatically rotate the camera when the target's heading and pitch changes +* Disable user interactions for rotating the camera +* Animate camera movement over a specified duration +* Control the vertical positioning of the target on the screen +* Set a target offset (e.g., to orbit around the tail of the plane) instead of defaulting to orbiting the center of the object + +## How to use the sample + +The sample loads with the camera orbiting an airplane model. The camera is preset with a restricted camera heading, pitch, and a limited minimum and maximum camera distance set from the plane. The position of the plane on the screen is also set just below center. + +Tap "Cockpit" to offset and fix the camera into the cockpit of the airplane. The camera will follow the pitch of the plane in this mode. In this view, adjusting the camera distance is disabled. Tap "Center" to exit the cockpit view and fix the camera controller on the center of the plane. + +Use the "Camera Heading" slider to adjust the camera heading. Use the "Plane Pitch" slider to adjust the plane's pitch. When not in Cockpit view, the plane's pitch will change independently to that of the camera pitch. + +Toggle on the "Allow Camera Distance Interaction" switch to allow zooming in and out by pinching. When the toggle is off, the user will be unable to adjust the camera distance. + +## How it works + +1. Instantiate an `OrbitGeoElementCameraController` with a `GeoElement` and camera distance as parameters. +2. Set the camera controller to the scene view. +3. Set the `cameraHeadingOffset`, `cameraPitchOffset`, and `cameraDistance` properties for the camera controller. +4. Set the minimum and maximum angle of heading and pitch, and minimum and maximum distance for the camera. +5. Set the distance from which the camera is offset from the plane using `setTargetOffsets(x:y:z:duration:)` or the properties. +6. Set the `targetVerticalScreenFactor` property to determine where the plane appears in the scene. +7. Animate the camera to the cockpit using `moveCamera(distanceDelta:headingDelta:pitchDelta:duration:)`. +8. Set `cameraDistanceIsInteractive` if the camera distance will adjust when zooming or panning using mouse or keyboard (default is true). +9. Set `autoPitchIsEnabled` if the camera will follow the pitch of the plane (default is true). + +## Relevant API + +* OrbitGeoElementCameraController + +## Tags + +3D, camera, object, orbit, rotate, scene diff --git a/Shared/Samples/Orbit camera around object/README.metadata.json b/Shared/Samples/Orbit camera around object/README.metadata.json new file mode 100644 index 000000000..7f9f3a77e --- /dev/null +++ b/Shared/Samples/Orbit camera around object/README.metadata.json @@ -0,0 +1,29 @@ +{ + "category": "Scenes", + "description": "Fix the camera to point at and rotate around a target object.", + "ignore": false, + "images": [ + "orbit-camera-around-object.png" + ], + "keywords": [ + "3D", + "camera", + "object", + "orbit", + "rotate", + "scene", + "OrbitGeoElementCameraController" + ], + "offline_data": [ + "681d6f7694644709a7c830ec57a2d72b" + ], + "redirect_from": [], + "relevant_apis": [ + "OrbitGeoElementCameraController" + ], + "snippets": [ + "OrbitCameraAroundObjectView.swift", + "OrbitCameraAroundObjectView.Model.swift" + ], + "title": "Orbit camera around object" +} diff --git a/Shared/Samples/Orbit camera around object/orbit-camera-around-object.png b/Shared/Samples/Orbit camera around object/orbit-camera-around-object.png new file mode 100644 index 000000000..fa33254a2 Binary files /dev/null and b/Shared/Samples/Orbit camera around object/orbit-camera-around-object.png differ diff --git a/Shared/Samples/Play KML tour/PlayKMLTourView.swift b/Shared/Samples/Play KML tour/PlayKMLTourView.swift index b91881c5a..8a9ad1adc 100644 --- a/Shared/Samples/Play KML tour/PlayKMLTourView.swift +++ b/Shared/Samples/Play KML tour/PlayKMLTourView.swift @@ -75,26 +75,22 @@ struct PlayKMLTourView: View { tourController.pause() } .toolbar { - ToolbarItem(placement: .bottomBar) { - ZStack { - HStack { - Button { - tourController.reset() - viewpoint = scene.initialViewpoint - } label: { - Image(systemName: "gobackward") - } - .disabled(tourDisabled || tourStatus == .initialized) - Spacer() - } - - Button { - tourStatus == .playing ? tourController.pause() : tourController.play() - } label: { - Image(systemName: tourStatus == .playing ? "pause.fill" : "play.fill") - } - .disabled(tourDisabled) + ToolbarItemGroup(placement: .bottomBar) { + Button { + tourController.reset() + viewpoint = scene.initialViewpoint + } label: { + Image(systemName: "gobackward") + } + .disabled(tourDisabled || tourStatus == .initialized) + Spacer() + Button { + tourStatus == .playing ? tourController.pause() : tourController.play() + } label: { + Image(systemName: tourStatus == .playing ? "pause.fill" : "play.fill") } + .disabled(tourDisabled) + Spacer() } } .overlay(alignment: .top) { @@ -163,3 +159,9 @@ private extension URL { URL(string: "https://www.arcgis.com/sharing/rest/content/items/f10b1d37fdd645c9bc9b189fb546307c/data")! } } + +#Preview { + NavigationView { + PlayKMLTourView() + } +} diff --git a/Shared/Samples/Project geometry/ProjectGeometryView.swift b/Shared/Samples/Project geometry/ProjectGeometryView.swift index 871869625..6d658d1ef 100644 --- a/Shared/Samples/Project geometry/ProjectGeometryView.swift +++ b/Shared/Samples/Project geometry/ProjectGeometryView.swift @@ -107,3 +107,7 @@ private extension Point { Text("\(self.x, format: .decimal), \(self.y, format: .decimal)") } } + +#Preview { + ProjectGeometryView() +} diff --git a/Shared/Samples/Query feature table/QueryFeatureTableView.swift b/Shared/Samples/Query feature table/QueryFeatureTableView.swift index c34cc6f13..9b232a857 100644 --- a/Shared/Samples/Query feature table/QueryFeatureTableView.swift +++ b/Shared/Samples/Query feature table/QueryFeatureTableView.swift @@ -115,3 +115,9 @@ private extension PortalItem.ID { /// The portal item ID of a USA 2016 Daytime Population feature layer. static var daytimePopulation: Self { Self("f01f0eda766344e29f42031e7bfb7d04")! } } + +#Preview { + NavigationView { + QueryFeatureTableView() + } +} diff --git a/Shared/Samples/Query features with Arcade expression/QueryFeaturesWithArcadeExpressionView.swift b/Shared/Samples/Query features with Arcade expression/QueryFeaturesWithArcadeExpressionView.swift new file mode 100644 index 000000000..0e3fd0fb3 --- /dev/null +++ b/Shared/Samples/Query features with Arcade expression/QueryFeaturesWithArcadeExpressionView.swift @@ -0,0 +1,157 @@ +// 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 ArcGIS +import SwiftUI + +struct QueryFeaturesWithArcadeExpressionView: View { + /// The view model for the sample. + @StateObject private var model = Model() + + /// The point on the screen where the user tapped. + @State private var tapScreenPoint: CGPoint? + + /// The placement of the callout on the map. + @State private var calloutPlacement: CalloutPlacement? + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + MapViewReader { mapViewProxy in + MapView(map: model.map) + .callout(placement: $calloutPlacement.animation(.default.speed(2))) { placement in + let crimeCount = placement.geoElement?.attributes["Crime_Count"] as! Int + Text("Crimes in the last 60 days: \(crimeCount)") + .font(.callout) + .padding(8) + } + .onSingleTapGesture { screenPoint, _ in + tapScreenPoint = screenPoint + } + .task(id: tapScreenPoint) { + guard let tapScreenPoint else { return } + calloutPlacement = nil + + do { + // Identify the tapped feature using the map view proxy. + let identifyResults = try await mapViewProxy.identifyLayers( + screenPoint: tapScreenPoint, + tolerance: 10 + ) + + guard let identifiedGeoElements = identifyResults.first?.geoElements, + let identifiedFeature = identifiedGeoElements.first as? ArcGISFeature, + let tapMapPoint = mapViewProxy.location(fromScreenPoint: tapScreenPoint) + else { return } + + // Evaluate the crime count for the feature. + let crimeCount = try await model.crimeCount(for: identifiedFeature) + + // Update the callout with the evaluation results. + identifiedFeature.setAttributeValue(crimeCount, forKey: "Crime_Count") + calloutPlacement = .geoElement(identifiedFeature, tapLocation: tapMapPoint) + + await mapViewProxy.setViewpointCenter(tapMapPoint) + } catch { + self.error = error + } + } + .overlay(alignment: .center) { + if model.isEvaluating { + VStack { + Text("Evaluating") + ProgressView() + .progressViewStyle(.circular) + } + .padding() + .background(.ultraThickMaterial) + .cornerRadius(10) + .shadow(radius: 50) + } + } + .task { + await mapViewProxy.setViewpointScale(2e5) + } + .errorAlert(presentingError: $error) + } + } +} + +private extension QueryFeaturesWithArcadeExpressionView { + /// The view model for the sample. + @MainActor + class Model: ObservableObject { + /// A map of the "Crime in Police Beats" portal item. + let map: Map = { + // Create a portal item using a portal and item ID. + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: .crimesInPoliceBeats + ) + + // Create a map using the portal item. + let map = Map(item: portalItem) + return map + }() + + /// The Arcade evaluator for evaluating the crime count of a feature. + private let crimeCountEvaluator: ArcadeEvaluator = { + // Create a string containing the Arcade expression. + let expressionValue = """ + var crimes = FeatureSetByName($map, 'Crime in the last 60 days'); + return Count(Intersects($feature, crimes)); + """ + + // Create an Arcade expression using the string. + let expression = ArcadeExpression(expression: expressionValue) + + // Create an Arcade evaluator with the Arcade expression and an Arcade profile. + let evaluator = ArcadeEvaluator(expression: expression, profile: .formCalculation) + return evaluator + }() + + /// A Boolean value indicating whether there is a current evaluation operation. + @Published private(set) var isEvaluating = false + + /// Evaluates the crime count for a given feature. + /// - Parameter feature: The ArcGIS feature evaluate. + /// - Returns: The evaluated crime count in the last 60 days. + func crimeCount(for feature: ArcGISFeature) async throws -> Int { + isEvaluating = true + defer { isEvaluating = false } + + // Create the profile variables for the script with the feature and map. + let profileVariables: [String: Any] = ["$feature": feature, "$map": map] + + // Evaluate for the profile variables using the evaluator. + let result = try await crimeCountEvaluator.evaluate(withProfileVariables: profileVariables) + + // Cast the result to get it's value. + let crimeCount = result.result(as: .double) as? Double ?? 0 + return Int(crimeCount) + } + } +} + +private extension PortalItem.ID { + /// The ID used in the "Crimes in Police Beats" portal item. + static var crimesInPoliceBeats: Self { + Self("539d93de54c7422f88f69bfac2aebf7d")! + } +} + +#Preview { + QueryFeaturesWithArcadeExpressionView() +} diff --git a/Shared/Samples/Query features with Arcade expression/README.md b/Shared/Samples/Query features with Arcade expression/README.md new file mode 100644 index 000000000..fbeea6589 --- /dev/null +++ b/Shared/Samples/Query features with Arcade expression/README.md @@ -0,0 +1,57 @@ +# Query features with Arcade expression + +Query features on a map using an Arcade expression. + +![QueryFeaturesWithArcadeExpression](query-features-with-arcade-expression.png) + +## Use case + +Arcade is a portable, lightweight, and secure expression language used to create custom content in ArcGIS applications. Like other expression languages, it can perform mathematical calculations, manipulate text, and evaluate logical statements. It also supports multi-statement expressions, variables, and flow control statements. What makes Arcade particularly unique when compared to other expression and scripting languages is its inclusion of feature and geometry data types. This sample uses an Arcade expression to query the number of crimes in a neighborhood in the last 60 days. + +## How to use the sample + +Tap on any neighborhood to see the number of crimes in the last 60 days in a callout. + +## How it works + +1. Create a `PortalItem` using a portal and the ID. +2. Create a `Map` using the portal item. +3. Use the `onSingleTapGesture` modifier to listen for tap events on the map view. +4. Identify the visible layer where it is tapped on and get the feature. +5. Create the following `ArcadeExpression`: + + ``` + expressionValue = "var crimes = FeatureSetByName($map, 'Crime in the last 60 days');\n" + "return Count(Intersects($feature, crimes));" + ``` + +6. Create an `ArcadeEvaluator` using the Arcade expression and `ArcadeProfile.formCalculation`. +7. Create a dictionary of profile variables with the following pairs: + + `["$feature": identifiedFeature]` + + `["$map": map]` + +8. Call `evaluate(withProfileVariables:)` on the Arcade evaluator object and pass the profile variables to evaluate the Arcade expression. +9. Convert the result to a `Double` with `result(as:)` and populate the callout with the crime count. + +## Relevant API + +* ArcadeEvaluationResult +* ArcadeEvaluator +* ArcadeExpression +* ArcadeProfile +* Portal +* PortalItem + +## About the data + +This sample uses the [Crimes in Police Beats Sample](https://www.arcgis.com/home/item.html?id=539d93de54c7422f88f69bfac2aebf7d) ArcGIS Online Web Map which contains 2 layers for city beats borders and crimes in the last 60 days as recorded by the Rochester, NY police department. + +## Additional information + +Visit [Getting Started](https://developers.arcgis.com/arcade/) on the *ArcGIS Developer* website to learn more about Arcade expressions. + +## Tags + +Arcade evaluator, Arcade expression, identify layers, portal, portal item, query diff --git a/Shared/Samples/Query features with Arcade expression/README.metadata.json b/Shared/Samples/Query features with Arcade expression/README.metadata.json new file mode 100644 index 000000000..4678291b2 --- /dev/null +++ b/Shared/Samples/Query features with Arcade expression/README.metadata.json @@ -0,0 +1,35 @@ +{ + "category": "Search and Query", + "description": "Query features on a map using an Arcade expression.", + "ignore": false, + "images": [ + "query-features-with-arcade-expression.png" + ], + "keywords": [ + "Arcade evaluator", + "Arcade expression", + "identify layers", + "portal", + "portal item", + "query", + "ArcadeEvaluationResult", + "ArcadeEvaluator", + "ArcadeExpression", + "ArcadeProfile", + "Portal", + "PortalItem" + ], + "redirect_from": [], + "relevant_apis": [ + "ArcadeEvaluationResult", + "ArcadeEvaluator", + "ArcadeExpression", + "ArcadeProfile", + "Portal", + "PortalItem" + ], + "snippets": [ + "QueryFeaturesWithArcadeExpressionView.swift" + ], + "title": "Query features with Arcade expression" +} diff --git a/Shared/Samples/Query features with Arcade expression/query-features-with-arcade-expression.png b/Shared/Samples/Query features with Arcade expression/query-features-with-arcade-expression.png new file mode 100644 index 000000000..87f3a0cea Binary files /dev/null and b/Shared/Samples/Query features with Arcade expression/query-features-with-arcade-expression.png differ diff --git a/Shared/Samples/Render multilayer symbols/RenderMultilayerSymbolsView.swift b/Shared/Samples/Render multilayer symbols/RenderMultilayerSymbolsView.swift index 7ceb5d403..9f3c9963d 100644 --- a/Shared/Samples/Render multilayer symbols/RenderMultilayerSymbolsView.swift +++ b/Shared/Samples/Render multilayer symbols/RenderMultilayerSymbolsView.swift @@ -484,3 +484,7 @@ private extension String { /// The JSON for a cross geometry. static let crossGeometryJSON = "{\"paths\":[[[-1,1],[0,0],[1,-1]],[[1,1],[0,0],[-1,-1]]]}" } + +#Preview { + RenderMultilayerSymbolsView() +} diff --git a/Shared/Samples/Run valve isolation trace/RunValveIsolationTraceView.swift b/Shared/Samples/Run valve isolation trace/RunValveIsolationTraceView.swift index bbc3dee77..75674cc78 100644 --- a/Shared/Samples/Run valve isolation trace/RunValveIsolationTraceView.swift +++ b/Shared/Samples/Run valve isolation trace/RunValveIsolationTraceView.swift @@ -191,3 +191,9 @@ extension RunValveIsolationTraceView.Model.TracingActivity { } } } + +#Preview { + NavigationView { + RunValveIsolationTraceView() + } +} diff --git a/Shared/Samples/Search for web map/README.md b/Shared/Samples/Search for web map/README.md new file mode 100644 index 000000000..9af0b9e34 --- /dev/null +++ b/Shared/Samples/Search for web map/README.md @@ -0,0 +1,31 @@ +# Search for web map + +Find web map portal items by using a search term. + +![Image of search for web map](search-for-web-map.png) + +## Use case + +Portals can contain many portal items and, at times, you may wish to query the portal to find what you're looking for. In this example, we search for web map portal items using a text search. + +## How to use the sample + +Enter search terms into the search bar. Once the search is complete, a list is populated with the resultant web maps. Tap on a web map to set it to the map view. Scrolling to the bottom of the web map list view will get more results. + +## How it works + +1. Create a new `Portal` and load it. +2. Create new `PortalItemQueryParameters`. Set the type to web map by adding `type:"Web Map"` and add the text you want to search for. Note that web maps authored prior to July 2nd, 2014, are not supported. You can also limit the query to only return maps published after that date. +3. Use `findItems(queryParameters:)` to get the first set of matching items (10 by default). +4. Get more results using the `nextQueryParameters` from the `PortalQueryResultSet`. + +## Relevant API + +* Portal +* PortalItem +* PortalQueryParameters +* PortalQueryResultSet + +## Tags + +keyword, query, search, web map diff --git a/Shared/Samples/Search for web map/README.metadata.json b/Shared/Samples/Search for web map/README.metadata.json new file mode 100644 index 000000000..046bcfb16 --- /dev/null +++ b/Shared/Samples/Search for web map/README.metadata.json @@ -0,0 +1,31 @@ +{ + "category": "Cloud and Portal", + "description": "Find web map portal items by using a search term.", + "ignore": false, + "images": [ + "search-for-web-map.png" + ], + "keywords": [ + "keyword", + "query", + "search", + "web map", + "Portal", + "PortalItem", + "PortalQueryParameters", + "PortalQueryResultSet" + ], + "redirect_from": [], + "relevant_apis": [ + "Portal", + "PortalItem", + "PortalQueryParameters", + "PortalQueryResultSet" + ], + "snippets": [ + "SearchForWebMapView.swift", + "SearchForWebMapView.Model.swift", + "SearchForWebMapView.Views.swift" + ], + "title": "Search for web map" +} diff --git a/Shared/Samples/Search for web map/SearchForWebMapView.Model.swift b/Shared/Samples/Search for web map/SearchForWebMapView.Model.swift new file mode 100644 index 000000000..acac1a068 --- /dev/null +++ b/Shared/Samples/Search for web map/SearchForWebMapView.Model.swift @@ -0,0 +1,107 @@ +// 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 ArcGIS +import SwiftUI + +extension SearchForWebMapView { + /// The view model for the sample. + @MainActor + class Model: ObservableObject { + /// A portal to ArcGIS Online to get the portal items from. + private let portal = Portal.arcGISOnline(connection: .anonymous) + + /// The query parameters for the next set of results based on the last results. + private var nextQueryParameters: PortalQueryParameters? + + /// The text query from the last search. + private var lastQuery = "" + + /// The portal items resulting from a search. + @Published private(set) var portalItems: [PortalItem] = [] + + /// Finds the portal items that match the given query. + /// - Parameter query: The text query used to find the portal items. + func findItems(for query: String) async throws { + // Ensure that a new search is necessary. + guard query != lastQuery else { return } + + if query.isEmpty { + portalItems.removeAll() + return + } + + // Find the new results using parameters made with the query. + let parameters = queryParameters(for: query) + let results = try await portalItems(using: parameters) + portalItems = results + lastQuery = query + } + + /// Finds the next portal item based on the last results. + func findNextItems() async throws { + guard let nextQueryParameters else { return } + + // Find the next results using the next query parameters from the last search. + let nextResults = try await portalItems(using: nextQueryParameters) + portalItems.append(contentsOf: nextResults) + } + + /// The portal items from the portal that match the given query parameters. + /// - Parameter queryParameters: The portal query parameters to find the portal items. + /// - Returns: The portal items that were found. + private func portalItems(using queryParameters: PortalQueryParameters) async throws -> [PortalItem] { + // Get the results from the portal using the parameters. + let resultsSet = try await portal.findItems(queryParameters: queryParameters) + nextQueryParameters = resultsSet.nextQueryParameters + return resultsSet.results + } + + /// The portal query parameters for a given query. + /// - Parameter query: The text query used to create the parameters. + /// - Returns: A new `PortalQueryParameters` object. + private func queryParameters(for query: String) -> PortalQueryParameters { + // Create a date string for a date range to search within. + // Note: Web maps authored prior to July 2nd, 2014 are not supported. + let startDate = Date.webMapSupportedDate + + // Convert the dates to UNIX time to be able to use with the ArcGIS REST API. + let dateRange = startDate.unixTime...Date.now.unixTime + let dateString = "uploaded:[\(dateRange.lowerBound) TO \(dateRange.upperBound)]" + + // Create a string to filter for web maps. + let typeString = #"type:"Web Map""# + + // Create the portal query parameters with the strings. + let fullQuery = [query, typeString, dateString].joined(separator: " AND ") + return PortalQueryParameters(query: fullQuery) + } + } +} + +private extension Date { + /// The date after which web maps are supported, July 2, 2014. + static var webMapSupportedDate: Date { + // swiftlint:disable:next force_try + try! Date( + "July 2, 2014", + strategy: Date.FormatStyle().day().month().year().parseStrategy + ) + } + + /// The milliseconds between the date value and 00:00:00 UTC on 1 January 1970. + var unixTime: Int64 { + Int64(timeIntervalSince1970 * 1_000) + } +} diff --git a/Shared/Samples/Search for web map/SearchForWebMapView.Views.swift b/Shared/Samples/Search for web map/SearchForWebMapView.Views.swift new file mode 100644 index 000000000..b19831ec2 --- /dev/null +++ b/Shared/Samples/Search for web map/SearchForWebMapView.Views.swift @@ -0,0 +1,110 @@ +// 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 ArcGIS +import ArcGISToolkit +import SwiftUI + +extension SearchForWebMapView { + /// A map view that shows an alert when there is an error loading the map. + struct SafeMapView: View { + /// The map shown in the map view. + let map: Map + + /// A Boolean value indicating whether the map is being loaded. + @State private var mapIsLoading = false + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + ZStack { + MapView(map: map) + .onLayerViewStateChanged { _, state in + // Show an alert for an error loading any of the layers. + guard let error = state.error else { return } + self.error = error + } + .task { + mapIsLoading = true + defer { mapIsLoading = false } + + // Show an alert for an error loading the map. + do { + try await map.load() + } catch { + self.error = error + } + } + + if mapIsLoading { + ProgressView() + } + } + .errorAlert(presentingError: $error) + } + } + + /// A view that shows a given portal item's info in a row. + struct PortalItemRowView: View { + /// The portal item to display in the row. + let item: PortalItem + + var body: some View { + VStack { + HStack { + AsyncImage(url: item.thumbnail?.url) { image in + image + .resizable() + } placeholder: { + Color(.lightGray) + } + .frame(width: 110, height: 75) + .border(.primary) + .padding([.leading, .top], 10) + + Text(item.title) + + Spacer() + } + + Divider() + .overlay(.black) + + HStack { + if let modificationDate = item.modificationDate { + Text(modificationDate, format: Date.FormatStyle(date: .abbreviated, time: .omitted)) + } else { + Text("Date: Unknown") + } + + Divider() + .overlay(.black) + + Text(item.owner) + .foregroundColor(.accentColor) + + Spacer() + } + .font(.footnote) + .padding(.top, 2) + .padding([.horizontal, .bottom], 10) + } + .background(Color(.systemGray5)) + .border(Color(.darkGray)) + .padding(.top, 8) + .padding(.horizontal) + } + } +} diff --git a/Shared/Samples/Search for web map/SearchForWebMapView.swift b/Shared/Samples/Search for web map/SearchForWebMapView.swift new file mode 100644 index 000000000..bc86ebbf5 --- /dev/null +++ b/Shared/Samples/Search for web map/SearchForWebMapView.swift @@ -0,0 +1,96 @@ +// 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 ArcGIS +import SwiftUI + +struct SearchForWebMapView: View { + /// The view model for the sample. + @StateObject private var model = Model() + + /// The text query in the search bar. + @State private var query = "" + + /// A Boolean value indicating whether new results are being loaded. + @State private var resultsAreLoading = false + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + ScrollView { + LazyVStack { + ForEach(model.portalItems, id: \.id) { item in + NavigationLink { + SafeMapView(map: Map(item: item)) + .navigationTitle(item.title) + } label: { + PortalItemRowView(item: item) + } + .buttonStyle(.plain) + .task { + // Load the next results when the last item is reached. + guard item.id == model.portalItems.last?.id else { return } + + resultsAreLoading = true + defer { resultsAreLoading = false } + + do { + try await model.findNextItems() + } catch { + self.error = error + } + } + } + + if resultsAreLoading { + ProgressView() + .padding() + } else if !query.isEmpty && model.portalItems.isEmpty { + VStack { + Text("No Results") + .font(.headline) + Text("Check spelling or try a new search.") + .font(.footnote) + } + .padding() + } + } + } + .background(Color(.secondarySystemBackground)) + .searchable( + text: $query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Web Maps" + ) + .task(id: query) { + // Load new results when the query changes. + resultsAreLoading = true + defer { resultsAreLoading = false } + + do { + try await model.findItems(for: query) + } catch { + self.error = error + } + } + .errorAlert(presentingError: $error) + } +} + +#Preview { + NavigationView { + SearchForWebMapView() + } +} diff --git a/Shared/Samples/Search for web map/search-for-web-map.png b/Shared/Samples/Search for web map/search-for-web-map.png new file mode 100644 index 000000000..09999e575 Binary files /dev/null and b/Shared/Samples/Search for web map/search-for-web-map.png differ diff --git a/Shared/Samples/Search with geocode/SearchWithGeocodeView.swift b/Shared/Samples/Search with geocode/SearchWithGeocodeView.swift index 7630be608..796c8216d 100644 --- a/Shared/Samples/Search with geocode/SearchWithGeocodeView.swift +++ b/Shared/Samples/Search with geocode/SearchWithGeocodeView.swift @@ -135,3 +135,7 @@ private extension SearchWithGeocodeView { let searchResultsOverlay = GraphicsOverlay() } } + +#Preview { + SearchWithGeocodeView() +} diff --git a/Shared/Samples/Select features in feature layer/SelectFeaturesInFeatureLayerView.swift b/Shared/Samples/Select features in feature layer/SelectFeaturesInFeatureLayerView.swift index 628ca9c9b..19f89f709 100644 --- a/Shared/Samples/Select features in feature layer/SelectFeaturesInFeatureLayerView.swift +++ b/Shared/Samples/Select features in feature layer/SelectFeaturesInFeatureLayerView.swift @@ -95,3 +95,7 @@ private extension SelectFeaturesInFeatureLayerView { private extension PortalItem.ID { static var gdpPerCapita: Self { Self("10d76a5b015647279b165f3a64c2524f")! } } + +#Preview { + SelectFeaturesInFeatureLayerView() +} diff --git a/Shared/Samples/Set basemap/SetBasemapView.swift b/Shared/Samples/Set basemap/SetBasemapView.swift index 8763168c0..3e8e1cad8 100644 --- a/Shared/Samples/Set basemap/SetBasemapView.swift +++ b/Shared/Samples/Set basemap/SetBasemapView.swift @@ -46,10 +46,16 @@ struct SetBasemapView: View { .toolbar { ToolbarItem(placement: .primaryAction) { Toggle(isOn: $isShowingBasemapGallery) { - Label("Show base map", systemImage: "map") + Label("Basemap Gallery", systemImage: "map") } } } } } } + +#Preview { + NavigationView { + SetBasemapView() + } +} diff --git a/Shared/Samples/Set feature request mode/README.md b/Shared/Samples/Set feature request mode/README.md new file mode 100644 index 000000000..0d59ec1f5 --- /dev/null +++ b/Shared/Samples/Set feature request mode/README.md @@ -0,0 +1,45 @@ +# Set feature request mode + +Use different feature request modes to populate the map from a service feature table. + +![Image of set feature request mode](set-feature-request-mode.png) + +## Use case + +Feature tables can be initialized with a feature request mode which controls how frequently features are requested and locally cached in response to panning, zooming, selecting, or querying. The feature request mode affects performance and should be chosen based on considerations such as how often the data is expected to change or how often changes in the data should be reflected to the user. + +* `OnInteractionCache` - fetches features within the current extent when needed (after a pan or zoom action) from the server and caches those features in a table on the client. Queries will be performed locally if the features are present, otherwise they will be requested from the server. This mode minimizes requests to the server and is useful for large batches of features which will change infrequently. +* `OnInteractionNoCache` - always fetches features from the server and doesn't cache any features on the client. This mode is best for features that may change often on the server or whose changes need to always be visible. + +> **NOTE**: **No cache** does not guarantee that features won't be cached locally. Feature request mode is a performance concept unrelated to data security. + +* `ManualCache` - only fetches features when explicitly populated from a query. This mode is best for features that change minimally or when it is not critical for the user to see the latest changes. + +## How to use the sample + +Choose a request mode. Pan and zoom to see how the features update at different scales. If you choose "Manual Cache", tap the "Populate" button to manually get a cache with a subset of features (where "Condition < 4") within the extent. + +Note: The service limits requests to 2000 features. + +## How it works + +1. Create an `ServiceFeatureTable` with a feature service URL. +2. Set the `featureRequestMode` property of the `ServiceFeatureTable` to the desired mode (Cache, No cache, or Manual cache) before the table is loaded. + * If using `manualCache`, populate the features with `ServiceFeatureTable.populateFromService(using:clearCache:outFields:)`. +3. Create an `FeatureLayer` with the feature table and add it to a map's operational layers to display it. + +## Relevant API + +* FeatureLayer +* FeatureRequestMode +* ServiceFeatureTable +* ServiceFeatureTable.featureRequestMode +* ServiceFeatureTable.populateFromService(using:clearCache:outFields:) + +## About the data + +This sample uses the [Trees of Portland](https://services2.arcgis.com/ZQgQTuoyBrtmoGdP/arcgis/rest/services/Trees_of_Portland/FeatureServer/0) service showcasing over 200,000 street trees in Portland, OR. Each tree point models the health of the tree (green - better, red - worse) as well as the diameter of its trunk. + +## Tags + +cache, data, feature, feature request mode, performance diff --git a/Shared/Samples/Set feature request mode/README.metadata.json b/Shared/Samples/Set feature request mode/README.metadata.json new file mode 100644 index 000000000..e85dea792 --- /dev/null +++ b/Shared/Samples/Set feature request mode/README.metadata.json @@ -0,0 +1,32 @@ +{ + "category": "Search and Query", + "description": "Use different feature request modes to populate the map from a service feature table.", + "ignore": false, + "images": [ + "set-feature-request-mode.png" + ], + "keywords": [ + "cache", + "data", + "feature", + "feature request mode", + "performance", + "FeatureLayer", + "FeatureRequestMode", + "ServiceFeatureTable", + "ServiceFeatureTable.featureRequestMode", + "ServiceFeatureTable.populateFromService(using:clearCache:outFields:)" + ], + "redirect_from": [], + "relevant_apis": [ + "FeatureLayer", + "FeatureRequestMode", + "ServiceFeatureTable", + "ServiceFeatureTable.featureRequestMode", + "ServiceFeatureTable.populateFromService(using:clearCache:outFields:)" + ], + "snippets": [ + "SetFeatureRequestModeView.swift" + ], + "title": "Set feature request mode" +} diff --git a/Shared/Samples/Set feature request mode/SetFeatureRequestModeView.swift b/Shared/Samples/Set feature request mode/SetFeatureRequestModeView.swift new file mode 100644 index 000000000..20ce4d259 --- /dev/null +++ b/Shared/Samples/Set feature request mode/SetFeatureRequestModeView.swift @@ -0,0 +1,186 @@ +// 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 ArcGIS +import SwiftUI + +struct SetFeatureRequestModeView: View { + /// The view model for the sample. + @StateObject private var model = Model() + + /// The feature table's current feature request mode. + @State private var selectedFeatureRequestMode: FeatureRequestMode = .onInteractionCache + + /// The text shown in the overlay at the top of the screen. + @State private var message = "" + + /// A Boolean value indicating whether the feature table is being populated. + @State private var isPopulating = false + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + GeometryReader { geometryProxy in + MapViewReader { mapViewProxy in + MapView(map: model.map) + .overlay(alignment: .top) { + Text(message) + .frame(maxWidth: .infinity, alignment: .center) + .padding(8) + .background(.thinMaterial, ignoresSafeAreaEdges: .horizontal) + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Button("Populate") { + isPopulating = true + } + .disabled(selectedFeatureRequestMode != .manualCache) + .task(id: isPopulating) { + // Populate the feature table when the "Populate" button is tapped. + guard isPopulating else { return } + defer { isPopulating = false } + + do { + // Get the current extent of the screen. + let viewRect = geometryProxy.frame(in: .local) + let viewExtent = mapViewProxy.envelope(fromViewRect: viewRect) + + // Populate the feature table with features contained in extent. + let count = try await model.populateFeatures(within: viewExtent) + message = "Populated \(count) features." + } catch { + self.error = error + } + } + + Picker("Feature Request Mode", selection: $selectedFeatureRequestMode) { + ForEach(FeatureRequestMode.modeCases, id: \.self) { mode in + Text(mode.label) + } + } + .onChange(of: selectedFeatureRequestMode) { newMode in + // Update the feature table's feature request mode. + model.featureTable.featureRequestMode = newMode + message = "\(model.featureTable.featureRequestMode.label) enabled." + } + } + } + } + } + .overlay(alignment: .center) { + if isPopulating { + VStack { + Text("Populating") + ProgressView() + .progressViewStyle(.circular) + } + .padding() + .background(.ultraThickMaterial) + .cornerRadius(10) + .shadow(radius: 50) + } + } + .errorAlert(presentingError: $error) + } +} + +private extension SetFeatureRequestModeView { + /// The view model for the sample. + class Model: ObservableObject { + /// A map with a topographic basemap centered on Portland OR, USA. + let map: Map = { + let map = Map(basemapStyle: .arcGISTopographic) + map.initialViewpoint = Viewpoint(latitude: 45.5266, longitude: -122.6219, scale: 6e3) + return map + }() + + /// The service feature table. + let featureTable: ServiceFeatureTable = { + // Create the table from a URL. + let featureTable = ServiceFeatureTable(url: .treesOfPortland) + + // Set the initial table's feature request mode. + featureTable.featureRequestMode = .onInteractionCache + + return featureTable + }() + + init() { + // Create a feature layer from the feature table and add it to the map. + let featureLayer = FeatureLayer(featureTable: featureTable) + map.addOperationalLayer(featureLayer) + } + + /// Populates the feature table using queried features contained within a given geometry. + /// - Parameter geometry: The geometry used to filter the results. + /// - Returns: The number of features populated. + func populateFeatures(within geometry: Geometry?) async throws -> Int { + // Create query parameters to filter for all tree + // conditions except "dead" (coded value '4'). + let queryParameters = QueryParameters() + queryParameters.whereClause = "Condition < '4'" + queryParameters.geometry = geometry + + // Use the query parameters to populate the feature table. + let featureQueryResult = try await featureTable.populateFromService( + using: queryParameters, + clearCache: true, + outFields: ["*"] + ) + + // Get the amount of features found from the feature query result. + let featureCount = featureQueryResult.features().reduce(into: Int()) { result, _ in + result += 1 + } + return featureCount + } + } +} + +private extension FeatureRequestMode { + /// The feature request mode cases that represent a valid mode, e.i., not `undefined`. + static var modeCases: [Self] { + return [.onInteractionCache, .onInteractionNoCache, .manualCache] + } + + /// A human-readable label for the feature request mode. + var label: String { + switch self { + case .undefined: + return "Undefined" + case .manualCache: + return "Manual Cache" + case .onInteractionCache: + return "Cache" + case .onInteractionNoCache: + return "No Cache" + @unknown default: + return "Unknown" + } + } +} + +private extension URL { + /// A URL to a feature layer from the "Trees of Portland" feature service. + static var treesOfPortland: URL { + URL(string: "https://services2.arcgis.com/ZQgQTuoyBrtmoGdP/arcgis/rest/services/Trees_of_Portland/FeatureServer/0")! + } +} + +#Preview { + NavigationView { + SetFeatureRequestModeView() + } +} diff --git a/Shared/Samples/Set feature request mode/set-feature-request-mode.png b/Shared/Samples/Set feature request mode/set-feature-request-mode.png new file mode 100644 index 000000000..b6658c0fc Binary files /dev/null and b/Shared/Samples/Set feature request mode/set-feature-request-mode.png differ diff --git a/Shared/Samples/Set max extent/SetMaxExtentView.swift b/Shared/Samples/Set max extent/SetMaxExtentView.swift index 7de2698ca..5e3c64dc9 100644 --- a/Shared/Samples/Set max extent/SetMaxExtentView.swift +++ b/Shared/Samples/Set max extent/SetMaxExtentView.swift @@ -45,8 +45,7 @@ struct SetMaxExtentView: View { MapView(map: map, graphicsOverlays: [graphicsOverlay]) .toolbar { ToolbarItem(placement: .bottomBar) { - Toggle(maxExtentIsSet ? "Max Extent On" : "Max Extent Off", isOn: $maxExtentIsSet) - .toggleStyle(.button) + Toggle(maxExtentIsSet ? "Max Extent Enabled" : "Max Extent Disabled", isOn: $maxExtentIsSet) .onChange(of: maxExtentIsSet) { newValue in if newValue { // Set the map's max extent to limit the map view to a certain @@ -74,3 +73,9 @@ private extension Envelope { ) } } + +#Preview { + NavigationView { + SetMaxExtentView() + } +} diff --git a/Shared/Samples/Set max extent/set-max-extent.png b/Shared/Samples/Set max extent/set-max-extent.png index e7dbdad1d..5a0039269 100644 Binary files a/Shared/Samples/Set max extent/set-max-extent.png and b/Shared/Samples/Set max extent/set-max-extent.png differ diff --git a/Shared/Samples/Set min and max scale/SetMinAndMaxScaleView.swift b/Shared/Samples/Set min and max scale/SetMinAndMaxScaleView.swift index ba12b044f..90ac225e3 100644 --- a/Shared/Samples/Set min and max scale/SetMinAndMaxScaleView.swift +++ b/Shared/Samples/Set min and max scale/SetMinAndMaxScaleView.swift @@ -34,3 +34,7 @@ struct SetMinAndMaxScaleView: View { MapView(map: map) } } + +#Preview { + SetMinAndMaxScaleView() +} diff --git a/Shared/Samples/Set surface placement mode/SetSurfacePlacementModeView.swift b/Shared/Samples/Set surface placement mode/SetSurfacePlacementModeView.swift index 077964510..240cb846e 100644 --- a/Shared/Samples/Set surface placement mode/SetSurfacePlacementModeView.swift +++ b/Shared/Samples/Set surface placement mode/SetSurfacePlacementModeView.swift @@ -222,3 +222,7 @@ private extension URL { URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")! } } + +#Preview { + SetSurfacePlacementModeView() +} diff --git a/Shared/Samples/Set up location-driven geotriggers/SetUpLocationDrivenGeotriggersView.swift b/Shared/Samples/Set up location-driven geotriggers/SetUpLocationDrivenGeotriggersView.swift index 5eb526bf8..982e2ae83 100644 --- a/Shared/Samples/Set up location-driven geotriggers/SetUpLocationDrivenGeotriggersView.swift +++ b/Shared/Samples/Set up location-driven geotriggers/SetUpLocationDrivenGeotriggersView.swift @@ -150,3 +150,9 @@ extension SetUpLocationDrivenGeotriggersView { } } } + +#Preview { + NavigationView { + SetUpLocationDrivenGeotriggersView() + } +} diff --git a/Shared/Samples/Set viewpoint rotation/SetViewpointRotationView.swift b/Shared/Samples/Set viewpoint rotation/SetViewpointRotationView.swift index 8957c968d..10f2efceb 100644 --- a/Shared/Samples/Set viewpoint rotation/SetViewpointRotationView.swift +++ b/Shared/Samples/Set viewpoint rotation/SetViewpointRotationView.swift @@ -67,3 +67,7 @@ struct SetViewpointRotationView: View { .padding(.bottom) } } + +#Preview { + SetViewpointRotationView() +} diff --git a/Shared/Samples/Set visibility of subtype sublayer/SetVisibilityOfSubtypeSublayerView.swift b/Shared/Samples/Set visibility of subtype sublayer/SetVisibilityOfSubtypeSublayerView.swift index 38ffbc823..8bd440616 100644 --- a/Shared/Samples/Set visibility of subtype sublayer/SetVisibilityOfSubtypeSublayerView.swift +++ b/Shared/Samples/Set visibility of subtype sublayer/SetVisibilityOfSubtypeSublayerView.swift @@ -39,8 +39,8 @@ struct SetVisibilityOfSubtypeSublayerView: View { .multilineTextAlignment(.center) } .toolbar { - ToolbarItemGroup(placement: .bottomBar) { - Button("Settings") { + ToolbarItem(placement: .bottomBar) { + Button("Visibility Settings") { isShowingSettings.toggle() } .sheet(isPresented: $isShowingSettings, detents: [.medium], dragIndicatorVisibility: .visible) { @@ -59,3 +59,9 @@ struct SetVisibilityOfSubtypeSublayerView: View { .errorAlert(presentingError: $error) } } + +#Preview { + NavigationView { + SetVisibilityOfSubtypeSublayerView() + } +} diff --git a/Shared/Samples/Show callout/ShowCalloutView.swift b/Shared/Samples/Show callout/ShowCalloutView.swift index 267ca3d8a..4dcb6a13a 100644 --- a/Shared/Samples/Show callout/ShowCalloutView.swift +++ b/Shared/Samples/Show callout/ShowCalloutView.swift @@ -56,3 +56,7 @@ struct ShowCalloutView: View { } } } + +#Preview { + ShowCalloutView() +} diff --git a/Shared/Samples/Show coordinates in multiple formats/ShowCoordinatesInMultipleFormatsView.swift b/Shared/Samples/Show coordinates in multiple formats/ShowCoordinatesInMultipleFormatsView.swift index f12e8562b..2645d80ce 100644 --- a/Shared/Samples/Show coordinates in multiple formats/ShowCoordinatesInMultipleFormatsView.swift +++ b/Shared/Samples/Show coordinates in multiple formats/ShowCoordinatesInMultipleFormatsView.swift @@ -179,3 +179,7 @@ private extension ShowCoordinatesInMultipleFormatsView { } } } + +#Preview { + ShowCoordinatesInMultipleFormatsView() +} diff --git a/Shared/Samples/Show device location history/ShowDeviceLocationHistoryView.swift b/Shared/Samples/Show device location history/ShowDeviceLocationHistoryView.swift index d785752e7..86f941c0d 100644 --- a/Shared/Samples/Show device location history/ShowDeviceLocationHistoryView.swift +++ b/Shared/Samples/Show device location history/ShowDeviceLocationHistoryView.swift @@ -50,8 +50,7 @@ struct ShowDeviceLocationHistoryView: View { } .toolbar { ToolbarItem(placement: .bottomBar) { - Toggle(isTracking ? "Stop Tracking" : "Start Tracking", isOn: $isTracking) - .toggleStyle(.button) + Toggle(isTracking ? "Tracking Enabled" : "Tracking Disabled", isOn: $isTracking) .disabled(trackingButtonIsDisabled) } } @@ -209,3 +208,9 @@ private extension ShowDeviceLocationHistoryView { } } } + +#Preview { + NavigationView { + ShowDeviceLocationHistoryView() + } +} diff --git a/Shared/Samples/Show device location history/show-device-location-history.png b/Shared/Samples/Show device location history/show-device-location-history.png index ff065a0f6..282ace99f 100644 Binary files a/Shared/Samples/Show device location history/show-device-location-history.png and b/Shared/Samples/Show device location history/show-device-location-history.png differ diff --git a/Shared/Samples/Show device location with NMEA data sources/README.md b/Shared/Samples/Show device location with NMEA data sources/README.md index 51c945388..6a448343a 100644 --- a/Shared/Samples/Show device location with NMEA data sources/README.md +++ b/Shared/Samples/Show device location with NMEA data sources/README.md @@ -63,6 +63,7 @@ Below is a list of protocol strings for commonly used GNSS external accessories. * com.amanenterprises.nmeasource * com.dualav.xgps150 +* com.emlid.nmea * com.garmin.pvt * com.junipersys.geode * com.leica-geosystems.zeno.gnss diff --git a/Shared/Samples/Show device location/README.md b/Shared/Samples/Show device location/README.md index 437d9a307..6b2eb01f9 100644 --- a/Shared/Samples/Show device location/README.md +++ b/Shared/Samples/Show device location/README.md @@ -40,7 +40,12 @@ Change the "Auto-Pan Mode" to choose if and how the SDK will position the map vi Location permissions are required for this sample. -**Note**: As of iOS 8, you are required to request the user's permission to enable location services. You must include either `NSLocationWhenInUseUsageDescription` or `NSLocationAlwaysUsageDescription` along with a brief description of how you use location services in the Info plist of your project. +**Note**: The default location data source, `SystemLocationDataSource`, needs the app to be authorized in order to access the device's location. The app must contain appropriate purpose strings (`NSLocationWhenInUseUsageDescription`, or `NSLocationAlwaysAndWhenInUseUsageDescription` keys) along with a brief description of how you use location services in the project's Info tab. + +Please read the documentation below for further details. + +* [Requesting authorization to use location services](https://developer.apple.com/documentation/corelocation/requesting_authorization_to_use_location_services) +* [Device location - Location data sources](https://developers.arcgis.com/swift/device-location/#location-data-sources) ## Tags diff --git a/Shared/Samples/Show device location/ShowDeviceLocationView.swift b/Shared/Samples/Show device location/ShowDeviceLocationView.swift index 4b322d727..7034447ce 100644 --- a/Shared/Samples/Show device location/ShowDeviceLocationView.swift +++ b/Shared/Samples/Show device location/ShowDeviceLocationView.swift @@ -123,8 +123,6 @@ private extension ShowDeviceLocationView { } private extension LocationDisplay.AutoPanMode { - static var allCases: [LocationDisplay.AutoPanMode] { [.off, .recenter, .navigation, .compassNavigation] } - /// A human-readable label for each auto-pan mode. var label: String { switch self { @@ -147,3 +145,9 @@ private extension LocationDisplay.AutoPanMode { } } } + +#Preview { + NavigationView { + ShowDeviceLocationView() + } +} diff --git a/Shared/Samples/Show extruded features/ShowExtrudedFeaturesView.swift b/Shared/Samples/Show extruded features/ShowExtrudedFeaturesView.swift index e330293ce..581767f69 100644 --- a/Shared/Samples/Show extruded features/ShowExtrudedFeaturesView.swift +++ b/Shared/Samples/Show extruded features/ShowExtrudedFeaturesView.swift @@ -125,3 +125,7 @@ private extension URL { URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Census/MapServer/3")! } } + +#Preview { + ShowExtrudedFeaturesView() +} diff --git a/Shared/Samples/Show labels on layer/ShowLabelsOnLayerView.swift b/Shared/Samples/Show labels on layer/ShowLabelsOnLayerView.swift index 3ef51231e..cdfe717fb 100644 --- a/Shared/Samples/Show labels on layer/ShowLabelsOnLayerView.swift +++ b/Shared/Samples/Show labels on layer/ShowLabelsOnLayerView.swift @@ -104,3 +104,7 @@ private extension PortalItem.ID { /// An id for a USA Congressional Districts Analysis feature table. static var usaCongressionalDistricts: Self { Self("cc6a869374434bee9fefad45e291b779 ")! } } + +#Preview { + ShowLabelsOnLayerView() +} diff --git a/Shared/Samples/Show line of sight between points/ShowLineOfSightBetweenPointsView.swift b/Shared/Samples/Show line of sight between points/ShowLineOfSightBetweenPointsView.swift index 3804b57a5..1933e6257 100644 --- a/Shared/Samples/Show line of sight between points/ShowLineOfSightBetweenPointsView.swift +++ b/Shared/Samples/Show line of sight between points/ShowLineOfSightBetweenPointsView.swift @@ -87,3 +87,7 @@ private extension URL { URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")! } } + +#Preview { + ShowLineOfSightBetweenPointsView() +} diff --git a/Shared/Samples/Show popup/ShowPopupView.swift b/Shared/Samples/Show popup/ShowPopupView.swift index 691c505af..dfbe4e3e7 100644 --- a/Shared/Samples/Show popup/ShowPopupView.swift +++ b/Shared/Samples/Show popup/ShowPopupView.swift @@ -68,3 +68,7 @@ private extension PortalItem.ID { /// The ID used in the "Incidents in San Francisco" portal item. static var incidentsInSanFrancisco: Self { Self("fb788308ea2e4d8682b9c05ef641f273")! } } + +#Preview { + ShowPopupView() +} diff --git a/Shared/Samples/Show realistic light and shadows/ShowRealisticLightAndShadowsView.swift b/Shared/Samples/Show realistic light and shadows/ShowRealisticLightAndShadowsView.swift index 6b71623d9..6416a96c7 100644 --- a/Shared/Samples/Show realistic light and shadows/ShowRealisticLightAndShadowsView.swift +++ b/Shared/Samples/Show realistic light and shadows/ShowRealisticLightAndShadowsView.swift @@ -58,7 +58,7 @@ struct ShowRealisticLightAndShadowsView: View { } .toolbar { ToolbarItem(placement: .bottomBar) { - Picker("Choose a lighting mode for the scene view.", selection: $lightingMode) { + Picker("Lighting Mode", selection: $lightingMode) { ForEach(SceneView.SunLighting.allCases, id: \.self) { mode in Text(mode.label) } @@ -126,9 +126,9 @@ private extension SceneView.SunLighting { var label: String { switch self { case .lightAndShadows: - return "Light And Shadows" + return "Light and Shadows" case .light: - return "Light only" + return "Light Only" case .off: return "No Light" @unknown default: @@ -140,3 +140,9 @@ private extension SceneView.SunLighting { private extension Date { static let startOfDay = Calendar.current.startOfDay(for: .now) } + +#Preview { + NavigationView { + ShowRealisticLightAndShadowsView() + } +} diff --git a/Shared/Samples/Show realistic light and shadows/show-realistic-light-and-shadows.png b/Shared/Samples/Show realistic light and shadows/show-realistic-light-and-shadows.png index bf135d477..1933f3e51 100644 Binary files a/Shared/Samples/Show realistic light and shadows/show-realistic-light-and-shadows.png and b/Shared/Samples/Show realistic light and shadows/show-realistic-light-and-shadows.png differ diff --git a/Shared/Samples/Show result of spatial operations/ShowResultOfSpatialOperationsView.swift b/Shared/Samples/Show result of spatial operations/ShowResultOfSpatialOperationsView.swift index 676009fed..d5e8c8f5e 100644 --- a/Shared/Samples/Show result of spatial operations/ShowResultOfSpatialOperationsView.swift +++ b/Shared/Samples/Show result of spatial operations/ShowResultOfSpatialOperationsView.swift @@ -167,3 +167,15 @@ private extension Geometry { ) } } + +#if DEBUG +private extension ShowResultOfSpatialOperationsView.Model { + typealias SpatialOperation = ShowResultOfSpatialOperationsView.SpatialOperation +} + +#Preview { + NavigationView { + ShowResultOfSpatialOperationsView() + } +} +#endif diff --git a/Shared/Samples/Show result of spatial relationships/ShowResultOfSpatialRelationshipsView.swift b/Shared/Samples/Show result of spatial relationships/ShowResultOfSpatialRelationshipsView.swift index ee5a753f0..475b933b2 100644 --- a/Shared/Samples/Show result of spatial relationships/ShowResultOfSpatialRelationshipsView.swift +++ b/Shared/Samples/Show result of spatial relationships/ShowResultOfSpatialRelationshipsView.swift @@ -272,3 +272,13 @@ private extension Graphic { return Graphic(geometry: point, symbol: markerSymbol) } } + +#if DEBUG +private extension ShowResultOfSpatialRelationshipsView.Model { + typealias Relationship = ShowResultOfSpatialRelationshipsView.Relationship +} + +#Preview { + ShowResultOfSpatialRelationshipsView() +} +#endif diff --git a/Shared/Samples/Show utility associations/ShowUtilityAssociationsView.swift b/Shared/Samples/Show utility associations/ShowUtilityAssociationsView.swift index 0e7f50119..78cee8524 100644 --- a/Shared/Samples/Show utility associations/ShowUtilityAssociationsView.swift +++ b/Shared/Samples/Show utility associations/ShowUtilityAssociationsView.swift @@ -51,18 +51,21 @@ struct ShowUtilityAssociationsView: View { try? await model.setup() try? await model.addAssociationGraphics(viewpoint: viewpoint, scale: scale) } - .toolbar { - ToolbarItemGroup(placement: .bottomBar) { - legend - } + .overlay(alignment: .topLeading) { + legend + .padding() + .background(.thinMaterial) + .cornerRadius(10) + .shadow(radius: 3) + .padding() } } } private extension ShowUtilityAssociationsView { - /// The legend at the bottom of the screen. + /// The legend for the utility associations. var legend: some View { - HStack { + VStack { Label { Text("Attachment") } icon: { @@ -76,7 +79,6 @@ private extension ShowUtilityAssociationsView { attachmentImage = try? await Symbol.attachment .makeSwatch(scale: displayScale) } - Spacer() Label { Text("Connectivity") } icon: { @@ -268,3 +270,9 @@ private extension Viewpoint { ) } } + +#Preview { + NavigationView { + ShowUtilityAssociationsView() + } +} diff --git a/Shared/Samples/Show utility associations/show-utility-associations.png b/Shared/Samples/Show utility associations/show-utility-associations.png index 36c450949..92059d7db 100644 Binary files a/Shared/Samples/Show utility associations/show-utility-associations.png and b/Shared/Samples/Show utility associations/show-utility-associations.png differ diff --git a/Shared/Samples/Show viewshed from point in scene/ShowViewshedFromPointInSceneView.ViewshedSettingsView.swift b/Shared/Samples/Show viewshed from point in scene/ShowViewshedFromPointInSceneView.ViewshedSettingsView.swift index 970c4bed8..71192190c 100644 --- a/Shared/Samples/Show viewshed from point in scene/ShowViewshedFromPointInSceneView.ViewshedSettingsView.swift +++ b/Shared/Samples/Show viewshed from point in scene/ShowViewshedFromPointInSceneView.ViewshedSettingsView.swift @@ -17,7 +17,7 @@ import SwiftUI extension ShowViewshedFromPointInSceneView { struct ViewshedSettingsView: View { /// The view model for the sample. - @EnvironmentObject private var model: Model + @ObservedObject var model: Model var body: some View { List { diff --git a/Shared/Samples/Show viewshed from point in scene/ShowViewshedFromPointInSceneView.swift b/Shared/Samples/Show viewshed from point in scene/ShowViewshedFromPointInSceneView.swift index 1dd3e6a2f..da7c2d698 100644 --- a/Shared/Samples/Show viewshed from point in scene/ShowViewshedFromPointInSceneView.swift +++ b/Shared/Samples/Show viewshed from point in scene/ShowViewshedFromPointInSceneView.swift @@ -37,16 +37,20 @@ struct ShowViewshedFromPointInSceneView: View { .background(.thinMaterial, ignoresSafeAreaEdges: .horizontal) } .toolbar { - ToolbarItemGroup(placement: .bottomBar) { - Spacer() + ToolbarItem(placement: .bottomBar) { Button("Viewshed Settings") { isShowingSettings = true } .sheet(isPresented: $isShowingSettings, detents: [.medium], dragIndicatorVisibility: .visible) { - ViewshedSettingsView() - .environmentObject(model) + ViewshedSettingsView(model: model) } } } } } + +#Preview { + NavigationView { + ShowViewshedFromPointInSceneView() + } +} diff --git a/Shared/Samples/Show viewshed from point in scene/show-viewshed-from-point-in-scene.png b/Shared/Samples/Show viewshed from point in scene/show-viewshed-from-point-in-scene.png index 272a960cb..c23427b26 100644 Binary files a/Shared/Samples/Show viewshed from point in scene/show-viewshed-from-point-in-scene.png and b/Shared/Samples/Show viewshed from point in scene/show-viewshed-from-point-in-scene.png differ diff --git a/Shared/Samples/Style graphics with renderer/StyleGraphicsWithRendererView.swift b/Shared/Samples/Style graphics with renderer/StyleGraphicsWithRendererView.swift index dbcb88caf..a46690f26 100644 --- a/Shared/Samples/Style graphics with renderer/StyleGraphicsWithRendererView.swift +++ b/Shared/Samples/Style graphics with renderer/StyleGraphicsWithRendererView.swift @@ -210,3 +210,7 @@ private extension StyleGraphicsWithRendererView { } } } + +#Preview { + StyleGraphicsWithRendererView() +} diff --git a/Shared/Samples/Style graphics with symbols/StyleGraphicsWithSymbolsView.swift b/Shared/Samples/Style graphics with symbols/StyleGraphicsWithSymbolsView.swift index 49188c402..c84ffb4c1 100644 --- a/Shared/Samples/Style graphics with symbols/StyleGraphicsWithSymbolsView.swift +++ b/Shared/Samples/Style graphics with symbols/StyleGraphicsWithSymbolsView.swift @@ -209,3 +209,7 @@ private extension Geometry { ) } } + +#Preview { + StyleGraphicsWithSymbolsView() +} diff --git a/Shared/Samples/Style point with distance composite scene symbol/README.md b/Shared/Samples/Style point with distance composite scene symbol/README.md new file mode 100644 index 000000000..6b834a55e --- /dev/null +++ b/Shared/Samples/Style point with distance composite scene symbol/README.md @@ -0,0 +1,31 @@ +# Style point with distance composite scene symbol + +Change a graphic's symbol based on the camera's proximity to it. + +![Image of style point with distance composite scene symbol](style-point-with-distance-composite-scene-symbol.png) + +## Use case + +When showing dense datasets, it is beneficial to reduce the detail of individual points when zooming out to avoid visual clutter and to avoid data points overlapping and obscuring each other. + +## How to use the sample + +The sample starts looking at a plane. Zoom out from the plane to see it turn into a cone. Keeping zooming out and it will turn into a point. + +## How it works + +1. Create a `GraphicsOverlay` object and add it to a `SceneView`. +2. Create a `DistanceCompositeSceneSymbol` object. +3. Create `DistanceSymbolRange` objects specifying a `Symbol` and the min and max distance within which the symbol should be visible. +4. Add the ranges to the range collection of the distance composite scene symbol. +5. Create a `Graphic` object with the distance composite scene symbol at a location and add it to the graphics overlay. + +## Relevant API + +* DistanceCompositeSceneSymbol +* DistanceSymbolRange +* OrbitGeoElementCameraController + +## Tags + +3D, data, graphic diff --git a/Shared/Samples/Style point with distance composite scene symbol/README.metadata.json b/Shared/Samples/Style point with distance composite scene symbol/README.metadata.json new file mode 100644 index 000000000..cc8de20c7 --- /dev/null +++ b/Shared/Samples/Style point with distance composite scene symbol/README.metadata.json @@ -0,0 +1,29 @@ +{ + "category": "Scenes", + "description": "Change a graphic's symbol based on the camera's proximity to it.", + "ignore": false, + "images": [ + "style-point-with-distance-composite-scene-symbol.png" + ], + "keywords": [ + "3D", + "data", + "graphic", + "DistanceCompositeSceneSymbol", + "DistanceSymbolRange", + "OrbitGeoElementCameraController" + ], + "offline_data": [ + "681d6f7694644709a7c830ec57a2d72b" + ], + "redirect_from": [], + "relevant_apis": [ + "DistanceCompositeSceneSymbol", + "DistanceSymbolRange", + "OrbitGeoElementCameraController" + ], + "snippets": [ + "StylePointWithDistanceCompositeSceneSymbolView.swift" + ], + "title": "Style point with distance composite scene symbol" +} diff --git a/Shared/Samples/Style point with distance composite scene symbol/StylePointWithDistanceCompositeSceneSymbolView.swift b/Shared/Samples/Style point with distance composite scene symbol/StylePointWithDistanceCompositeSceneSymbolView.swift new file mode 100644 index 000000000..25ec97039 --- /dev/null +++ b/Shared/Samples/Style point with distance composite scene symbol/StylePointWithDistanceCompositeSceneSymbolView.swift @@ -0,0 +1,144 @@ +// 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 ArcGIS +import SwiftUI + +struct StylePointWithDistanceCompositeSceneSymbolView: View { + /// The view model for the sample. + @StateObject private var model = Model() + + /// The distance from the target object to the camera in meters. + @State private var cameraDistance = Measurement(value: 0, unit: UnitLength.meters) + + var body: some View { + SceneView( + scene: model.scene, + cameraController: model.cameraController, + graphicsOverlays: [model.graphicsOverlay] + ) + .overlay(alignment: .top) { + VStack { + Text("Zoom in and out to see the symbol change.") + .frame(maxWidth: .infinity) + .padding(8) + .background(.thinMaterial, ignoresSafeAreaEdges: .horizontal) + + HStack { + Spacer() + HStack { + Text("Distance:") + Text(cameraDistance, format: .measurement( + width: .narrow, + usage: .asProvided, + numberFormatStyle: .number.precision(.fractionLength(0)) + )) + } + .padding() + .background(.ultraThinMaterial) + .cornerRadius(10) + .shadow(radius: 3) + } + .padding(.trailing, 8) + } + } + .task { + for await newDistance in model.cameraController.$cameraDistance { + cameraDistance.value = newDistance + } + } + } +} + +private extension StylePointWithDistanceCompositeSceneSymbolView { + /// The view model for the sample. + class Model: ObservableObject { + /// A scene with an imagery basemap and world elevation surface. + let scene: ArcGIS.Scene = { + let scene = Scene(basemapStyle: .arcGISImagery) + let elevationSource = ArcGISTiledElevationSource(url: .worldElevationService) + scene.baseSurface.addElevationSource(elevationSource) + return scene + }() + + /// The camera controller focused on the plane graphic. + private(set) var cameraController: OrbitGeoElementCameraController! + + /// The graphics overlay for the plane graphic. + let graphicsOverlay: GraphicsOverlay = { + let graphicsOverlay = GraphicsOverlay() + graphicsOverlay.sceneProperties.surfacePlacement = .relative + return graphicsOverlay + }() + + /// The plane graphic created from a distance composite symbol. + private let planeGraphic: Graphic = { + // Create the different symbols. + let planeSymbol = ModelSceneSymbol(url: .bristol, scale: 100) + let coneSymbol = SimpleMarkerSceneSymbol.cone( + color: .red, + diameter: 200, + height: 600, + anchorPosition: .center + ) + coneSymbol.pitch = -90.0 + let circleSymbol = SimpleMarkerSymbol(style: .circle, color: .red, size: 10) + + // Create a distance composite symbol using the symbols. + let distanceCompositeSymbol = DistanceCompositeSceneSymbol() + distanceCompositeSymbol.addRange( + DistanceSymbolRange(symbol: planeSymbol, maxDistance: 10000) + ) + distanceCompositeSymbol.addRange( + DistanceSymbolRange(symbol: coneSymbol, minDistance: 10001, maxDistance: 30000) + ) + distanceCompositeSymbol.addRange( + DistanceSymbolRange(symbol: circleSymbol, minDistance: 30001) + ) + + // Create a graphic using the distance composite symbol. + let planePosition = Point(x: -2.708, y: 56.096, z: 5000, spatialReference: .wgs84) + let planeGraphic = Graphic(geometry: planePosition, symbol: distanceCompositeSymbol) + + return planeGraphic + }() + + init() { + graphicsOverlay.addGraphic(planeGraphic) + + // Create the camera controller targeted on the plane graphic. + cameraController = { + let cameraController = OrbitGeoElementCameraController( + target: planeGraphic, + distance: 4000 + ) + cameraController.cameraPitchOffset = 80 + cameraController.cameraHeadingOffset = -30 + return cameraController + }() + } + } +} + +private extension URL { + /// A URL to the local Bristol 3D model file. + static var bristol: URL { + Bundle.main.url(forResource: "Bristol", withExtension: "dae", subdirectory: "Bristol")! + } + + /// A world elevation service from the Terrain3D ArcGIS REST service. + static var worldElevationService: URL { + URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")! + } +} diff --git a/Shared/Samples/Style point with distance composite scene symbol/style-point-with-distance-composite-scene-symbol.png b/Shared/Samples/Style point with distance composite scene symbol/style-point-with-distance-composite-scene-symbol.png new file mode 100644 index 000000000..5c602fafb Binary files /dev/null and b/Shared/Samples/Style point with distance composite scene symbol/style-point-with-distance-composite-scene-symbol.png differ diff --git a/Shared/Samples/Style point with picture marker symbols/StylePointWithPictureMarkerSymbolsView.swift b/Shared/Samples/Style point with picture marker symbols/StylePointWithPictureMarkerSymbolsView.swift index 8ccecd1bf..ff2e33d15 100644 --- a/Shared/Samples/Style point with picture marker symbols/StylePointWithPictureMarkerSymbolsView.swift +++ b/Shared/Samples/Style point with picture marker symbols/StylePointWithPictureMarkerSymbolsView.swift @@ -91,3 +91,7 @@ struct StylePointWithPictureMarkerSymbolsView: View { MapView(map: map, graphicsOverlays: [graphicsOverlay]) } } + +#Preview { + StylePointWithPictureMarkerSymbolsView() +} diff --git a/Shared/Samples/Style point with scene symbol/README.md b/Shared/Samples/Style point with scene symbol/README.md new file mode 100644 index 000000000..20254ee66 --- /dev/null +++ b/Shared/Samples/Style point with scene symbol/README.md @@ -0,0 +1,36 @@ +# Style point with scene symbol + +Show various kinds of 3D symbols in a scene. + +![Image of style point with scene symbol](style-point-with-scene-symbol.png) + +## Use case + +You can programmatically create different types of 3D symbols and add them to a scene at specified locations. You could do this to call attention to the prominence of a location. + +## How to use the sample + +When the scene loads, note the different types of 3D symbols that you can create. Pan and zoom to observe the symbols. + +## How it works + +1. Create a `GraphicsOverlay`. +2. Create various `SimpleMarkerSceneSymbol`s by specifying different styles and colors, and a height, width, depth, and anchor position of each. +3. Create a `Graphic` for each symbol. +4. Add the graphics to the graphics overlay. +5. Add the graphics overlay to a `SceneView`. + +## Relevant API + +* Graphic +* GraphicsOverlay +* Scene +* SimpleMarkerSceneSymbol + +## About the data + +This sample shows arbitrary symbols in an empty scene with imagery basemap. + +## Tags + +3D, cone, cube, cylinder, diamond, geometry, graphic, graphics overlay, pyramid, scene, shape, sphere, symbol, tetrahedron, tube, visualization diff --git a/Shared/Samples/Style point with scene symbol/README.metadata.json b/Shared/Samples/Style point with scene symbol/README.metadata.json new file mode 100644 index 000000000..f396d9b05 --- /dev/null +++ b/Shared/Samples/Style point with scene symbol/README.metadata.json @@ -0,0 +1,41 @@ +{ + "category": "Scenes", + "description": "Show various kinds of 3D symbols in a scene.", + "ignore": false, + "images": [ + "style-point-with-scene-symbol.png" + ], + "keywords": [ + "3D", + "cone", + "cube", + "cylinder", + "diamond", + "geometry", + "graphic", + "graphics overlay", + "pyramid", + "scene", + "shape", + "sphere", + "symbol", + "tetrahedron", + "tube", + "visualization", + "Graphic", + "GraphicsOverlay", + "Scene", + "SimpleMarkerSceneSymbol" + ], + "redirect_from": [], + "relevant_apis": [ + "Graphic", + "GraphicsOverlay", + "Scene", + "SimpleMarkerSceneSymbol" + ], + "snippets": [ + "StylePointWithSceneSymbolView.swift" + ], + "title": "Style point with scene symbol" +} diff --git a/Shared/Samples/Style point with scene symbol/StylePointWithSceneSymbolView.swift b/Shared/Samples/Style point with scene symbol/StylePointWithSceneSymbolView.swift new file mode 100644 index 000000000..22b888757 --- /dev/null +++ b/Shared/Samples/Style point with scene symbol/StylePointWithSceneSymbolView.swift @@ -0,0 +1,118 @@ +// 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 ArcGIS +import SwiftUI + +struct StylePointWithSceneSymbolView: View { + /// A scene with a topographic basemap and elevation surface. + @State private var scene: ArcGIS.Scene = { + // Create a scene with an initial viewpoint. + let scene = Scene(basemapStyle: .arcGISTopographic) + let camera = Camera( + latitude: 48.973, + longitude: 4.92, + altitude: 2082, + heading: 60, + pitch: 75, + roll: 0 + ) + scene.initialViewpoint = Viewpoint(latitude: .nan, longitude: .nan, scale: .nan, camera: camera) + + // Add an elevation source to the base surface. + let elevationSource = ArcGISTiledElevationSource(url: .worldElevationService) + scene.baseSurface.addElevationSource(elevationSource) + + return scene + }() + + /// The graphics overlay for the scene symbol graphics. + @State private var graphicsOverlay: GraphicsOverlay = { + let graphicsOverlay = GraphicsOverlay() + graphicsOverlay.sceneProperties.surfacePlacement = .absolute + return graphicsOverlay + }() + + init() { + // Add the scene symbol graphics to the graphics overlay. + graphicsOverlay.addGraphics(makeGraphics()) + } + + var body: some View { + // Add the scene and graphics overlay to a scene view. + SceneView(scene: scene, graphicsOverlays: [graphicsOverlay]) + } + + /// Creates a graphic for each simple marker scene symbol style. + /// - Returns: A list of graphics. + private func makeGraphics() -> [Graphic] { + // Create a simple maker scene symbol for each style. + let sceneSymbols = SimpleMarkerSceneSymbol.Style.allCases.map { style in + SimpleMarkerSceneSymbol( + style: style, + color: .random(), + height: 200, + width: 200, + depth: 200, + anchorPosition: .center + ) + } + + // Create a graphic for each scene symbol. + let startingX = 4.975 + let graphics = sceneSymbols.enumerated().map { offset, symbol in + let point = Point( + x: startingX + 0.01 * Double(offset), + y: 49, + z: 500, + spatialReference: .wgs84 + ) + return Graphic(geometry: point, symbol: symbol) + } + + return graphics + } +} + +private extension UIColor { + /// Creates a random color whose red, green, and blue values are in the + /// range `0...1` and whose alpha value is `1`. + /// - Returns: A new `UIColor` object. + static func random() -> UIColor { + let range: ClosedRange = 0...1 + return UIColor( + red: .random(in: range), + green: .random(in: range), + blue: .random(in: range), + alpha: 1 + ) + } +} + +private extension SimpleMarkerSceneSymbol.Style { + static var allCases: [Self] { + return [.cone, .cube, .cylinder, .diamond, .sphere, .tetrahedron] + } +} + +private extension URL { + /// A world elevation service from the Terrain3D ArcGIS REST service. + static var worldElevationService: URL { + URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")! + } +} + +#Preview { + StylePointWithSceneSymbolView() +} diff --git a/Shared/Samples/Style point with scene symbol/style-point-with-scene-symbol.png b/Shared/Samples/Style point with scene symbol/style-point-with-scene-symbol.png new file mode 100644 index 000000000..f88295f60 Binary files /dev/null and b/Shared/Samples/Style point with scene symbol/style-point-with-scene-symbol.png differ diff --git a/Shared/Samples/Trace utility network/TraceUtilityNetworkView.swift b/Shared/Samples/Trace utility network/TraceUtilityNetworkView.swift index 3b3483b1c..dbf93f314 100644 --- a/Shared/Samples/Trace utility network/TraceUtilityNetworkView.swift +++ b/Shared/Samples/Trace utility network/TraceUtilityNetworkView.swift @@ -17,7 +17,7 @@ import SwiftUI struct TraceUtilityNetworkView: View { /// The view model for the sample. - @StateObject var model = TraceUtilityNetworkView.Model() + @StateObject var model = Model() var body: some View { MapViewReader { mapViewProxy in @@ -107,3 +107,9 @@ private extension Viewpoint { ) } } + +#Preview { + NavigationView { + TraceUtilityNetworkView() + } +} diff --git a/Shared/Samples/Validate utility network topology/README.md b/Shared/Samples/Validate utility network topology/README.md new file mode 100644 index 000000000..fa7fd20f3 --- /dev/null +++ b/Shared/Samples/Validate utility network topology/README.md @@ -0,0 +1,62 @@ +# Validate utility network topology + +Demonstrates the workflow of getting the network state and validating the topology of a utility network. + +![Image of Validate utility network topology](validate-utility-network-topology.png) + +## Use case + +Dirty areas are generated where edits to utility network features have not been evaluated against the network rules. Tracing across this area could result in an error or return inaccurate results. Validating the utility network updates the network topology with the edited feature data, maintaining consistency between the features and topology. Querying the network state allows you to determine if there are dirty areas or errors in a utility network, and if it supports network topology. + +## How to use the sample + +Select a feature to make edits and then tap "Apply" to send edits to the server. + +* Tap "Get state" to check if validate is required or if tracing is available. +* Tap "Validate" to validate network topology and clear dirty areas. +* Tap "Trace" to run a trace. + +## How it works + +1. Create and load a `Map` with a web map item URL. +2. Load the `UtilityNetwork` from the web map and switch its `ServiceGeodatabase` to a new branch version. +3. Add `LabelDefinition`s for the fields that will be updated on a feature edit. +4. Add the `UtilityNetwork.dirtyAreaTable` to the map to visualize dirty areas or errors. +5. Set a default starting location and trace parameters to stop traversability on an open device. +6. Get the `UtilityNetworkCapabilities` from the `UtilityNetworkDefinition` and use these values to enable or disable the 'Get State', 'Validate', and 'Trace' buttons. +7. When an `ArcGISFeature` is selected for editing, populate the choice list for the field value using the field's `CodedValueDomain.codedValues`. +8. When "Apply" is tapped, update the value of the selected feature's attribute value with the selected `CodedValue.code` and call `ServiceGeodatabase.applyEdits()`. +9. When "Get State" is tapped, call `UtilityNetwork.state` and print the results. +10. When "Validate" is tapped, get the current map extent and call `UtilityNetwork.validateNetworkTopology(forExtent:executionType:)`. +11. When "Trace" is tapped, call `UtilityNetwork.trace(using:)` with the predefined parameters and select all features returned. +12. When "Clear" or "Cancel" are tapped, clear all selected features on each layer in the map and close the attribute picker. + +## Relevant API + +* UtilityElement +* UtilityElementTraceResult +* UtilityNetwork +* UtilityNetworkCapabilities +* UtilityNetworkState +* UtilityNetworkValidationJob +* UtilityTraceConfiguration +* UtilityTraceParameters +* UtilityTraceResult + +## About the data + +The [Naperville electric](https://sampleserver7.arcgisonline.com/server/rest/services/UtilityNetwork/NapervilleElectricV5/FeatureServer) feature service contains a utility network that can be used to query the network state and validate network topology before tracing. The [Naperville electric webmap](https://sampleserver7.arcgisonline.com/portal/home/item.html?id=6e3fc6db3d0b4e6589eb4097eb3e5b9b) uses the same feature service endpoint and is shown in this sample. Authentication is required and handled within the sample code. + +## Additional information + +Starting from 200.4, an Advanced Editing extension is required for editing a utility network in the following cases: + +* Stand-alone mobile geodatabase that is exported from ArcGIS Pro 2.7 or higher +* Sync-enabled mobile geodatabase that is generated from an ArcGIS Enterprise Feature Service 11.2 or higher +* Web map or service geodatabase that points to an ArcGIS Enterprise Feature Service 11.2 or higher + +Please refer to the "Advanced Editing" section in the extension license table in [License and deployment](https://developers.arcgis.com/swift/license-and-deployment/license-levels-and-capabilities/) for details. + +## Tags + +dirty areas, edit, network topology, online, state, trace, utility network, validate diff --git a/Shared/Samples/Validate utility network topology/README.metadata.json b/Shared/Samples/Validate utility network topology/README.metadata.json new file mode 100644 index 000000000..aa69bf2aa --- /dev/null +++ b/Shared/Samples/Validate utility network topology/README.metadata.json @@ -0,0 +1,45 @@ +{ + "category": "Utility Networks", + "description": "Demonstrates the workflow of getting the network state and validating the topology of a utility network.", + "ignore": false, + "images": [ + "validate-utility-network-topology.png" + ], + "keywords": [ + "dirty areas", + "edit", + "network topology", + "online", + "state", + "trace", + "utility network", + "validate", + "UtilityElement", + "UtilityElementTraceResult", + "UtilityNetwork", + "UtilityNetworkCapabilities", + "UtilityNetworkState", + "UtilityNetworkValidationJob", + "UtilityTraceConfiguration", + "UtilityTraceParameters", + "UtilityTraceResult" + ], + "redirect_from": [], + "relevant_apis": [ + "UtilityElement", + "UtilityElementTraceResult", + "UtilityNetwork", + "UtilityNetworkCapabilities", + "UtilityNetworkState", + "UtilityNetworkValidationJob", + "UtilityTraceConfiguration", + "UtilityTraceParameters", + "UtilityTraceResult" + ], + "snippets": [ + "ValidateUtilityNetworkTopologyView.swift", + "ValidateUtilityNetworkTopologyView.Model.swift", + "ValidateUtilityNetworkTopologyView.Views.swift" + ], + "title": "Validate utility network topology" +} diff --git a/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.Model.swift b/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.Model.swift new file mode 100644 index 000000000..f2b083f6f --- /dev/null +++ b/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.Model.swift @@ -0,0 +1,442 @@ +// 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 ArcGIS +import SwiftUI + +extension ValidateUtilityNetworkTopologyView { + /// The view model for the sample. + @MainActor + class Model: ObservableObject { + // MARK: Properties + + /// A map with no specified style. + let map = Map() + + /// The graphics overlay for the starting location graphic. + let graphicsOverlay: GraphicsOverlay = { + let greenCrossSymbol = SimpleMarkerSymbol(style: .cross, color: .green, size: 25) + let graphic = Graphic(symbol: greenCrossSymbol) + return GraphicsOverlay(graphics: [graphic]) + }() + + /// The utility network for the sample. + private var utilityNetwork: UtilityNetwork! + + /// The trace parameters for tracing with the utility network. + private var traceParameters: UtilityTraceParameters! + + /// The feature currently being edited. + private(set) var feature: ArcGISFeature? + + /// The feature's field currently being edited. + private(set) var field: Field? + + /// The coded values from the field's domain. + private(set) var fieldValueOptions: [CodedValue] = [] + + /// The selected field coded value. + @Published var selectedFieldValue: CodedValue? + + /// The text representing the current status. + @Published var statusMessage = "" + + /// A Boolean value indicating whether the current state of the utility network can be obtained. + @Published private(set) var canGetState = false + + /// A Boolean value indicating whether a trace can be run. + @Published private(set) var canTrace = false + + /// A Boolean value indicating whether the utility network topology can be validated. + @Published private(set) var canValidateNetworkTopology = false + + /// A Boolean value indicating whether there is a selection that can be cleared. + @Published private(set) var canClearSelection = false + + deinit { + ArcGISEnvironment.authenticationManager.arcGISCredentialStore.removeAll() + } + + // MARK: Methods + + /// Gets the current state of the utility network and updates the status with the results. + func getState() async throws { + statusMessage = "Getting utility network state…" + + // Get the current state of the utility network. + let state = try await utilityNetwork.state + + // Allow validating if the network contains any dirty areas or errors. + canValidateNetworkTopology = state.hasDirtyAreas || state.hasErrors + + // Allow tracing if the network has topology is enabled. + canTrace = state.networkTopologyIsEnabled + + // Update the status with the state. + let instructionMessage = canValidateNetworkTopology + ? "Tap 'Validate' before trace or expect a trace error." + : "Tap on a feature to edit or tap 'Trace' to run a trace." + + statusMessage = """ + Utility Network State: + Has dirty areas: \(state.hasDirtyAreas) + Has errors: \(state.hasErrors) + Network topology is enabled: \(state.networkTopologyIsEnabled) + \(instructionMessage) + """ + } + + /// Runs a trace and selects features in the map that correspond to the resulting elements. + func trace() async throws { + statusMessage = "Running a downstream trace…" + clearLayerSelections() + + // Get the element trace result from the utility network using the trace parameters. + let traceResults = try await utilityNetwork.trace(using: traceParameters) + guard let elementTraceResult = traceResults.first(where: { $0 is UtilityElementTraceResult }) + as? UtilityElementTraceResult else { return } + + // Select all of elements found. + statusMessage = "Selecting found elements…" + + for layer in map.operationalLayers.compactMap({ $0 as? FeatureLayer }) { + let layerElements = elementTraceResult.elements.filter { element in + element.networkSource.featureTable.tableName == layer.featureTable?.tableName + } + + if !layerElements.isEmpty { + let features = try await utilityNetwork.features(for: layerElements) + layer.selectFeatures(features) + } + } + canClearSelection = true + + statusMessage = "Trace completed: \(elementTraceResult.elements.count) elements found." + } + + /// Validates the utility network topology within a given extent and updates the status with the results. + func validate(forExtent extent: Envelope) async throws { + statusMessage = "Validating utility network topology…" + + // Validate the utility network topology with the extent. + let job = utilityNetwork.validateNetworkTopology(forExtent: extent) + job.start() + let result = try await job.result.get() + + // Update the status with the result. + canValidateNetworkTopology = result.hasDirtyAreas + statusMessage = """ + Network Validation Result + Has dirty areas: \(result.hasDirtyAreas) + Has errors: \(result.hasErrors) + Tap 'Get State' to check the updated network state. + """ + } + + /// Selects a feature from a given list of identify layer results. + /// - Parameter identifyResults: The identify layer results. + func selectFeature(from identifyResults: [IdentifyLayerResult]) { + clearSelection() + + // Get the first feature from the results. + let layerResult = identifyResults.first { + let layerName = $0.layerContent.name + return layerName == .deviceTableName || layerName == .lineTableName + } + guard let feature = layerResult?.geoElements.first as? ArcGISFeature else { return } + + // Get the coded values from the feature's field. + let fieldName: String = feature.table?.tableName == .deviceTableName + ? .deviceStatusField + : .nominalVoltageField + + guard let field = feature.table?.field(named: fieldName), + let codedValues = (field.domain as? CodedValueDomain)?.codedValues else { return } + self.field = field + fieldValueOptions = codedValues + + // Get the current attribute value from the feature. + let fieldValue = feature.attributes[field.name] + selectedFieldValue = codedValues.first { valuesAreEqual($0.code, fieldValue) } + + // Select the identified feature. + let featureLayer = feature.table?.layer as? FeatureLayer + featureLayer?.selectFeature(feature) + canClearSelection = true + self.feature = feature + + statusMessage = "Select a new '\(field.alias)'." + } + + /// Applies the edits to the feature to the service. + func applyEdits() async throws { + guard let feature, + let serviceFeatureTable = feature.table as? ServiceFeatureTable, + let serviceGeodatabase = serviceFeatureTable.serviceGeodatabase, + let fieldName = field?.name else { return } + + // Update the feature with the new value in the it's feature table. + statusMessage = "Updating feature…" + feature.setAttributeValue(selectedFieldValue?.code, forKey: fieldName) + try await serviceFeatureTable.update(feature) + + // Apply the edits in the feature table to the service. + statusMessage = "Applying edits…" + let featureTableEditResults = try await serviceGeodatabase.applyEdits() + + // Determine if the attempt to edit resulted in any errors. + let didCompleteSuccessfully = featureTableEditResults.allSatisfy { tableEditResult in + tableEditResult.editResults.allSatisfy { featureEditResult in + !featureEditResult.didCompleteWithErrors + } + } + + // Update the status with the results. + canValidateNetworkTopology = true + statusMessage = didCompleteSuccessfully ? """ + Edits applied successfully. + Tap 'Get State' to check the updated network state. + """ + : "Apply edits completed with error." + } + + /// Clears the selected feature(s). + func clearSelection() { + clearLayerSelections() + feature = nil + canClearSelection = false + } + + // MARK: Setup + + /// Performs setup tasks such as adding credentials, loading the utility network, and setting up the trace parameters. + func setup() async throws { + // Add the credential to access the web map. + try await ArcGISEnvironment.authenticationManager.arcGISCredentialStore.add(.publicSample) + + try await setupMap() + try await setupUtilityNetwork() + try await setupTraceParameters() + + // Set the initial states using utility network's capabilities. + guard let utilityNetworkCapabilities = utilityNetwork.definition?.capabilities else { return } + canGetState = utilityNetworkCapabilities.supportsNetworkState + canTrace = utilityNetworkCapabilities.supportsTrace + canValidateNetworkTopology = utilityNetworkCapabilities.supportsValidateNetworkTopology + canClearSelection = false + + statusMessage = """ + Utility Network Loaded + Tap on a feature to edit. + Tap 'Get State' to check if validating is necessary or if tracing is available. + Tap 'Trace' to run a trace. + """ + } + + /// Sets up and loads the web map. + private func setupMap() async throws { + statusMessage = "Loading web map…" + + // Create a portal item using the portal and id for the Naperville Electric web map. + let portal = Portal(url: .sampleServerPortal, connection: .authenticated) + let portalItem = PortalItem(portal: portal, id: .napervilleElectric) + + // Set the portal item to the map and load the map. + map.item = portalItem + map.initialViewpoint = Viewpoint(center: Point(x: -9815160, y: 5128880), scale: 3640) + try await map.load() + + // Set the map to load in persistent session mode (workaround for server caching issue). + // https://support.esri.com/en-us/bug/asynchronous-validate-request-for-utility-network-servi-bug-000160443 + map.loadSettings.featureServiceSessionType = .persistent + + // Add labels to the map to visualize attribute editing. + addLabels(to: .deviceTableName, for: .deviceStatusField, color: .blue) + addLabels(to: .lineTableName, for: .nominalVoltageField, color: .red) + } + + /// Loads the utility network and switches to a new version on the service. + private func setupUtilityNetwork() async throws { + statusMessage = "Loading utility network…" + + // Get the utility network from the map. + utilityNetwork = map.utilityNetworks.first + try await utilityNetwork.load() + + // Create service version parameters to restrict editing and tracing on a random branch. + let uniqueString = UUID().uuidString + let parameters = ServiceVersionParameters() + parameters.name = "ValidateNetworkTopology_\(uniqueString)" + parameters.description = "Validate network topology with ArcGIS Maps SDK." + parameters.access = .private + + // Create and switch to a new version on the service geodatabase using the parameters. + let serviceGeodatabase = utilityNetwork.serviceGeodatabase! + let serviceVersionInfo = try await serviceGeodatabase.makeVersion(parameters: parameters) + try await serviceGeodatabase.switchToVersion(named: serviceVersionInfo.name) + + // Add the dirty area table to the map to visualize it. + guard let dirtyAreaTable = utilityNetwork.dirtyAreaTable else { return } + try await dirtyAreaTable.load() + let featureLayer = FeatureLayer(featureTable: dirtyAreaTable) + map.addOperationalLayer(featureLayer) + } + + /// Sets up the starting location and trace parameters for tracing. + private func setupTraceParameters() async throws { + statusMessage = "Loading starting location…" + + // Constants for creating the starting location and trace parameters. + let assetGroupName = "Circuit Breaker" + let assetTypeName = "Three Phase" + let domainNetworkName = "ElectricDistribution" + let tierName = "Medium Voltage Radial" + + // Create the default starting location using the utility network. + guard let networkSource = utilityNetwork.definition?.networkSource(named: .deviceTableName), + let assetGroup = networkSource.assetGroup(named: assetGroupName), + let assetType = assetGroup.assetType(named: assetTypeName), + let startingLocation = utilityNetwork.makeElement( + assetType: assetType, + globalID: .globalID + ) else { return } + + // Set the terminal for the location, in our case, the "Load" terminal. + let terminal = startingLocation.assetType.terminalConfiguration?.terminals.first { + $0.name == "Load" + } + startingLocation.terminal = terminal + + // Add a graphic to indicate the location on the map. + let startFeature = try await utilityNetwork.features(for: [startingLocation]).first + graphicsOverlay.graphics.first?.geometry = startFeature?.geometry + + // Create downstream trace parameters for the location. + traceParameters = UtilityTraceParameters( + traceType: .downstream, + startingLocations: [startingLocation] + ) + + // Set the configuration to stop traversing on an open device. + let domainNetwork = utilityNetwork?.definition?.domainNetwork(named: domainNetworkName) + let sourceTier = domainNetwork?.tier(named: tierName) + traceParameters?.traceConfiguration = sourceTier?.defaultTraceConfiguration + } + + // MARK: Helpers + + /// Clears the selections for all of the map's operational layers.. + private func clearLayerSelections() { + for layer in map.operationalLayers.compactMap({ $0 as? FeatureLayer }) { + layer.clearSelection() + } + } + + /// Adds labels for a given field name to a layer with a given name. + /// - Parameters: + /// - layerName: The name of the layer on the map to display the labels on. + /// - fieldName: The name of the field to display in the labels. + /// - color: The color of the label's text. + private func addLabels(to layerName: String, for fieldName: String, color: UIColor) { + // Create a expression for the label using the given field name. + let expression = SimpleLabelExpression(simpleExpression: "[\(fieldName)]") + + // Create a symbol for label's text using the given color. + let symbol = TextSymbol(color: color, size: 12) + symbol.haloColor = .white + symbol.haloWidth = 2 + + // Create the definition from the expression and text symbol + let definition = LabelDefinition(labelExpression: expression, textSymbol: symbol) + + // Add the definition to the map layer with the given layer name. + let layer = self.map.operationalLayers.first { $0.name == layerName } as? FeatureLayer + layer?.addLabelDefinition(definition) + layer?.labelsAreEnabled = true + } + + /// Determines whether the values of two `Any` types are equal. + /// - Parameters: + /// - lhs: The left hand side value. + /// - rhs: The right hand side value. + /// - Returns: A Boolean value indicating whether the values are equal. + private func valuesAreEqual(_ lhs: Any?, _ rhs: Any?) -> Bool { + guard let lhs = lhs as? any Equatable, + let rhs = rhs as? any Equatable else { return false } + + return lhs.isEqual(to: rhs) || rhs.isEqual(to: lhs) + } + } +} + +// MARK: Extensions + +private extension Equatable { + /// Determines whether a given equatable is equal. + /// - Parameter other: The value to compare. + /// - Returns: A Boolean value indicating whether the value is equal. + func isEqual(to other: any Equatable) -> Bool { + guard let other = other as? Self else { return false } + return self == other + } +} + +private extension String { + /// The name of the "Electric Distribution Device" feature table. + static let deviceTableName = "Electric Distribution Device" + + /// The name of the device status field in the "Electric Distribution Device" feature table. + static let deviceStatusField = "devicestatus" + + /// The name of the "Electric Distribution Line" feature table. + static let lineTableName = "Electric Distribution Line" + + /// The name of the nominal voltage field in the "Electric Distribution Line" feature table. + static let nominalVoltageField = "nominalvoltage" +} + +private extension UUID { + /// The global ID of the feature from which the starting location is created. + static var globalID: UUID { + UUID(uuidString: "1CAF7740-0BF4-4113-8DB2-654E18800028")! + } +} + +private extension URL { + /// The URL for the sample server 7 portal. + static var sampleServerPortal: URL { + URL(string: "https://sampleserver7.arcgisonline.com/portal/sharing/rest")! + } +} + +private extension PortalItem.ID { + /// The ID for the "Naperville Electric" portal item on sample server 7. + static var napervilleElectric: Self { + Self("6e3fc6db3d0b4e6589eb4097eb3e5b9b")! + } +} + +private extension ArcGISCredential { + /// The public credentials for the data in this sample. + /// - Note: Never hardcode login information in a production application. This is done solely for the sake of the sample. + static var publicSample: ArcGISCredential { + get async throws { + try await TokenCredential.credential( + for: .sampleServerPortal, + username: "editor01", + password: "S7#i2LWmYH75" + ) + } + } +} diff --git a/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.Views.swift b/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.Views.swift new file mode 100644 index 000000000..ca0ebf16f --- /dev/null +++ b/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.Views.swift @@ -0,0 +1,127 @@ +// 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 ArcGIS +import SwiftUI + +extension ValidateUtilityNetworkTopologyView { + /// A view allowing a user to edit the attribute field value of the feature. + struct EditFeatureView: View { + /// The view model for the sample. + @ObservedObject var model: Model + + /// The current view model operation being executed. + @Binding var operationSelection: ModelOperation + + /// The action to dismiss the view. + @Environment(\.dismiss) private var dismiss: DismissAction + + var body: some View { + NavigationView { + fieldValuePicker + .navigationTitle("Edit Feature") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Apply") { + operationSelection = .applyEdits + dismiss() + } + } + } + } + .navigationViewStyle(.stack) + } + + /// The picker for the field value options. + private var fieldValuePicker: some View { + Form { + Section(model.field?.alias ?? "Field") { + ForEach(model.fieldValueOptions, id: \.name) { option in + Button { + model.selectedFieldValue = option + } label: { + HStack { + Text(option.name) + Spacer() + if option.name == model.selectedFieldValue?.name { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + } + } + + /// Text with a button for collapsing multiple lines into a single one. + struct CollapsibleText: View { + /// The text to show. + @Binding var text: String + + /// A Boolean value indicating whether the message is presented. + @State private var messageIsPresented = false + + /// The first line of the text. + private var title: String { + text.components(separatedBy: .newlines).first ?? text + } + + /// The text without the title. + private var message: String { + var lines = text.components(separatedBy: .newlines) + lines.removeFirst() + return lines.joined(separator: "\n") + } + + var body: some View { + VStack { + HStack { + Spacer() + Text(title) + .fontWeight(messageIsPresented ? .bold : .regular) + Spacer() + Button { + withAnimation { + messageIsPresented.toggle() + } + } label: { + Image(systemName: messageIsPresented ? "x" : "chevron.down") + .symbolVariant(.circle) + } + .disabled(message.isEmpty) + } + + if messageIsPresented { + Text(message) + } + } + .multilineTextAlignment(.center) + .onChange(of: text) { _ in + // Start with the full text showing to notify the user of the change. + messageIsPresented = !message.isEmpty + } + } + } +} diff --git a/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.swift b/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.swift new file mode 100644 index 000000000..a1765e47d --- /dev/null +++ b/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.swift @@ -0,0 +1,184 @@ +// 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 ArcGIS +import SwiftUI + +struct ValidateUtilityNetworkTopologyView: View { + /// The view model for the sample. + @StateObject private var model = Model() + + /// The visible area on the map. + @State private var visibleArea: ArcGIS.Polygon? + + /// The operation on the model currently being executed. + @State private var selectedOperation: ModelOperation = .setup + + /// A Boolean value indicating whether a model operation is in progress. + @State private var operationIsRunning = false + + /// A Boolean value indicating whether the edit feature sheet is presented. + @State private var editSheetIsPresented = false + + /// A Boolean value indicating whether the details of the status message are presented. + @State private var statusDetailsArePresented = false + + /// The error shown in the error alert. + @State private var error: Error? + + var body: some View { + MapViewReader { mapViewProxy in + MapView(map: model.map, graphicsOverlays: [model.graphicsOverlay]) + .onVisibleAreaChanged { visibleArea = $0 } + .onSingleTapGesture { screenPoint, _ in + selectedOperation = .selectFeature(screenPoint: screenPoint) + } + .contentInsets(.init(top: 0, leading: 0, bottom: 350, trailing: 0)) + .task(id: selectedOperation) { + operationIsRunning = true + defer { operationIsRunning = false } + + do { + switch selectedOperation { + case .setup: + try await model.setup() + + case .getState: + try await model.getState() + + case .trace: + try await model.trace() + + case .validateNetworkTopology: + guard let extent = visibleArea?.extent else { return } + try await model.validate(forExtent: extent) + + case .selectFeature(let screenPoint): + // Identify the tapped layers using the map view proxy. + let identifyResults = try await mapViewProxy.identifyLayers( + screenPoint: screenPoint!, + tolerance: 5 + ) + model.selectFeature(from: identifyResults) + + // Present the sheet to edit the feature if one was selected. + if let feature = model.feature { + editSheetIsPresented = true + + guard let featureCenter = feature.geometry?.extent.center else { return } + await mapViewProxy.setViewpointCenter(featureCenter) + } else { + model.statusMessage = "No feature identified. Tap on a feature." + } + + case .applyEdits: + try await model.applyEdits() + + case .clearSelection: + model.clearSelection() + model.statusMessage = "Selection cleared." + } + } catch { + model.statusMessage = selectedOperation.errorMessage + self.error = error + } + } + } + .overlay(alignment: .top) { + CollapsibleText(text: $model.statusMessage) + .frame(maxWidth: .infinity, alignment: .center) + .padding(8) + .background(.ultraThinMaterial, ignoresSafeAreaEdges: .horizontal) + } + .overlay(alignment: .center) { + if operationIsRunning { + ProgressView() + .padding() + .background(.ultraThickMaterial) + .cornerRadius(10) + .shadow(radius: 50) + } + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Button("Get State") { selectedOperation = .getState } + .disabled(!model.canGetState) + Spacer() + Button("Trace") { selectedOperation = .trace } + .disabled(!model.canTrace) + Spacer() + Button("Validate") { selectedOperation = .validateNetworkTopology } + .disabled(!model.canValidateNetworkTopology) + Spacer() + Button("Clear") { selectedOperation = .clearSelection } + .disabled(!model.canClearSelection) + .sheet(isPresented: $editSheetIsPresented, detents: [.medium]) { + if selectedOperation != .applyEdits { + // Clear the selection if the sheet was dismissed without applying. + selectedOperation = .clearSelection + } + } content: { + EditFeatureView(model: model, operationSelection: $selectedOperation) + } + } + } + .errorAlert(presentingError: $error) + } +} + +extension ValidateUtilityNetworkTopologyView { + /// An enumeration representing an operation run on the view model. + enum ModelOperation: Equatable { + /// Setup the model. + case setup + /// Get the state of the utility network. + case getState + /// Run a utility network trace. + case trace + /// Validate the utility network topology. + case validateNetworkTopology + /// Select a feature on the map at a given screen point. + case selectFeature(screenPoint: CGPoint? = nil) + /// Apply the edits to the feature to the service. + case applyEdits + /// Clear the selected feature(s). + case clearSelection + + /// The message to display if the operations fails. + var errorMessage: String { + switch self { + case .setup: + "Initialization failed." + case .getState: + "Get state failed." + case .trace: + "Trace failed. \nTap 'Get State' to check the updated network state." + case .selectFeature: + "Select feature failed." + case .validateNetworkTopology: + "Validate network topology failed." + case .applyEdits: + "Apply edits failed." + case .clearSelection: + "" + } + } + } +} + +#Preview { + NavigationView { + ValidateUtilityNetworkTopologyView() + } +} diff --git a/Shared/Samples/Validate utility network topology/validate-utility-network-topology.png b/Shared/Samples/Validate utility network topology/validate-utility-network-topology.png new file mode 100644 index 000000000..1fdff8219 Binary files /dev/null and b/Shared/Samples/Validate utility network topology/validate-utility-network-topology.png differ diff --git a/Shared/SamplesApp.swift b/Shared/SamplesApp.swift index 43711db04..ef527d0a4 100644 --- a/Shared/SamplesApp.swift +++ b/Shared/SamplesApp.swift @@ -23,7 +23,7 @@ struct SamplesApp: App { var body: some SwiftUI.Scene { WindowGroup { - ContentView(samples: Self.samples) + ContentView() } } } diff --git a/Shared/Supporting Files/Extensions/Array+RawRepresentable.swift b/Shared/Supporting Files/Extensions/Array+RawRepresentable.swift new file mode 100644 index 000000000..4e9fda590 --- /dev/null +++ b/Shared/Supporting Files/Extensions/Array+RawRepresentable.swift @@ -0,0 +1,37 @@ +// Copyright 2023 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 Foundation + +/// An extension allowing an array to be used with the app storage property wrapper. +extension Array: RawRepresentable where Element == String { + /// Creates a new array from a given raw value. + /// - Parameter rawValue: The raw value of the array to create. + public init(rawValue: String) { + if let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode([Element].self, from: data) { + self = result + } else { + self = [] + } + } + + /// The raw value of the array. + public var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) + else { return "[]" } + return result + } +} diff --git a/Shared/Supporting Files/Extensions/EnvironmentValues+SampleInfoVisibility.swift b/Shared/Supporting Files/Extensions/String.swift similarity index 57% rename from Shared/Supporting Files/Extensions/EnvironmentValues+SampleInfoVisibility.swift rename to Shared/Supporting Files/Extensions/String.swift index 872c641ec..f9cc4e90c 100644 --- a/Shared/Supporting Files/Extensions/EnvironmentValues+SampleInfoVisibility.swift +++ b/Shared/Supporting Files/Extensions/String.swift @@ -1,4 +1,4 @@ -// Copyright 2022 Esri +// Copyright 2023 Esri // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,16 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import SwiftUI +import Foundation -private struct IsSampleInfoViewVisibleKey: EnvironmentKey { - static let defaultValue = false -} - -extension EnvironmentValues { - /// A Boolean value indicating whether the sample's information view is visible. - var isSampleInfoViewVisible: Bool { - get { self[IsSampleInfoViewVisibleKey.self] } - set { self[IsSampleInfoViewVisibleKey.self] = newValue } - } +extension String { + /// The key to read and write the names of the favorite samples to the user defaults. + static var favoriteSampleNames = "favoriteSampleNames" } diff --git a/Shared/Supporting Files/Extensions/View+Sheet.swift b/Shared/Supporting Files/Extensions/View+Sheet.swift index a70abc2f9..7731e2559 100644 --- a/Shared/Supporting Files/Extensions/View+Sheet.swift +++ b/Shared/Supporting Files/Extensions/View+Sheet.swift @@ -26,6 +26,8 @@ extension View { /// - idealHeight: The ideal height of the popover. /// - onDismiss: A closure to execute when dismissing the sheet. /// - content: A closure returning the content of the sheet. + /// - Note: This modifier can have conflict with modal presentation views, such as an alert. + /// When the sheet is presented, it may cause the "already presenting" problem. func sheet( isPresented: Binding, detents: [Detent], @@ -176,28 +178,33 @@ private extension SheetModifier { func makeContentWithSheetWrapper(_ content: Content) -> some View { ZStack { content - .popover( - isPresented: Binding( - get: { isPresented && !isSheetLayout }, - set: { isPresented = $0 } - ) - ) { + .task(id: isPresented) { + if isPresented { + // Sleep to prevent appearing when other content is disappearing. + try? await Task.sleep(nanoseconds: 1000) + + if isSheetLayout { + isSheetVisible = true + } else { + isPopoverVisible = true + } + } else { + isSheetVisible = false + isPopoverVisible = false + } + } + .popover(isPresented: $isPopoverVisible) { sheetContent .frame(idealWidth: idealWidth, idealHeight: idealHeight) - .onAppear { isPopoverVisible = true } .onDisappear { isPopoverVisible = false - if !isPresented { - onDismiss?() - } + isPresented = false + onDismiss?() } } Sheet( - isPresented: Binding( - get: { isPresented && !isPopoverVisible }, - set: { isPresented = $0 } - ), + isPresented: $isSheetVisible, detents: detents.map { $0.sheetDetent }, selection: Binding( get: { @@ -212,9 +219,9 @@ private extension SheetModifier { ) { sheetContent .onDisappear { - if !isPresented { - onDismiss?() - } + isSheetVisible = false + isPresented = false + onDismiss?() } } .fixedSize() @@ -284,7 +291,16 @@ private struct Sheet: UIViewRepresentable where Content: View { guard let rootViewController = uiView.window?.rootViewController else { return } /// A Boolean value indicating whether the presented view controller is a hosting controller. - let isPresentedControllerHostingType = rootViewController.presentedViewController is UIHostingController + let presentedControllerIsHosting = rootViewController.presentedViewController is UIHostingController + + /// A Boolean value indicating whether the presented view controller is an alert controller. + let presentedControllerIsAlert = rootViewController.presentedViewController is UIAlertController + + /// A Boolean value indicating whether the sheet was already presenting. + let wasPresenting = rootViewController.presentedViewController != nil && presentedControllerIsHosting + + /// A Boolean value indicating whether the hosting controller is being dismissed. + let hostingControllerIsBeingDismissed = model.hostingController.isBeingDismissed // Ensures that the device's layout is such that a sheet should be presented and // the hosting controller's sheet presentation controller exists. @@ -292,38 +308,25 @@ private struct Sheet: UIViewRepresentable where Content: View { let sheet = model.hostingController.sheetPresentationController else { // Dismisses the sheet if it is being presented and a popover should // be presented instead. - if isPresentedControllerHostingType && !model.hostingController.isBeingDismissed { + if presentedControllerIsHosting && !hostingControllerIsBeingDismissed { rootViewController.dismiss(animated: false) } return } - /// A Boolean value indicating whether the sheet was already presenting. - let wasPresenting = rootViewController.presentedViewController != nil - - /// A Boolean value indicating whether the hosting controller is being dismissed. - let isHostBeingDismissed = model.hostingController.isBeingDismissed - - /// A Boolean value indicating whether the presented view controller is an alert controller. - let isPresentedControllerAlertType = rootViewController.presentedViewController is UIAlertController - if isPresented && !wasPresenting { // Sets the sheet presentation controller's delegate. sheet.delegate = context.coordinator // Configures the hosting controller's sheet presentation controller. configureSheetPresentationController(sheet) // Presents the hosting controller. - rootViewController.present(model.hostingController, animated: model.isTransitioningFromPopover ? false : true) - } else if !isPresented && wasPresenting && !isHostBeingDismissed && !isPresentedControllerAlertType { + rootViewController.present(model.hostingController, animated: !model.isTransitioningFromPopover) + } else if !isPresented && wasPresenting && !hostingControllerIsBeingDismissed && !presentedControllerIsAlert { // Dismisses the view controller presented by the root view controller // if 'isPresented' is false, but was presenting before (popover), is // not currently being dismissed, and is not an alert. - rootViewController.dismiss(animated: isPresentedControllerHostingType ? true : false) - model.isTransitioningFromPopover = !isPresentedControllerHostingType - } else if isHostBeingDismissed { - // Sets 'isPresented' to false when the hosting controller is being dismissed. - isPresented = false - model.isTransitioningFromPopover = !isPresentedControllerHostingType + rootViewController.dismiss(animated: presentedControllerIsHosting) + model.isTransitioningFromPopover = !presentedControllerIsHosting } else if wasPresenting { // Updates the sheet presentation controller and the root view of the hosting // controller if the sheet was already presenting. diff --git a/Shared/Supporting Files/Models/Sample.swift b/Shared/Supporting Files/Models/Sample.swift index 34e99baa1..c611c6139 100644 --- a/Shared/Supporting Files/Models/Sample.swift +++ b/Shared/Supporting Files/Models/Sample.swift @@ -41,6 +41,12 @@ protocol Sample { // MARK: Computed Variables extension Sample { + /// The URL to a sample's sub-directory on GitHub main branch. + var gitHubURL: URL { + URL(string: "https://github.com/Esri/arcgis-maps-sdk-swift-samples/tree/main/Shared/Samples")! + .appendingPathComponent(name) + } + /// The URL to a sample's `README.md` file. var readmeURL: URL { Bundle.main.url(forResource: name, withExtension: "md", subdirectory: "READMEs")! diff --git a/Shared/Supporting Files/Views/AboutView.swift b/Shared/Supporting Files/Views/AboutView.swift index f30adf8d3..061eaf520 100644 --- a/Shared/Supporting Files/Views/AboutView.swift +++ b/Shared/Supporting Files/Views/AboutView.swift @@ -71,8 +71,8 @@ private extension Bundle { private extension URL { static let developers = URL(string: "https://developers.arcgis.com/swift/")! static let esriCommunity = URL(string: "https://community.esri.com/t5/swift-maps-sdk-questions/bd-p/swift-maps-sdk-questions")! - static let githubRepository = URL(string: "https://github.com/ArcGIS/arcgis-maps-sdk-swift-samples")! - static let toolkit = URL(string: "https://github.com/ArcGIS/arcgis-maps-sdk-swift-toolkit")! + static let githubRepository = URL(string: "https://github.com/Esri/arcgis-maps-sdk-swift-samples")! + static let toolkit = URL(string: "https://github.com/Esri/arcgis-maps-sdk-swift-toolkit")! static let apiReference = URL(string: "https://developers.arcgis.com/swift/api-reference/documentation/arcgis/")! } @@ -80,7 +80,7 @@ private struct AboutList: View { @Environment(\.dismiss) private var dismiss: DismissAction var copyrightText: Text { - Text("Copyright © 2022 - 2023 Esri. All Rights Reserved.") + Text("Copyright © 2022 - 2024 Esri. All Rights Reserved.") } var body: some View { diff --git a/Shared/Supporting Files/Views/CategoriesView.swift b/Shared/Supporting Files/Views/CategoriesView.swift index b69450010..101b86071 100644 --- a/Shared/Supporting Files/Views/CategoriesView.swift +++ b/Shared/Supporting Files/Views/CategoriesView.swift @@ -15,38 +15,35 @@ import SwiftUI struct CategoriesView: View { - /// All samples that will be shown in the categories. - private let samples: [Sample] - - /// The different sample categories generated from the samples list. - private let categories: [String] - - /// The columns for the grid. - private let columns = [ - GridItem(.flexible()), - GridItem(.flexible()) - ] - - init(samples: [Sample]) { - self.samples = samples - let categoriesSet = Set(samples.map(\.category)) - categories = categoriesSet.sorted() - } + /// The sample categories generated from the samples list. + private let categories = Set(SamplesApp.samples.map(\.category)).sorted() var body: some View { ScrollView { - LazyVGrid(columns: columns) { + LazyVGrid(columns: [GridItem(), GridItem()]) { + NavigationLink { + FavoritesView() + .navigationTitle("Favorites") + } label: { + CategoryTile(name: "Favorites") + } + .isDetailLink(false) + .buttonStyle(.plain) + .contentShape(RoundedRectangle(cornerRadius: 30)) + ForEach(categories, id: \.self) { category in NavigationLink { - CategoryList(samples: samples.filter { $0.category == category }) - .navigationTitle(category) + List(SamplesApp.samples.filter { $0.category == category }, id: \.name) { sample in + SampleLink(sample) + } + .listStyle(.sidebar) + .navigationTitle(category) } label: { - CategoryTitleView(category: category) + CategoryTile(name: category) } .isDetailLink(false) - .clipShape(RoundedRectangle(cornerRadius: 15)) - .contentShape(RoundedRectangle(cornerRadius: 30)) .buttonStyle(.plain) + .contentShape(RoundedRectangle(cornerRadius: 30)) } } .padding() @@ -55,47 +52,29 @@ struct CategoriesView: View { } private extension CategoriesView { - struct CategoryList: View { - /// The samples in a category. - let samples: [Sample] + struct CategoryTile: View { + /// The name of the category. + let name: String var body: some View { - List(samples, id: \.name) { sample in - NavigationLink { - SampleDetailView(sample: sample) - .id(sample.name) - } label: { - SampleRow(name: AttributedString(sample.name), description: AttributedString(sample.description)) - } - } - .listStyle(.sidebar) - } - } - - struct CategoryTitleView: View { - /// The category name used to load the images from assets. - let category: String - - var body: some View { - Image("\(category.replacingOccurrences(of: " ", with: "-"))-bg") + Image("\(name.replacingOccurrences(of: " ", with: "-"))-bg") .resizable() .aspectRatio(contentMode: .fit) .overlay { - ZStack { - Color(red: 0.24, green: 0.24, blue: 0.26, opacity: 0.6) - Image("\(category.replacingOccurrences(of: " ", with: "-"))-icon") - .resizable() - .colorInvert() - .padding(10) - .frame(width: 50, height: 50) - .background(.black.opacity(0.75)) - .clipShape(Circle()) - Text(category) - .multilineTextAlignment(.center) - .foregroundColor(.white) - .offset(y: 45) - } + Color(red: 0.24, green: 0.24, blue: 0.26, opacity: 0.6) + Image("\(name.replacingOccurrences(of: " ", with: "-"))-icon") + .resizable() + .colorInvert() + .padding(10) + .frame(width: 50, height: 50) + .background(.black.opacity(0.75)) + .clipShape(Circle()) + Text(name) + .multilineTextAlignment(.center) + .foregroundColor(.white) + .offset(y: 45) } + .clipShape(RoundedRectangle(cornerRadius: 15)) } } } diff --git a/Shared/Supporting Files/Views/ContentView.swift b/Shared/Supporting Files/Views/ContentView.swift index 8a8488cb0..5e5c2dd45 100644 --- a/Shared/Supporting Files/Views/ContentView.swift +++ b/Shared/Supporting Files/Views/ContentView.swift @@ -15,9 +15,6 @@ import SwiftUI struct ContentView: View { - /// All samples retrieved from the Samples directory. - let samples: [Sample] - /// The search query in the search bar. @State private var query = "" @@ -41,7 +38,7 @@ struct ContentView: View { } var sidebar: some View { - Sidebar(samples: samples, query: query) + Sidebar(query: query) .searchable(text: $query) } diff --git a/Shared/Supporting Files/Views/FavoritesView.swift b/Shared/Supporting Files/Views/FavoritesView.swift new file mode 100644 index 000000000..fcf9f2d35 --- /dev/null +++ b/Shared/Supporting Files/Views/FavoritesView.swift @@ -0,0 +1,118 @@ +// Copyright 2023 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 + +struct FavoritesView: View { + /// A Boolean value indicating whether the add favorite sheet is showing. + @State private var addFavoriteSheetIsShowing = false + + /// The names of the favorite samples loaded from user defaults. + @AppStorage(.favoriteSampleNames) private var favoriteNames: [String] = [] + + /// A list of the favorite samples. + private var favoriteSamples: [Sample] { + favoriteNames.compactMap { name in + SamplesApp.samples.first(where: { $0.name == name }) + } + } + + var body: some View { + List { + ForEach(favoriteSamples, id: \.name) { sample in + SampleLink(sample) + } + .onMove { fromOffsets, toOffset in + favoriteNames.move(fromOffsets: fromOffsets, toOffset: toOffset) + } + .onDelete { atOffsets in + favoriteNames.remove(atOffsets: atOffsets) + } + } + .listStyle(.sidebar) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + EditButton() + + Button { + addFavoriteSheetIsShowing = true + } label: { + Image(systemName: "plus") + } + .sheet(isPresented: $addFavoriteSheetIsShowing) { + AddFavoriteView() + } + } + } + } +} + +private extension FavoritesView { + /// A view to add a favorite sample from a searchable list. + struct AddFavoriteView: View { + /// The action to dismiss the sheet. + @Environment(\.dismiss) private var dismiss: DismissAction + + /// The names of the favorite samples loaded from user defaults. + @AppStorage(.favoriteSampleNames) private var favoriteNames: [String] = [] + + /// The search query in the search bar. + @State private var query = "" + + /// The list of samples filtered by the search query. + private var filteredSamples: [Sample] { + query.isEmpty + ? SamplesApp.samples + : SamplesApp.samples.filter { + $0.name.localizedStandardContains(query) + } + } + + var body: some View { + NavigationView { + List { + ForEach(filteredSamples, id: \.name) { sample in + Button { + if !favoriteNames.contains(sample.name) { + favoriteNames.append(sample.name) + } + dismiss() + } label: { + HStack { + Text(sample.name) + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .listStyle(.inset) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Choose a sample to add to Favorites") + .font(.subheadline) + } + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always)) + } + } +} diff --git a/Shared/Supporting Files/Views/SampleDetailView.swift b/Shared/Supporting Files/Views/SampleDetailView.swift index 314fc818e..4a077280d 100644 --- a/Shared/Supporting Files/Views/SampleDetailView.swift +++ b/Shared/Supporting Files/Views/SampleDetailView.swift @@ -84,7 +84,10 @@ struct SampleDetailView: View { .navigationTitle(sample.name) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .topBarTrailing) { +#if targetEnvironment(macCatalyst) + Link("View on GitHub", destination: sample.gitHubURL) +#endif Button { isSampleInfoViewPresented = true } label: { diff --git a/Shared/Supporting Files/Views/SampleInfoView.swift b/Shared/Supporting Files/Views/SampleInfoView.swift index 5ed4a3dc2..2782e818d 100644 --- a/Shared/Supporting Files/Views/SampleInfoView.swift +++ b/Shared/Supporting Files/Views/SampleInfoView.swift @@ -33,7 +33,7 @@ struct SampleInfoView: View { WebView(htmlString: codeHTML) .opacity(informationMode == .code ? 1 : 0) } - .edgesIgnoringSafeArea([.horizontal, .bottom]) + .ignoresSafeArea(edges: [.horizontal, .bottom]) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { diff --git a/Shared/Supporting Files/Views/SampleLink.swift b/Shared/Supporting Files/Views/SampleLink.swift new file mode 100644 index 000000000..22d1f1678 --- /dev/null +++ b/Shared/Supporting Files/Views/SampleLink.swift @@ -0,0 +1,131 @@ +// Copyright 2023 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 + +struct SampleLink: View { + /// The sample to present. + private let sample: Sample + + /// The text to bold in the sample's name and description. + private let textToBold: String + + /// Creates a link that presents a given sample. + /// - Parameters: + /// - sample: The sample to present. + /// - textToBold: The text to bold in the sample's name and description. + init(_ sample: Sample, textToBold: String = "") { + self.sample = sample + self.textToBold = textToBold + } + + var body: some View { + NavigationLink { + SampleDetailView(sample: sample) + .id(sample.name) + } label: { + SampleRow( + name: sample.name.boldingFirstOccurrence(of: textToBold), + description: sample.description.boldingFirstOccurrence(of: textToBold) + ) + } + } +} + +private extension SampleLink { + struct SampleRow: View { + /// The name of the sample. + private let name: String + + /// The name of the sample with attributes. + private let attributedName: AttributedString + + /// The description of the sample with attributes. + private let attributedDescription: AttributedString + + /// A Boolean value indicating whether the sample's description is showing. + @State private var isShowingDescription = false + + /// The names of the favorite samples loaded from user defaults. + @AppStorage(.favoriteSampleNames) private var favoriteNames: [String] = [] + + /// A Boolean value indicating whether the sample is a favorite. + private var sampleIsFavorite: Bool { + favoriteNames.contains(name) + } + + init(name: AttributedString, description: AttributedString) { + self.name = String(name.characters) + self.attributedName = name + self.attributedDescription = description + } + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(attributedName) + + if isShowingDescription { + Text(attributedDescription) + .font(.caption) + .opacity(0.75) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + Spacer() + + if sampleIsFavorite { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + } + + Label {} icon: { + Image(systemName: "info.circle") + .symbolVariant(isShowingDescription ? .fill : .none) + .imageScale(.medium) + } + .onTapGesture { + isShowingDescription.toggle() + } + } + .contextMenu { + Button { + if sampleIsFavorite { + favoriteNames.removeAll { $0 == name } + } else { + favoriteNames.append(name) + } + } label: { + Label(sampleIsFavorite ? "Unfavorite" : "Favorite", systemImage: "star") + .symbolVariant(sampleIsFavorite ? .slash : .none) + } + } + .animation(.easeOut(duration: 0.2), value: isShowingDescription) + } + } +} + +private extension String { + /// Bolds the first occurrence of substring within the string using markdown. + /// - Parameter substring: The substring to bold. + /// - Returns: The attributed string with the bolded substring. + func boldingFirstOccurrence(of substring: String) -> AttributedString { + if let range = localizedStandardRange(of: substring.trimmingCharacters(in: .whitespacesAndNewlines)), + let boldedString = try? AttributedString(markdown: replacingCharacters(in: range, with: "**\(self[range])**")) { + return boldedString + } else { + return AttributedString(self) + } + } +} diff --git a/Shared/Supporting Files/Views/SampleRow.swift b/Shared/Supporting Files/Views/SampleRow.swift deleted file mode 100644 index 736a553c7..000000000 --- a/Shared/Supporting Files/Views/SampleRow.swift +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2023 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 - -struct SampleRow: View { - /// The name string of the sample with attributes. - let name: AttributedString - - /// The description string of the sample with attributes. - let description: AttributedString - - /// A Boolean value that indicates whether to show the sample's description. - @State private var isShowingDescription = false - - var body: some View { - HStack { - VStack(alignment: .leading) { - Text(name) - - if isShowingDescription { - Text(description) - .font(.caption) - .opacity(0.75) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - Spacer() - Label {} icon: { - Image(systemName: "info.circle") - .symbolVariant(isShowingDescription ? .fill : .none) - .imageScale(.medium) - } - .onTapGesture { - isShowingDescription.toggle() - } - } - .animation(.easeOut(duration: 0.2), value: isShowingDescription) - } -} - -extension SampleRow { - /// Creates a sample row. - /// - Parameters: - /// - sample: The sample to display. - /// - query: A string to be bolded in the sample's name or description. - init(sample: Sample, query: String) { - self.init( - name: sample.name.boldingFirstOccurrence(of: query), - description: sample.description.boldingFirstOccurrence(of: query) - ) - } -} - -private extension String { - /// Bolds the first occurrence of substring within the string using markdown. - /// - Parameter substring: The substring to bold. - /// - Returns: The attributed string with the bolded substring. - func boldingFirstOccurrence(of substring: String) -> AttributedString { - if let range = localizedStandardRange(of: substring.trimmingCharacters(in: .whitespacesAndNewlines)), - let boldedString = try? AttributedString(markdown: replacingCharacters(in: range, with: "**\(self[range])**")) { - return boldedString - } else { - return AttributedString(self) - } - } -} diff --git a/Shared/Supporting Files/Views/SamplesSearchView.swift b/Shared/Supporting Files/Views/SamplesSearchView.swift index a70177702..781ae8551 100644 --- a/Shared/Supporting Files/Views/SamplesSearchView.swift +++ b/Shared/Supporting Files/Views/SamplesSearchView.swift @@ -21,41 +21,34 @@ struct SamplesSearchView: View { /// The search result to display in the various sections. private let searchResult: SearchResult + /// Creates a sample search view. + /// - Parameters: + /// - query: The search query in the search bar. + init(query: String) { + self.query = query + self.searchResult = Self.searchSamples(with: query) + } + var body: some View { List { if !searchResult.nameMatches.isEmpty { Section("Name Results") { ForEach(searchResult.nameMatches, id: \.name) { sample in - NavigationLink { - SampleDetailView(sample: sample) - .id(sample.name) - } label: { - SampleRow(sample: sample, query: query) - } + SampleLink(sample, textToBold: query) } } } if !searchResult.descriptionMatches.isEmpty { Section("Description Results") { ForEach(searchResult.descriptionMatches, id: \.name) { sample in - NavigationLink { - SampleDetailView(sample: sample) - .id(sample.name) - } label: { - SampleRow(sample: sample, query: query) - } + SampleLink(sample, textToBold: query) } } } if !searchResult.tagMatches.isEmpty { Section("Tags Results") { ForEach(searchResult.tagMatches, id: \.name) { sample in - NavigationLink { - SampleDetailView(sample: sample) - .id(sample.name) - } label: { - SampleRow(sample: sample, query: query) - } + SampleLink(sample, textToBold: query) } } } @@ -65,17 +58,6 @@ struct SamplesSearchView: View { // MARK: Search -extension SamplesSearchView { - /// Create a sample search view. - /// - Parameters: - /// - samples: All samples retrieved from the Samples directory. - /// - query: The search query in the search bar. - init(samples: [Sample], query: String) { - self.query = query - self.searchResult = Self.searchSamples(in: samples, with: query) - } -} - private extension SamplesSearchView { /// A struct that contains various search results to be displayed in /// different sections in a list. @@ -93,11 +75,11 @@ private extension SamplesSearchView { } } - /// Searches through a list of samples to find ones that match the query. + /// Searches through the list of samples to find ones that match the query. /// - Parameters: - /// - samples: The samples to search through. /// - query: The query to search with. - private static func searchSamples(in samples: [Sample], with query: String) -> SearchResult { + private static func searchSamples(with query: String) -> SearchResult { + let samples = SamplesApp.samples let nameMatches: [Sample] let descriptionMatches: [Sample] let tagMatches: [Sample] diff --git a/Shared/Supporting Files/Views/Sidebar.swift b/Shared/Supporting Files/Views/Sidebar.swift index 04ecba6d7..93083aaf7 100644 --- a/Shared/Supporting Files/Views/Sidebar.swift +++ b/Shared/Supporting Files/Views/Sidebar.swift @@ -21,23 +21,20 @@ struct Sidebar: View { /// A Boolean value that indicates whether to present the about view. @State private var isAboutViewPresented = false - /// All samples retrieved from the Samples directory. - let samples: [Sample] - /// The search query. let query: String var body: some View { Group { if !isSearching { - CategoriesView(samples: samples) + CategoriesView() .navigationTitle("Categories") } else { - SamplesSearchView(samples: samples, query: query) + SamplesSearchView(query: query) } } .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { Button { isAboutViewPresented = true } label: { diff --git a/iOS/Info.plist b/iOS/Info.plist index 8d7cc3671..b11ea3608 100644 --- a/iOS/Info.plist +++ b/iOS/Info.plist @@ -31,6 +31,10 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.education LSRequiresIPhoneOS NSAppTransportSecurity @@ -48,6 +52,8 @@ NSCameraUsageDescription This app uses augmented reality to overlay imagery over your real-world environment. Camera access is required for this functionality. + NSLocationUsageDescription + Your location is used to show your position on the map on Mac Catalyst. NSLocationWhenInUseUsageDescription Your location is used to show your position on the map. UIApplicationSceneManifest