diff --git a/.swiftlint.yml b/.swiftlint.yml index 5274290d6..b6a1b1593 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -21,8 +21,8 @@ disabled_rules: - file_length - for_where - force_cast + - force_try - line_length - - notification_center_detachment - type_body_length - type_name diff --git a/Samples.xcodeproj/project.pbxproj b/Samples.xcodeproj/project.pbxproj index 30ecc067e..3fe1b3966 100644 --- a/Samples.xcodeproj/project.pbxproj +++ b/Samples.xcodeproj/project.pbxproj @@ -239,6 +239,7 @@ 4C8127682DCD4F18006EF7D2 /* ApplyStretchRendererView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 4C8127602DCBED62006EF7D2 /* ApplyStretchRendererView.swift */; }; 4C81276B2DCED03D006EF7D2 /* BrowseWFSLayersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C81276A2DCED03D006EF7D2 /* BrowseWFSLayersView.swift */; }; 4C81278D2DDBC5EB006EF7D2 /* BrowseWFSLayersView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 4C81276A2DCED03D006EF7D2 /* BrowseWFSLayersView.swift */; }; + 4CFC9E132E577FBE00F730C5 /* StringProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFC9E122E577FBB00F730C5 /* StringProtocol.swift */; }; 4D126D6D29CA1B6000CFB7A7 /* ShowDeviceLocationWithNMEADataSourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D126D6929CA1B6000CFB7A7 /* ShowDeviceLocationWithNMEADataSourcesView.swift */; }; 4D126D7229CA1E1800CFB7A7 /* FileNMEASentenceReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D126D7129CA1E1800CFB7A7 /* FileNMEASentenceReader.swift */; }; 4D126D7329CA1EFD00CFB7A7 /* ShowDeviceLocationWithNMEADataSourcesView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 4D126D6929CA1B6000CFB7A7 /* ShowDeviceLocationWithNMEADataSourcesView.swift */; }; @@ -324,6 +325,8 @@ 9520B2B62E135AEE00B3BEF9 /* ShowLineOfSightBetweenGeoelementsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 9520B2B42E135AD800B3BEF9 /* ShowLineOfSightBetweenGeoelementsView.swift */; }; 9525404E2DF904BA004090B9 /* SetFeatureLayerRenderingModeOnSceneView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 9501BBEE2DF39DA50054F4BD /* SetFeatureLayerRenderingModeOnSceneView.swift */; }; 9529D1942C01676200B5C1A3 /* SelectFeaturesInSceneLayerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 954AEDED2C01332600265114 /* SelectFeaturesInSceneLayerView.swift */; }; + 95321B302E554B31002E9DE2 /* ApplyRenderersToSceneLayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95321B2F2E554B20002E9DE2 /* ApplyRenderersToSceneLayerView.swift */; }; + 95321B322E554B88002E9DE2 /* ApplyRenderersToSceneLayerView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 95321B2F2E554B20002E9DE2 /* ApplyRenderersToSceneLayerView.swift */; }; 9537AFD72C220EF0000923C5 /* ExchangeSetwithoutUpdates in Resources */ = {isa = PBXBuildFile; fileRef = 9537AFD62C220EF0000923C5 /* ExchangeSetwithoutUpdates */; settings = {ASSET_TAGS = (ConfigureElectronicNavigationalCharts, ); }; }; 9547085C2C3C719800CA8579 /* EditFeatureAttachmentsView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9547085B2C3C719800CA8579 /* EditFeatureAttachmentsView.Model.swift */; }; 954AEDEE2C01332600265114 /* SelectFeaturesInSceneLayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954AEDED2C01332600265114 /* SelectFeaturesInSceneLayerView.swift */; }; @@ -332,6 +335,8 @@ 955AFAC62C110B8A009C8FE5 /* ApplyMosaicRuleToRastersView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 955AFAC32C10FD6F009C8FE5 /* ApplyMosaicRuleToRastersView.swift */; }; 956332482E3455DB0091C877 /* SimplifyGeometryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956332472E3455D70091C877 /* SimplifyGeometryView.swift */; }; 9563324A2E3456EC0091C877 /* SimplifyGeometryView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 956332472E3455D70091C877 /* SimplifyGeometryView.swift */; }; + 9563324C2E3950880091C877 /* UpdateRelatedFeaturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9563324B2E39507E0091C877 /* UpdateRelatedFeaturesView.swift */; }; + 9563324E2E3950B00091C877 /* UpdateRelatedFeaturesView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 9563324B2E39507E0091C877 /* UpdateRelatedFeaturesView.swift */; }; 9579FCEA2C3360BB00FC8A1D /* EditFeatureAttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9579FCE92C3360BB00FC8A1D /* EditFeatureAttachmentsView.swift */; }; 9579FCEC2C33616B00FC8A1D /* EditFeatureAttachmentsView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = 9579FCE92C3360BB00FC8A1D /* EditFeatureAttachmentsView.swift */; }; 95813E392DF88FD300342CBF /* SetMapImageLayerSublayerVisibilityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95813E382DF88FD000342CBF /* SetMapImageLayerSublayerVisibilityView.swift */; }; @@ -400,6 +405,9 @@ D718A1F02B57602000447087 /* ManageBookmarksView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D718A1EA2B575FD900447087 /* ManageBookmarksView.swift */; }; D71A9DE22D8CC88D00CA03CB /* SnapGeometryEditsWithUtilityNetworkRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71A9DE02D8CC88D00CA03CB /* SnapGeometryEditsWithUtilityNetworkRulesView.swift */; }; D71A9DE52D8CC8B500CA03CB /* SnapGeometryEditsWithUtilityNetworkRulesView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D71A9DE02D8CC88D00CA03CB /* SnapGeometryEditsWithUtilityNetworkRulesView.swift */; }; + D71C01592E3C299D00E87E3D /* QueryDynamicEntitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71C01542E3C299D00E87E3D /* QueryDynamicEntitiesView.swift */; }; + D71C015A2E3C299D00E87E3D /* QueryDynamicEntitiesView.Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71C01552E3C299D00E87E3D /* QueryDynamicEntitiesView.Model.swift */; }; + D71C01602E3C2A6200E87E3D /* phx_air_traffic.json in Resources */ = {isa = PBXBuildFile; fileRef = D71C015E2E3C2A6200E87E3D /* phx_air_traffic.json */; settings = {ASSET_TAGS = (QueryDynamicEntities, ); }; }; 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 */; }; D71C90A22C6C249B0018C63E /* StyleGeometryTypesWithSymbolsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71C909C2C6C249B0018C63E /* StyleGeometryTypesWithSymbolsView.swift */; }; @@ -439,6 +447,8 @@ D7352F8E2BD992C40013FFEF /* MonitorChangesToDrawStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7352F8A2BD992C40013FFEF /* MonitorChangesToDrawStatusView.swift */; }; D7352F912BD992E40013FFEF /* MonitorChangesToDrawStatusView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7352F8A2BD992C40013FFEF /* MonitorChangesToDrawStatusView.swift */; }; D73571D72CB613220046A433 /* hydrography in Resources */ = {isa = PBXBuildFile; fileRef = D73571D62CB6131E0046A433 /* hydrography */; settings = {ASSET_TAGS = (ConfigureElectronicNavigationalCharts, ); }; }; + D73617782E4CF2400008471A /* QueryDynamicEntitiesView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D71C01542E3C299D00E87E3D /* QueryDynamicEntitiesView.swift */; }; + D73617792E4CF2640008471A /* QueryDynamicEntitiesView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D71C01552E3C299D00E87E3D /* QueryDynamicEntitiesView.Model.swift */; }; D73723762AF5877500846884 /* FindRouteInMobileMapPackageView.Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73723742AF5877500846884 /* FindRouteInMobileMapPackageView.Models.swift */; }; 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 */; }; @@ -628,6 +638,7 @@ D7CE9F9B2AE2F575008F7A5F /* streetmap_SD.tpkx in Resources */ = {isa = PBXBuildFile; fileRef = D7CE9F9A2AE2F575008F7A5F /* streetmap_SD.tpkx */; settings = {ASSET_TAGS = (GeocodeOffline, ); }; }; D7CE9FA32AE2F595008F7A5F /* san-diego-eagle-locator in Resources */ = {isa = PBXBuildFile; fileRef = D7CE9FA22AE2F595008F7A5F /* san-diego-eagle-locator */; settings = {ASSET_TAGS = (GeocodeOffline, ); }; }; D7D1F3532ADDBE5D009CE2DA /* philadelphia.mspk in Resources */ = {isa = PBXBuildFile; fileRef = D7D1F3522ADDBE5D009CE2DA /* philadelphia.mspk */; settings = {ASSET_TAGS = (AugmentRealityToShowTabletopScene, DisplaySceneFromMobileScenePackage, ); }; }; + D7D847752DB80F4F00390B1D /* DownloadOfflineResourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D847742DB80F4F00390B1D /* DownloadOfflineResourcesView.swift */; }; D7D9FCF62BF2CC8600F972A2 /* FilterByDefinitionExpressionOrDisplayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D9FCF22BF2CC8600F972A2 /* FilterByDefinitionExpressionOrDisplayFilterView.swift */; }; D7D9FCF92BF2CCA300F972A2 /* FilterByDefinitionExpressionOrDisplayFilterView.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D7D9FCF22BF2CC8600F972A2 /* FilterByDefinitionExpressionOrDisplayFilterView.swift */; }; D7DDF84E2AF43AA2004352D9 /* GeocodeOfflineView.Model.swift in Copy Source Code Files */ = {isa = PBXBuildFile; fileRef = D72C43F22AEB066D00B6157B /* GeocodeOfflineView.Model.swift */; }; @@ -747,6 +758,10 @@ dstPath = ""; dstSubfolderSpec = 7; files = ( + 95321B322E554B88002E9DE2 /* ApplyRenderersToSceneLayerView.swift in Copy Source Code Files */, + D73617792E4CF2640008471A /* QueryDynamicEntitiesView.Model.swift in Copy Source Code Files */, + D73617782E4CF2400008471A /* QueryDynamicEntitiesView.swift in Copy Source Code Files */, + 9563324E2E3950B00091C877 /* UpdateRelatedFeaturesView.swift in Copy Source Code Files */, 9563324A2E3456EC0091C877 /* SimplifyGeometryView.swift in Copy Source Code Files */, 95FEAE562E3BF43200727ABA /* ShowWFSLayerWithXMLQueryView.swift in Copy Source Code Files */, 1062C6C82E3D81E400B2D7FC /* SetKMLGroundOverlayPropertiesView.swift in Copy Source Code Files */, @@ -1182,6 +1197,7 @@ 4C81275C2DCBB72C006EF7D2 /* ShastaBW */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ShastaBW; sourceTree = ""; }; 4C8127602DCBED62006EF7D2 /* ApplyStretchRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplyStretchRendererView.swift; sourceTree = ""; }; 4C81276A2DCED03D006EF7D2 /* BrowseWFSLayersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseWFSLayersView.swift; sourceTree = ""; }; + 4CFC9E122E577FBB00F730C5 /* StringProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringProtocol.swift; sourceTree = ""; }; 4D126D6929CA1B6000CFB7A7 /* ShowDeviceLocationWithNMEADataSourcesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShowDeviceLocationWithNMEADataSourcesView.swift; sourceTree = ""; }; 4D126D7129CA1E1800CFB7A7 /* FileNMEASentenceReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileNMEASentenceReader.swift; sourceTree = ""; }; 4D126D7B29CA3E6000CFB7A7 /* Redlands.nmea */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Redlands.nmea; sourceTree = ""; }; @@ -1226,12 +1242,14 @@ 951896D22E2AE5E400144F9B /* ShowViewshedFromCameraInSceneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowViewshedFromCameraInSceneView.swift; sourceTree = ""; }; 951961AB2E00BC3C0088B0C2 /* ShowGeodesicSectorAndEllipseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowGeodesicSectorAndEllipseView.swift; sourceTree = ""; }; 9520B2B42E135AD800B3BEF9 /* ShowLineOfSightBetweenGeoelementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowLineOfSightBetweenGeoelementsView.swift; sourceTree = ""; }; + 95321B2F2E554B20002E9DE2 /* ApplyRenderersToSceneLayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplyRenderersToSceneLayerView.swift; sourceTree = ""; }; 9537AFD62C220EF0000923C5 /* ExchangeSetwithoutUpdates */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ExchangeSetwithoutUpdates; sourceTree = ""; }; 9547085B2C3C719800CA8579 /* EditFeatureAttachmentsView.Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFeatureAttachmentsView.Model.swift; sourceTree = ""; }; 954AEDED2C01332600265114 /* SelectFeaturesInSceneLayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectFeaturesInSceneLayerView.swift; sourceTree = ""; }; 955271602C0E6749009B1ED4 /* AddRasterFromServiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddRasterFromServiceView.swift; sourceTree = ""; }; 955AFAC32C10FD6F009C8FE5 /* ApplyMosaicRuleToRastersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplyMosaicRuleToRastersView.swift; sourceTree = ""; }; 956332472E3455D70091C877 /* SimplifyGeometryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplifyGeometryView.swift; sourceTree = ""; }; + 9563324B2E39507E0091C877 /* UpdateRelatedFeaturesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateRelatedFeaturesView.swift; sourceTree = ""; }; 9579FCE92C3360BB00FC8A1D /* EditFeatureAttachmentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFeatureAttachmentsView.swift; sourceTree = ""; }; 957B25D32E31BE23007E88F9 /* ShowWFSLayerWithXMLQueryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowWFSLayerWithXMLQueryView.swift; sourceTree = ""; }; 95813E382DF88FD000342CBF /* SetMapImageLayerSublayerVisibilityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetMapImageLayerSublayerVisibilityView.swift; sourceTree = ""; }; @@ -1269,6 +1287,9 @@ 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 = ""; }; D71A9DE02D8CC88D00CA03CB /* SnapGeometryEditsWithUtilityNetworkRulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapGeometryEditsWithUtilityNetworkRulesView.swift; sourceTree = ""; }; + D71C01542E3C299D00E87E3D /* QueryDynamicEntitiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryDynamicEntitiesView.swift; sourceTree = ""; }; + D71C01552E3C299D00E87E3D /* QueryDynamicEntitiesView.Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryDynamicEntitiesView.Model.swift; sourceTree = ""; }; + D71C015E2E3C2A6200E87E3D /* phx_air_traffic.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = phx_air_traffic.json; sourceTree = ""; }; D71C5F632AAA7A88006599FD /* CreateSymbolStylesFromWebStylesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSymbolStylesFromWebStylesView.swift; sourceTree = ""; }; D71C909C2C6C249B0018C63E /* StyleGeometryTypesWithSymbolsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StyleGeometryTypesWithSymbolsView.swift; sourceTree = ""; }; D71C909D2C6C249B0018C63E /* StyleGeometryTypesWithSymbolsView.Views.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StyleGeometryTypesWithSymbolsView.Views.swift; sourceTree = ""; }; @@ -1400,6 +1421,7 @@ 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 = ""; }; D7D1F3522ADDBE5D009CE2DA /* philadelphia.mspk */ = {isa = PBXFileReference; lastKnownFileType = file; path = philadelphia.mspk; sourceTree = ""; }; + D7D847742DB80F4F00390B1D /* DownloadOfflineResourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOfflineResourcesView.swift; sourceTree = ""; }; D7D9FCF22BF2CC8600F972A2 /* FilterByDefinitionExpressionOrDisplayFilterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterByDefinitionExpressionOrDisplayFilterView.swift; sourceTree = ""; }; D7DDF8502AF47C6C004352D9 /* FindRouteAroundBarriersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindRouteAroundBarriersView.swift; sourceTree = ""; }; D7DFA0E62CBA0242007C31F2 /* AddMapImageLayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMapImageLayerView.swift; sourceTree = ""; }; @@ -1473,6 +1495,7 @@ 00B04272282EC59E0072E1B4 /* AboutView.swift */, D70BE5782A5624A80022CA02 /* CategoriesView.swift */, 00E5400D27F3CCA100CF66D5 /* ContentView.swift */, + D7D847742DB80F4F00390B1D /* DownloadOfflineResourcesView.swift */, D7B759B22B1FFBE300017FDD /* FavoritesView.swift */, 000558092817C51E00224BC6 /* SampleDetailView.swift */, E041ABD6287DB04D0056009B /* SampleInfoView.swift */, @@ -1499,6 +1522,7 @@ D7A670D62DADBC770060E327 /* EnvironmentValues+RequestReviewModel.swift */, 00181B452846AD7100654571 /* View+ErrorAlert.swift */, D7AECFDC2E31448100FD312A /* View+OnTeardown.swift */, + 4CFC9E122E577FBB00F730C5 /* StringProtocol.swift */, D7142BC32DB71082004F87B7 /* View+PagePresentation.swift */, ); path = Extensions; @@ -1676,6 +1700,7 @@ 88E52E6D2DC969CF00F48409 /* Apply hillshade renderer to raster */, 955AFAC52C10FD74009C8FE5 /* Apply mosaic rule to rasters */, D771D0C52CD55211004C13CB /* Apply raster rendering rule */, + 95321B312E554B76002E9DE2 /* Apply renderers to scene layer */, 00FA4E612DCA72DA008A34CF /* Apply RGB renderer */, 88C5E0E92DCBC1B20091D271 /* Apply scene property expressions */, 004A2BA12BED456500C297CE /* Apply scheduled updates to preplanned map area */, @@ -1784,6 +1809,7 @@ D7232EDD2AC1E5410079ABFF /* Play KML tour */, E0EA0B75286638FD00C9621D /* Project geometry */, 1C012CF22DE7C3030027F570 /* Project with chosen transformation */, + D71C01582E3C299D00E87E3D /* Query dynamic entities */, 3E9F77712C6A609B0022CAB5 /* Query feature count and extent */, 108EC03F29D25AE1000F35D0 /* Query feature table */, D73F06652B5EE73D000B574F /* Query features with Arcade expression */, @@ -1862,6 +1888,7 @@ D74C8BFA2ABA5572007C76B8 /* Style symbols from mobile style file */, 002056C22DD7F6C70016B8A9 /* Take screenshot */, 7573E81229D6134C00BEED9C /* Trace utility network */, + 9563324D2E3950960091C877 /* Update related features */, D74EA7802B6DADA5008F6C7C /* Validate utility network topology */, ); path = Samples; @@ -1992,6 +2019,7 @@ 1C38921E2DC1749700ADFDDC /* b051f5c3e01048f3bf11c59b41507896 */, D7F8C03F2B605E720072BFA7 /* b5106355f1634b8996e634c04b6a930a */, D70539042CD0122D00F63F4A /* c78b149a1d52414682c86a5feeb13d30 */, + D71C015F2E3C2A6200E87E3D /* c78e297e99ad4572a48cdcd0b54bed30 */, 00D4EF8128638BF100B9CC30 /* cb1b20748a9f4d128dad8a87244e3e37 */, 4C81274E2DCADC82006EF7D2 /* cc68728b5904403ba637e1f1cd2995ae */, 4D126D7629CA3B3F00CFB7A7 /* d5bad9f4fee9483791e405880fb466da */, @@ -2819,6 +2847,14 @@ path = 3af5cfec0fd24dac8d88aea679027cb9; sourceTree = ""; }; + 95321B312E554B76002E9DE2 /* Apply renderers to scene layer */ = { + isa = PBXGroup; + children = ( + 95321B2F2E554B20002E9DE2 /* ApplyRenderersToSceneLayerView.swift */, + ); + path = "Apply renderers to scene layer"; + sourceTree = ""; + }; 9537AFC82C220ECB000923C5 /* 9d2987a825c646468b3ce7512fb76e2d */ = { isa = PBXGroup; children = ( @@ -2859,6 +2895,14 @@ path = "Simplify geometry"; sourceTree = ""; }; + 9563324D2E3950960091C877 /* Update related features */ = { + isa = PBXGroup; + children = ( + 9563324B2E39507E0091C877 /* UpdateRelatedFeaturesView.swift */, + ); + path = "Update related features"; + sourceTree = ""; + }; 9579FCEB2C3360CA00FC8A1D /* Edit feature attachments */ = { isa = PBXGroup; children = ( @@ -3081,6 +3125,23 @@ path = "Snap geometry edits with utility network rules"; sourceTree = ""; }; + D71C01582E3C299D00E87E3D /* Query dynamic entities */ = { + isa = PBXGroup; + children = ( + D71C01542E3C299D00E87E3D /* QueryDynamicEntitiesView.swift */, + D71C01552E3C299D00E87E3D /* QueryDynamicEntitiesView.Model.swift */, + ); + path = "Query dynamic entities"; + sourceTree = ""; + }; + D71C015F2E3C2A6200E87E3D /* c78e297e99ad4572a48cdcd0b54bed30 */ = { + isa = PBXGroup; + children = ( + D71C015E2E3C2A6200E87E3D /* phx_air_traffic.json */, + ); + path = c78e297e99ad4572a48cdcd0b54bed30; + sourceTree = ""; + }; D71C5F602AAA7854006599FD /* Create symbol styles from web styles */ = { isa = PBXGroup; children = ( @@ -4285,6 +4346,7 @@ ListContentsOfKmlFile, NavigateRouteWithRerouting, OrbitCameraAroundObject, + QueryDynamicEntities, SearchSymbolStyleDictionary, ShowDeviceLocationWithNmeaDataSources, ShowLineOfSightBetweenGeoelements, @@ -4335,6 +4397,7 @@ 1C8438F42DDBD02C005CCBC7 /* GasDeviceAnno.mmpk in Resources */, 1CC755E12DC5A6A7004B346F /* Shasta_Elevation in Resources */, D7F8C0412B605E720072BFA7 /* FillmoreTopographicMap.vtpk in Resources */, + D71C01602E3C2A6200E87E3D /* phx_air_traffic.json in Resources */, 1C293D4D2DCD91BF000B0822 /* color.json in Resources */, 9537AFD72C220EF0000923C5 /* ExchangeSetwithoutUpdates in Resources */, D7F8C03E2B605AF60072BFA7 /* ContingentValuesBirdNests.geodatabase in Resources */, @@ -4597,6 +4660,7 @@ D7E7D0812AEB39D5003AAD02 /* FindRouteInTransportNetworkView.swift in Sources */, D742E4922B04132B00690098 /* DisplayWebSceneFromPortalItemView.swift in Sources */, 1C7B85E72DF37F7700B267EA /* SetFeatureLayerRenderingModeOnMapView.swift in Sources */, + 9563324C2E3950880091C877 /* UpdateRelatedFeaturesView.swift in Sources */, 0042E24328E4BF8F001F33D6 /* ShowViewshedFromPointInSceneView.Model.swift in Sources */, 95F3A52B2C07F09C00885DED /* SetSurfaceNavigationConstraintView.swift in Sources */, 00FA4E572DBC139C008A34CF /* AddRastersAndFeatureTablesFromGeopackageView.swift in Sources */, @@ -4671,6 +4735,7 @@ E088E1572862579D00413100 /* SetSurfacePlacementModeView.swift in Sources */, 9579FCEA2C3360BB00FC8A1D /* EditFeatureAttachmentsView.swift in Sources */, D762AF5F2BF6A7B900ECE3C7 /* EditFeaturesWithFeatureLinkedAnnotationView.swift in Sources */, + D7D847752DB80F4F00390B1D /* DownloadOfflineResourcesView.swift in Sources */, 1CAF831F2A20305F000E1E60 /* ShowUtilityAssociationsView.swift in Sources */, 00E7C15C2BBE1BF000B85D69 /* SnapGeometryEditsView.swift in Sources */, E004A6C128414332002A1FE6 /* SetViewpointRotationView.swift in Sources */, @@ -4733,6 +4798,7 @@ D71371792BD88ECC00EB2F86 /* MonitorChangesToLayerViewStateView.swift in Sources */, D7B759B32B1FFBE300017FDD /* FavoritesView.swift in Sources */, D722BD222A420DAD002C2087 /* ShowExtrudedFeaturesView.swift in Sources */, + 4CFC9E132E577FBE00F730C5 /* StringProtocol.swift in Sources */, E004A6F6284FA42A002A1FE6 /* SelectFeaturesInFeatureLayerView.swift in Sources */, 88FB70392DCC207B00EB76E3 /* ApplySymbologyToShapefileView.swift in Sources */, 1C3892312DC59E6000ADFDDC /* ApplyBlendRendererToHillshadeView.SettingsView.swift in Sources */, @@ -4772,6 +4838,8 @@ 1C7B861C2DFB4EAF00B267EA /* ShowExtrudedGraphicsView.swift in Sources */, 1C9B74D929DB54560038B06F /* ChangeCameraControllerView.swift in Sources */, D7BEBAD22CBDFE1C00F882E7 /* DisplayAlternateSymbolsAtDifferentScalesView.swift in Sources */, + D71C01592E3C299D00E87E3D /* QueryDynamicEntitiesView.swift in Sources */, + D71C015A2E3C299D00E87E3D /* QueryDynamicEntitiesView.Model.swift in Sources */, D76000AE2AF19C2300B3084D /* FindRouteInMobileMapPackageView.swift in Sources */, 00273CF42A82AB5900A7A77D /* SamplesSearchView.swift in Sources */, D78666AD2A2161F100C60110 /* FindNearestVertexView.swift in Sources */, @@ -4788,6 +4856,7 @@ 9520B2B52E135AD800B3BEF9 /* ShowLineOfSightBetweenGeoelementsView.swift in Sources */, 00CB9138284814A4005C2C5D /* SearchWithGeocodeView.swift in Sources */, 1C43BC7F2A43781200509BF8 /* SetVisibilityOfSubtypeSublayerView.Views.swift in Sources */, + 95321B302E554B31002E9DE2 /* ApplyRenderersToSceneLayerView.swift in Sources */, D731F3C12AD0D2AC00A8431E /* IdentifyGraphicsView.swift in Sources */, 4C8126DD2DBBCF0B006EF7D2 /* ApplyStyleToWMSLayerView.swift in Sources */, 00E5401C27F3CCA200CF66D5 /* SamplesApp.swift in Sources */, diff --git a/Shared/Samples/Add custom dynamic entity data source/AddCustomDynamicEntityDataSourceView.Vessel.swift b/Shared/Samples/Add custom dynamic entity data source/AddCustomDynamicEntityDataSourceView.Vessel.swift index f9af62064..363f11299 100644 --- a/Shared/Samples/Add custom dynamic entity data source/AddCustomDynamicEntityDataSourceView.Vessel.swift +++ b/Shared/Samples/Add custom dynamic entity data source/AddCustomDynamicEntityDataSourceView.Vessel.swift @@ -12,19 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +import ArcGIS + extension AddCustomDynamicEntityDataSourceView { /// A marine vessel that can be decoded from the vessel JSON. struct Vessel { - /// A geometry that gives the location of the vessel. - struct Geometry: Decodable { // swiftlint:disable:this nesting - /// The x coordinate of the geometry. - let x: Double - /// The y coordinate of the geometry. - let y: Double - } - /// The location of the vessel. - let geometry: Geometry + let geometry: Point /// The attributes of the vessel. let attributes: [String: any Sendable] } @@ -56,7 +50,7 @@ extension AddCustomDynamicEntityDataSourceView.Vessel: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) - let geometry = try container.decode(Geometry.self, forKey: .geometry) + let geometry = try container.decode(Point.self, forKey: .geometry) let attributes: [String: any Sendable] = try { let attributes = try container.decode(Attributes.self, forKey: .attributes) return [ diff --git a/Shared/Samples/Add custom dynamic entity data source/AddCustomDynamicEntityDataSourceView.swift b/Shared/Samples/Add custom dynamic entity data source/AddCustomDynamicEntityDataSourceView.swift index 647028dad..58844e151 100644 --- a/Shared/Samples/Add custom dynamic entity data source/AddCustomDynamicEntityDataSourceView.swift +++ b/Shared/Samples/Add custom dynamic entity data source/AddCustomDynamicEntityDataSourceView.swift @@ -127,13 +127,10 @@ private struct VesselFeed: CustomDynamicEntityFeed { from: line.data(using: .utf8)! ) - // The location of the vessel that was decoded from the JSON. - let location = vessel.geometry - // We successfully decoded the vessel JSON so we should // add that vessel as a new observation. return CustomDynamicEntityFeedEvent.newObservation( - geometry: Point(x: location.x, y: location.y, spatialReference: .wgs84), + geometry: vessel.geometry, attributes: vessel.attributes ) } diff --git a/Shared/Samples/Add vector tiled layer from custom style/AddVectorTiledLayerFromCustomStyleView.swift b/Shared/Samples/Add vector tiled layer from custom style/AddVectorTiledLayerFromCustomStyleView.swift index 401e80c9e..86e6ef3b1 100644 --- a/Shared/Samples/Add vector tiled layer from custom style/AddVectorTiledLayerFromCustomStyleView.swift +++ b/Shared/Samples/Add vector tiled layer from custom style/AddVectorTiledLayerFromCustomStyleView.swift @@ -183,7 +183,6 @@ private extension FileManager { /// Creates a temporary directory. /// - Returns: The URL of the created directory static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.swift b/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.swift index b791be7d2..cc4c2ac29 100644 --- a/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.swift +++ b/Shared/Samples/Animate 3D graphic/Animate3DGraphicView.swift @@ -147,9 +147,9 @@ struct Animate3DGraphicView: View { } Section { - Toggle("Auto-Heading Enabled", isOn: $model.cameraController.autoHeadingIsEnabled) - Toggle("Auto-Pitch Enabled", isOn: $model.cameraController.autoPitchIsEnabled) - Toggle("Auto-Roll Enabled", isOn: $model.cameraController.autoRollIsEnabled) + Toggle("Auto-Heading", isOn: $model.cameraController.autoHeadingIsEnabled) + Toggle("Auto-Pitch", isOn: $model.cameraController.autoPitchIsEnabled) + Toggle("Auto-Roll", isOn: $model.cameraController.autoRollIsEnabled) } } } diff --git a/Shared/Samples/Apply function to raster from service/ApplyFunctionToRasterFromServiceView.swift b/Shared/Samples/Apply function to raster from service/ApplyFunctionToRasterFromServiceView.swift index c1d3d6b1c..07e7c7abb 100644 --- a/Shared/Samples/Apply function to raster from service/ApplyFunctionToRasterFromServiceView.swift +++ b/Shared/Samples/Apply function to raster from service/ApplyFunctionToRasterFromServiceView.swift @@ -29,7 +29,7 @@ struct ApplyFunctionToRasterFromServiceView: View { url: URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/NLCDLandCover2001/ImageServer")! ) // Creates a raster function from a json string. - let function = try! RasterFunction.fromJSON(rasterFunctionJSON) // swiftlint:disable:this force_try + let function = try! RasterFunction.fromJSON(rasterFunctionJSON) // Sets the arguments on the function. function.arguments!.setRaster( imageServiceRaster, diff --git a/Shared/Samples/Apply renderers to scene layer/ApplyRenderersToSceneLayerView.swift b/Shared/Samples/Apply renderers to scene layer/ApplyRenderersToSceneLayerView.swift new file mode 100644 index 000000000..c3b8d6fc9 --- /dev/null +++ b/Shared/Samples/Apply renderers to scene layer/ApplyRenderersToSceneLayerView.swift @@ -0,0 +1,321 @@ +// Copyright 2025 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 ApplyRenderersToSceneLayerView: View { + /// Renderer type selected by the user. + @State private var rendererSelection: RendererType = .none + + /// Scene layer for Helsinki scene. + private var sceneLayer: ArcGISSceneLayer { + scene.operationalLayers.first as! ArcGISSceneLayer + } + + /// Scene with elevation layer and viewpoint centered on Helsinki. + @State private var scene: ArcGIS.Scene = { + let scene = Scene(basemapStyle: .arcGISLightGray) + // Creates the surface and adds it to the scene. + let surface = Surface() + surface.addElevationSource( + ArcGISTiledElevationSource( + url: .worldElevationService + ) + ) + scene.baseSurface = surface + scene.initialViewpoint = Viewpoint( + latitude: .nan, + longitude: .nan, + scale: .nan, + camera: Camera( + location: .helsinkiCenter, + heading: 308.9, + pitch: 50.7, + roll: 0.0 + ) + ) + scene.addOperationalLayer(ArcGISSceneLayer(url: .helsinkiScene)) + return scene + }() + + /// Simple renderer that adds yellow mesh to buildings. + @State private var simpleRenderer: SimpleRenderer = { + let materialFillSymbolLayer = MaterialFillSymbolLayer( + color: .yellow + ) + materialFillSymbolLayer.colorMixMode = .replace + materialFillSymbolLayer.edges = SymbolLayerEdges3D( + color: .black, + width: 0.5 + ) + let meshSymbol = MultilayerMeshSymbol( + symbolLayer: materialFillSymbolLayer + ) + return SimpleRenderer(symbol: meshSymbol) + }() + + /// Renderer that provides color depending on building usage (i.e. commercial, residential). + @State private var uniqueValueRenderer: UniqueValueRenderer = { + UniqueValueRenderer( + fieldNames: ["usage"], + uniqueValues: [ + .commercial, + .residential, + .otherUsage + ], + defaultSymbol: MultilayerMeshSymbol( + symbolLayer: MaterialFillSymbolLayer( + color: UIColor( + red: 230 / 255, + green: 230 / 255, + blue: 230 / 255, + alpha: 1.0 + ) + ) + ) + ) + }() + + /// A class breaks renderer that categorizes data based on 'yearCompleted' values. + @State private var classBreaksRenderer: ClassBreaksRenderer = { + ClassBreaksRenderer( + fieldName: "yearCompleted", + classBreaks: [ + .before1900, + .from1900to1956, + .from1957to2000, + .after2000 + ] + ) + }() + + var body: some View { + SceneView(scene: scene) + .toolbar { + ToolbarItem(placement: .bottomBar) { + Picker("Renderer selected", selection: $rendererSelection) { + ForEach(RendererType.allCases, id: \.self) { renderer in + Text(renderer.label) + } + } + .onChange(of: rendererSelection) { + // Update the renderer based on selection. + switch rendererSelection { + case .none: + sceneLayer.renderer = nil + case .simpleRenderer: + sceneLayer.renderer = simpleRenderer + case .uniqueValueRenderer: + sceneLayer.renderer = uniqueValueRenderer + case .classBreaksRenderer: + sceneLayer.renderer = classBreaksRenderer + } + } + } + } + } +} + +/// Enum to manage available renderer options in the Picker. +private enum RendererType: CaseIterable { + case none + case simpleRenderer + case uniqueValueRenderer + case classBreaksRenderer + + var label: String { + switch self { + case .none: + return "None" + case .simpleRenderer: + return "Simple Renderer" + case .uniqueValueRenderer: + return "Unique Value Renderer" + case .classBreaksRenderer: + return "Class Breaks Renderer" + } + } +} + +// Defines custom color and symbol for different ranges of 'yearCompleted'. +private extension ClassBreak { + static var before1900: ClassBreak { + ClassBreak( + description: "before 1900", + label: "before 1900", + minValue: 1725, + maxValue: 1900, + symbol: { + let symbolLayer = MaterialFillSymbolLayer( + color: UIColor( + red: 230 / 255, + green: 238 / 255, + blue: 207 / 255, + alpha: 1.0 + ) + ) + symbolLayer.colorMixMode = .tint + return MultilayerMeshSymbol(symbolLayer: symbolLayer) + }() + ) + } + + static var from1900to1956: ClassBreak { + ClassBreak( + description: "1900 - 1956", + label: "1900 - 1956", + minValue: 1900, + maxValue: 1957, + symbol: { + let symbolLayer = MaterialFillSymbolLayer( + color: UIColor( + red: 155 / 255, + green: 196 / 255, + blue: 193 / 255, + alpha: 1.0 + ) + ) + symbolLayer.colorMixMode = .tint + return MultilayerMeshSymbol(symbolLayer: symbolLayer) + }() + ) + } + + static var from1957to2000: ClassBreak { + ClassBreak( + description: "1957 - 2000", + label: "1957 - 2000", + minValue: 1957, + maxValue: 2000, + symbol: { + let symbolLayer = MaterialFillSymbolLayer( + color: UIColor( + red: 105 / 255, + green: 168 / 255, + blue: 183 / 255, + alpha: 1.0 + ) + ) + symbolLayer.colorMixMode = .tint + return MultilayerMeshSymbol(symbolLayer: symbolLayer) + }() + ) + } + + static var after2000: ClassBreak { + ClassBreak( + description: "after 2000", + label: "after 2000", + minValue: 2000, + maxValue: 3000, + symbol: { + let symbolLayer = MaterialFillSymbolLayer( + color: UIColor( + red: 75 / 255, + green: 126 / 255, + blue: 152 / 255, + alpha: 1.0 + ) + ) + symbolLayer.colorMixMode = .tint + return MultilayerMeshSymbol(symbolLayer: symbolLayer) + }() + ) + } +} + +// Defines unique value symbols for building usage types. +private extension UniqueValue { + static var commercial: UniqueValue { + UniqueValue( + description: "commercial buildings", + label: "commercial buildings", + symbol: MultilayerMeshSymbol( + symbolLayer: MaterialFillSymbolLayer( + color: UIColor( + red: 245 / 255, + green: 213 / 255, + blue: 169 / 255, + alpha: 200.0 / 255 + ) + ) + ), + values: ["general or commercial"] + ) + } + + static var residential: UniqueValue { + UniqueValue( + description: "residential buildings", + label: "residential buildings", + symbol: MultilayerMeshSymbol( + symbolLayer: MaterialFillSymbolLayer( + color: UIColor( + red: 210 / 255, + green: 254 / 255, + blue: 208 / 255, + alpha: 1.0 + ) + ) + ), + values: ["residential"] + ) + } + + static var otherUsage: UniqueValue { + UniqueValue( + description: "other", + label: "other", + symbol: MultilayerMeshSymbol( + symbolLayer: MaterialFillSymbolLayer( + color: UIColor( + red: 253 / 255, + green: 198 / 255, + blue: 227 / 255, + alpha: 150.0 / 255 + ) + ) + ), + values: ["other"] + ) + } +} + +private extension Point { + /// Predefined geometry point for Helsinki city center. + static var helsinkiCenter: Point { + Point( + x: 2778453.8008, + y: 8436451.3882, + z: 387.4524, + spatialReference: .webMercator + ) + } +} + +// Scene and elevation data sources. +private extension URL { + static var helsinkiScene: URL { + URL(string: "https://www.arcgis.com/home/item.html?id=fdfa7e3168e74bf5b846fc701180930b")! + } + + static var worldElevationService: URL { + URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")! + } +} + +#Preview { + ApplyRenderersToSceneLayerView() +} diff --git a/Shared/Samples/Apply renderers to scene layer/README.md b/Shared/Samples/Apply renderers to scene layer/README.md new file mode 100644 index 000000000..24c3b53ff --- /dev/null +++ b/Shared/Samples/Apply renderers to scene layer/README.md @@ -0,0 +1,43 @@ +# Apply renderers to scene layer + +Change the appearance of a 3D object scene layer with different renderers. + +![Screenshot of apply renderers to scene layer sample](apply-renderers-to-scene-layer.png) + +## Use case + +A scene layer of 3D buildings hosted on ArcGIS Online comes with a preset renderer that defines how the buildings are displayed in the application. However, the fill color may sometimes blend into the basemap, making the buildings difficult to distinguish. To enhance visualization, you can apply a custom renderer with a more contrasting fill color, helping the 3D buildings stand out more clearly. Additionally, you can use a unique value renderer to represent different building uses, or a class breaks renderer to visualize building ages - valuable insights for urban planning and analysis. + +## How to use the sample + +Wait for the scene layer to load. The original scene layer displays 3D textured buildings. Tap on the renderer picker and choose a different renderer to change how the buildings are visualized. Each renderer applies different symbology to the scene layer. Setting the renderer to `nil` will remove any applied symbology, reverting the buildings to their original textured appearance. + +## How it works + +1. Create an `ArcGISSceneLayer` from a service URL. +2. Add the scene layer to an `Scene` and display it in a `SceneView`. +3. Create different renderers: + * A `SimpleRenderer` with a `MultilayerMeshSymbol` and a fill color and edges. + * A `UniqueValueRenderer` using a string field and different `MultilayerMeshSymbol` for each unique value of the building usage. + * A `ClassBreaksRenderer` using a numeric field and different `MultilayerMeshSymbol` for each value range of the year the building was completed. +4. Set the scene layer's `renderer` property to the selected renderer. +5. Set the scene layer's `renderer` property to `nil`, resulting in displaying the original texture of the buildings. + +## Relevant API + +* ArcGISSceneLayer +* ClassBreaksRenderer +* MaterialFillSymbolLayer +* MultilayerMeshSymbol +* SceneView +* SimpleRenderer +* SymbolLayerEdges3D +* UniqueValueRenderer + +## About the data + +This sample displays a [Helsinki 3D buildings scene](https://www.arcgis.com/home/item.html?id=fdfa7e3168e74bf5b846fc701180930b) hosted on ArcGIS Online, showing 3D textured buildings in Helsinki, Finland. + +## Tags + +3D, buildings, renderer, scene layer, symbology, visualization diff --git a/Shared/Samples/Apply renderers to scene layer/README.metadata.json b/Shared/Samples/Apply renderers to scene layer/README.metadata.json new file mode 100644 index 000000000..4c4fd041b --- /dev/null +++ b/Shared/Samples/Apply renderers to scene layer/README.metadata.json @@ -0,0 +1,39 @@ +{ + "category": "Visualization", + "description": "Change the appearance of a 3D object scene layer with different renderers.", + "ignore": false, + "images": [ + "apply-renderers-to-scene-layer.png" + ], + "keywords": [ + "3D", + "buildings", + "renderer", + "scene layer", + "symbology", + "visualization", + "ArcGISSceneLayer", + "ClassBreaksRenderer", + "MaterialFillSymbolLayer", + "MultilayerMeshSymbol", + "SceneView", + "SimpleRenderer", + "SymbolLayerEdges3D", + "UniqueValueRenderer" + ], + "redirect_from": [], + "relevant_apis": [ + "ArcGISSceneLayer", + "ClassBreaksRenderer", + "MaterialFillSymbolLayer", + "MultilayerMeshSymbol", + "SceneView", + "SimpleRenderer", + "SymbolLayerEdges3D", + "UniqueValueRenderer" + ], + "snippets": [ + "ApplyRenderersToSceneLayerView.swift" + ], + "title": "Apply renderers to scene layer" +} diff --git a/Shared/Samples/Apply renderers to scene layer/apply-renderers-to-scene-layer.png b/Shared/Samples/Apply renderers to scene layer/apply-renderers-to-scene-layer.png new file mode 100644 index 000000000..8beb38df5 Binary files /dev/null and b/Shared/Samples/Apply renderers to scene layer/apply-renderers-to-scene-layer.png differ diff --git a/Shared/Samples/Apply scheduled updates to preplanned map area/ApplyScheduledUpdatesToPreplannedMapAreaView.swift b/Shared/Samples/Apply scheduled updates to preplanned map area/ApplyScheduledUpdatesToPreplannedMapAreaView.swift index 0b454a66b..4e3249c54 100644 --- a/Shared/Samples/Apply scheduled updates to preplanned map area/ApplyScheduledUpdatesToPreplannedMapAreaView.swift +++ b/Shared/Samples/Apply scheduled updates to preplanned map area/ApplyScheduledUpdatesToPreplannedMapAreaView.swift @@ -188,7 +188,6 @@ private extension FileManager { /// Creates a temporary directory. /// - Returns: The URL of the created directory. static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Configure electronic navigational charts/ConfigureElectronicNavigationalChartsView.swift b/Shared/Samples/Configure electronic navigational charts/ConfigureElectronicNavigationalChartsView.swift index 64c4b4349..7016a101d 100644 --- a/Shared/Samples/Configure electronic navigational charts/ConfigureElectronicNavigationalChartsView.swift +++ b/Shared/Samples/Configure electronic navigational charts/ConfigureElectronicNavigationalChartsView.swift @@ -256,7 +256,6 @@ private extension FileManager { /// Creates a temporary directory. /// - Returns: The URL of the created directory. static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Create KML multi-track/CreateKMLMultiTrackView.Model.swift b/Shared/Samples/Create KML multi-track/CreateKMLMultiTrackView.Model.swift index 1bc63f58c..d2305b1c3 100644 --- a/Shared/Samples/Create KML multi-track/CreateKMLMultiTrackView.Model.swift +++ b/Shared/Samples/Create KML multi-track/CreateKMLMultiTrackView.Model.swift @@ -141,7 +141,6 @@ private extension FileManager { /// Creates a temporary directory. /// - Returns: The URL of the created directory. static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Create and edit geometries/CreateAndEditGeometriesView.swift b/Shared/Samples/Create and edit geometries/CreateAndEditGeometriesView.swift index ad348657c..09c693939 100644 --- a/Shared/Samples/Create and edit geometries/CreateAndEditGeometriesView.swift +++ b/Shared/Samples/Create and edit geometries/CreateAndEditGeometriesView.swift @@ -480,7 +480,6 @@ private class GeometryEditorModel: ObservableObject { } private extension Geometry { - // swiftlint:disable force_try static func house() -> Point { let json = Data( """ @@ -517,7 +516,7 @@ private extension Geometry { [-1067889.49,6998483.56],[-1067880.98,6998527.26]]], "spatialReference":{"latestWkid":3857,"wkid":102100}} """.utf8 - ) + ) return try! Polyline.fromJSON(json) } @@ -555,7 +554,6 @@ private extension Geometry { ) return try! Polygon.fromJSON(json) } - // swiftlint:enable force_try } private extension Symbol { diff --git a/Shared/Samples/Create and edit geometries/README.metadata.json b/Shared/Samples/Create and edit geometries/README.metadata.json index 04ec9f1b7..283e28714 100644 --- a/Shared/Samples/Create and edit geometries/README.metadata.json +++ b/Shared/Samples/Create and edit geometries/README.metadata.json @@ -1,5 +1,5 @@ { - "category": "Maps", + "category": "Edit and Manage Data", "description": "Use the Geometry Editor to create new point, multipoint, polyline, or polygon geometries or to edit existing geometries by interacting with a map view.", "ignore": false, "images": [ diff --git a/Shared/Samples/Create and save KML file/CreateAndSaveKMLView.swift b/Shared/Samples/Create and save KML file/CreateAndSaveKMLView.swift index b66356e1d..525075515 100644 --- a/Shared/Samples/Create and save KML file/CreateAndSaveKMLView.swift +++ b/Shared/Samples/Create and save KML file/CreateAndSaveKMLView.swift @@ -121,7 +121,6 @@ extension CreateAndSaveKMLView { private extension FileManager { /// Creates a temporary directory and returns the URL of the created directory. static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.Model.swift b/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.Model.swift index 71dcefa99..8e1da2b97 100644 --- a/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.Model.swift +++ b/Shared/Samples/Download preplanned map area/DownloadPreplannedMapAreaView.Model.swift @@ -310,7 +310,6 @@ private extension DownloadPreplannedMapAreaView.OfflineMapModel { private extension FileManager { /// Creates a temporary directory and returns the URL of the created directory. static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, 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 41da25b08..2618067f2 100644 --- a/Shared/Samples/Download vector tiles to local cache/DownloadVectorTilesToLocalCacheView.swift +++ b/Shared/Samples/Download vector tiles to local cache/DownloadVectorTilesToLocalCacheView.swift @@ -294,7 +294,6 @@ private extension DownloadVectorTilesToLocalCacheView { /// Creates a temporary directory. /// - Returns: The URL to the temporary directory. private static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Edit and sync features with feature service/EditAndSyncFeaturesWithFeatureServiceView.Model.swift b/Shared/Samples/Edit and sync features with feature service/EditAndSyncFeaturesWithFeatureServiceView.Model.swift index a54710b9a..b8f4eeff4 100644 --- a/Shared/Samples/Edit and sync features with feature service/EditAndSyncFeaturesWithFeatureServiceView.Model.swift +++ b/Shared/Samples/Edit and sync features with feature service/EditAndSyncFeaturesWithFeatureServiceView.Model.swift @@ -189,7 +189,6 @@ private extension FileManager { /// Creates a temporary directory. /// - Returns: The URL of the created directory. static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Edit features with feature-linked annotation/EditFeaturesWithFeatureLinkedAnnotationView.Model.swift b/Shared/Samples/Edit features with feature-linked annotation/EditFeaturesWithFeatureLinkedAnnotationView.Model.swift index 8f774cc78..2af71f66b 100644 --- a/Shared/Samples/Edit features with feature-linked annotation/EditFeaturesWithFeatureLinkedAnnotationView.Model.swift +++ b/Shared/Samples/Edit features with feature-linked annotation/EditFeaturesWithFeatureLinkedAnnotationView.Model.swift @@ -154,7 +154,6 @@ private extension FileManager { /// Creates a temporary directory. /// - Returns: The URL of the created directory. static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Edit geodatabase with transactions/EditGeodatabaseWithTransactionsView.Model.swift b/Shared/Samples/Edit geodatabase with transactions/EditGeodatabaseWithTransactionsView.Model.swift index 347fe0cf4..d2d440fce 100644 --- a/Shared/Samples/Edit geodatabase with transactions/EditGeodatabaseWithTransactionsView.Model.swift +++ b/Shared/Samples/Edit geodatabase with transactions/EditGeodatabaseWithTransactionsView.Model.swift @@ -90,7 +90,6 @@ private extension FileManager { /// Creates a temporary directory. /// - Returns: The URL of the created directory. static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Edit geometries with programmatic reticle tool/EditGeometriesWithProgrammaticReticleToolView.swift b/Shared/Samples/Edit geometries with programmatic reticle tool/EditGeometriesWithProgrammaticReticleToolView.swift index cea69cf90..7688bf7e5 100644 --- a/Shared/Samples/Edit geometries with programmatic reticle tool/EditGeometriesWithProgrammaticReticleToolView.swift +++ b/Shared/Samples/Edit geometries with programmatic reticle tool/EditGeometriesWithProgrammaticReticleToolView.swift @@ -472,7 +472,6 @@ private extension Geometry { } } - // swiftlint:disable force_try static var pinkneysGreen: Geometry { let json = Data( """ @@ -511,7 +510,6 @@ private extension Geometry { ) return try! Multipoint.fromJSON(json) } - // swiftlint:enable force_try } private extension Symbol { diff --git a/Shared/Samples/Generate geodatabase replica from feature service/GenerateGeodatabaseReplicaFromFeatureServiceView.Model.swift b/Shared/Samples/Generate geodatabase replica from feature service/GenerateGeodatabaseReplicaFromFeatureServiceView.Model.swift index 234b1f20b..75fef5c86 100644 --- a/Shared/Samples/Generate geodatabase replica from feature service/GenerateGeodatabaseReplicaFromFeatureServiceView.Model.swift +++ b/Shared/Samples/Generate geodatabase replica from feature service/GenerateGeodatabaseReplicaFromFeatureServiceView.Model.swift @@ -113,7 +113,6 @@ private extension FileManager { /// Creates a temporary directory. /// - Returns: The URL of the created directory. static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Generate offline map with custom parameters/GenerateOfflineMapWithCustomParametersView.Model.swift b/Shared/Samples/Generate offline map with custom parameters/GenerateOfflineMapWithCustomParametersView.Model.swift index 07eb8bf37..945237f7c 100644 --- a/Shared/Samples/Generate offline map with custom parameters/GenerateOfflineMapWithCustomParametersView.Model.swift +++ b/Shared/Samples/Generate offline map with custom parameters/GenerateOfflineMapWithCustomParametersView.Model.swift @@ -147,7 +147,6 @@ extension GenerateOfflineMapWithCustomParametersView { /// Creates a temporary directory. private static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Generate offline map with local basemap/GenerateOfflineMapWithLocalBasemapView.swift b/Shared/Samples/Generate offline map with local basemap/GenerateOfflineMapWithLocalBasemapView.swift index fce40adf7..1635785ef 100644 --- a/Shared/Samples/Generate offline map with local basemap/GenerateOfflineMapWithLocalBasemapView.swift +++ b/Shared/Samples/Generate offline map with local basemap/GenerateOfflineMapWithLocalBasemapView.swift @@ -260,7 +260,6 @@ private extension GenerateOfflineMapWithLocalBasemapView { /// Creates a temporary directory. private static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Generate offline map/GenerateOfflineMapView.swift b/Shared/Samples/Generate offline map/GenerateOfflineMapView.swift index a21939832..bcc9f3b8a 100644 --- a/Shared/Samples/Generate offline map/GenerateOfflineMapView.swift +++ b/Shared/Samples/Generate offline map/GenerateOfflineMapView.swift @@ -224,7 +224,6 @@ private extension GenerateOfflineMapView { /// Creates a temporary directory. private static func createTemporaryDirectory() -> URL { - // swiftlint:disable:next force_try try! FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Group layers together/README.md b/Shared/Samples/Group layers together/README.md index bafb637b2..a1b3fd319 100644 --- a/Shared/Samples/Group layers together/README.md +++ b/Shared/Samples/Group layers together/README.md @@ -25,6 +25,8 @@ The layers in the map will be displayed in a table of contents. For project area ## Relevant API * GroupLayer +* GroupLayer.VisibilityMode +* LayerContent ## Additional information @@ -34,4 +36,4 @@ Group layers can be saved to web scenes. In web maps, group layers will be ignor ## Tags -group layer, layers +3D, group layer, layers diff --git a/Shared/Samples/Group layers together/README.metadata.json b/Shared/Samples/Group layers together/README.metadata.json index e701bb073..7ec039935 100644 --- a/Shared/Samples/Group layers together/README.metadata.json +++ b/Shared/Samples/Group layers together/README.metadata.json @@ -6,13 +6,18 @@ "group-layers-together.png" ], "keywords": [ + "3D", "group layer", "layers", - "GroupLayer" + "GroupLayer", + "GroupLayer.VisibilityMode", + "LayerContent" ], "redirect_from": [], "relevant_apis": [ - "GroupLayer" + "GroupLayer", + "GroupLayer.VisibilityMode", + "LayerContent" ], "snippets": [ "GroupLayersTogetherView.swift", diff --git a/Shared/Samples/Manage features/ManageFeaturesView.swift b/Shared/Samples/Manage features/ManageFeaturesView.swift index 22646e38c..2aacd0816 100644 --- a/Shared/Samples/Manage features/ManageFeaturesView.swift +++ b/Shared/Samples/Manage features/ManageFeaturesView.swift @@ -124,7 +124,7 @@ struct ManageFeaturesView: View { func featureCalloutContent(feature: Feature, table: ServiceFeatureTable) -> some View { HStack { VStack(alignment: .leading) { - Text("ID: \(feature.attributeValue(forKey: table.objectIDField) ?? "Unknown")") + Text("ID: \(feature.attributeValue(forKey: table.objectIDField) as? String ?? "Unknown")") Text("Damage: \(feature.damageKind?.rawValue ?? "Unknown")") .font(.footnote) .foregroundStyle(.secondary) diff --git a/Shared/Samples/Monitor changes to layer view state/MonitorChangesToLayerViewStateView.swift b/Shared/Samples/Monitor changes to layer view state/MonitorChangesToLayerViewStateView.swift index 5cba9be47..1fb196ec1 100644 --- a/Shared/Samples/Monitor changes to layer view state/MonitorChangesToLayerViewStateView.swift +++ b/Shared/Samples/Monitor changes to layer view state/MonitorChangesToLayerViewStateView.swift @@ -65,7 +65,7 @@ struct MonitorChangesToLayerViewStateView: View { .toolbar { ToolbarItem(placement: .bottomBar) { Toggle( - layerIsVisible ? "Layer Enabled" : "Layer Disabled", + layerIsVisible ? "Layer Visible" : "Layer Hidden", isOn: $layerIsVisible ) .onChange(of: layerIsVisible) { diff --git a/Shared/Samples/Query dynamic entities/QueryDynamicEntitiesView.Model.swift b/Shared/Samples/Query dynamic entities/QueryDynamicEntitiesView.Model.swift new file mode 100644 index 000000000..55b50fb2e --- /dev/null +++ b/Shared/Samples/Query dynamic entities/QueryDynamicEntitiesView.Model.swift @@ -0,0 +1,234 @@ +// Copyright 2025 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 QueryDynamicEntitiesView { + /// The view model for this sample. + @MainActor + @Observable + final class Model { + /// A map with a topographic basemap. + let map: Map = { + let map = Map(basemapStyle: .arcGISTopographic) + map.initialViewpoint = Viewpoint(center: .phoenixAirport, scale: 1_266_500) + return map + }() + + /// The graphics overlay for displaying the phoenix airport buffer graphic. + let graphicsOverlay = GraphicsOverlay() + + /// The data source containing the dynamic dynamic entities to query. + private let dataSource: CustomDynamicEntityDataSource = { + // Creates the metadata for the data source. + let fields = PlaneAttributeKey.allCases.map { attribute in + Field(type: attribute.fieldType, name: attribute.rawValue, alias: "") + } + let info = DynamicEntityDataSourceInfo(entityIDFieldName: "flight_number", fields: fields) + info.spatialReference = .wgs84 + + // Creates a custom data source using the data feed. + return CustomDynamicEntityDataSource(info: info, makeFeed: PlaneFeed.init) + }() + + /// The layer displaying the dynamic entities on the map. + private let dynamicEntityLayer: DynamicEntityLayer + + /// A geometry representing a 15 mile buffer around the Phoenix airport. + private let phoenixAirportBuffer = GeometryEngine.geodeticBuffer( + around: .phoenixAirport, + distance: 15, + distanceUnit: .miles, + maxDeviation: .nan, + curveType: .geodesic + ) + + init() { + dynamicEntityLayer = DynamicEntityLayer(dataSource: dataSource) + setUpDynamicEntityLayer() + + setUpGraphicsOverlay() + } + + /// Queries the dynamic entities on the data source. + /// - Parameter type: The type of query to perform. + /// - Returns: The result of the query operation. + func queryDynamicEntities(type: QueryType) async -> Result<[DynamicEntity], any Error> { + let parameters = DynamicEntityQueryParameters() + + switch type { + case .geometry: + // Sets the parameters' geometry and spatial relationship to query within the buffer. + parameters.geometry = phoenixAirportBuffer + parameters.spatialRelationship = .intersects + + graphicsOverlay.isVisible = true + case .attributes: + // Sets the parameters' where clause to query the entities' attributes. + parameters.whereClause = "status = 'In flight' AND arrival_airport = 'PHX'" + case .trackID(let id): + // Adds a track ID to query for to the parameters. + parameters.addTrackID(id) + } + + return await Result { + // Performs a dynamic entities query on the data source. + let queryResult = try await dataSource.queryDynamicEntities(using: parameters) + + // Gets the entities from the query result and selects them on the layer. + let entities = Array(queryResult.entities()) + dynamicEntityLayer.selectDynamicEntities(entities) + + return entities + } + } + + /// Clears selected dynamic entities and hides the graphics overlay. + func resetDisplay() { + dynamicEntityLayer.clearSelection() + graphicsOverlay.isVisible = false + } + + /// Sets up the dynamic entity layer's properties and adds it to the map. + private func setUpDynamicEntityLayer() { + // Sets display tracking properties on the layer. + let trackDisplayProperties = dynamicEntityLayer.trackDisplayProperties + trackDisplayProperties.showsPreviousObservations = true + trackDisplayProperties.showsTrackLine = true + trackDisplayProperties.maximumObservations = 20 + + // Creates a label definition to display the entities' flight numbers. + let labelDefinition = LabelDefinition( + labelExpression: SimpleLabelExpression(simpleExpression: "[flight_number]"), + textSymbol: .init(color: .red, size: 12) + ) + labelDefinition.placement = .pointAboveCenter + + dynamicEntityLayer.addLabelDefinition(labelDefinition) + dynamicEntityLayer.labelsAreEnabled = true + + map.addOperationalLayer(dynamicEntityLayer) + } + + /// Creates a phoenix airport buffer graphic and adds it to the overlay. + private func setUpGraphicsOverlay() { + let blackLineSymbol = SimpleLineSymbol(style: .solid, color: .black, width: 1) + let redFillSymbol = SimpleFillSymbol( + color: .red.withAlphaComponent(0.1), + outline: blackLineSymbol + ) + + let bufferGraphic = Graphic(geometry: phoenixAirportBuffer, symbol: redFillSymbol) + graphicsOverlay.addGraphic(bufferGraphic) + + // Hides the graphics overlay initially. + graphicsOverlay.isVisible = false + } + } + + /// A plane that can be decoded from JSON. + fileprivate struct Plane { + /// The location of the plane. + let point: Point + /// The attributes of the plane. + let attributes: [String: any Sendable] + } + + /// A custom dynamic entity feed that emits events representing planes. + private struct PlaneFeed: CustomDynamicEntityFeed { + /// The feed's stream of events. + let events = URL.phoenixAirTrafficJSON.lines.map { line in + // Delays the next observation to simulate live data. + try await Task.sleep(for: .seconds(0.1)) + + // Decodes the plane from the line and uses it to create a new observation. + let plane = try JSONDecoder().decode(Plane.self, from: .init(line.utf8)) + return CustomDynamicEntityFeedEvent.newObservation( + geometry: plane.point, + attributes: plane.attributes + ) + } + } + + /// A type of dynamic entities query. + enum QueryType: Hashable, Identifiable { + case geometry, attributes, trackID(String) + + var id: Self { self } + } + + /// The keys for decoding `Plane.attributes`. + enum PlaneAttributeKey: String, CodingKey, CaseIterable { + case aircraft + case altitudeFeet = "altitude_feet" + case arrivalAirport = "arrival_airport" + case flightNumber = "flight_number" + case heading + case speed + case status + + /// The type used to decode the attribute. + fileprivate var decodeType: (Decodable & Sendable).Type { + isNumeric ? Double.self : String.self + } + + /// The type used to create a field for the attribute. + fileprivate var fieldType: FieldType { + isNumeric ? .float64 : .text + } + + /// A Boolean value indicating whether the attribute has a numeric value. + private var isNumeric: Bool { + switch self { + case .heading, .altitudeFeet, .speed: + true + default: + false + } + } + } +} + +extension QueryDynamicEntitiesView.Plane: Decodable { + private enum CodingKeys: CodingKey { + case geometry, attributes + } + + private typealias AttributeKeys = QueryDynamicEntitiesView.PlaneAttributeKey + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + point = try container.decode(Point.self, forKey: .geometry) + + let attributesDecoder = try container.superDecoder(forKey: .attributes) + let attributesContainer = try attributesDecoder.container(keyedBy: AttributeKeys.self) + attributes = try AttributeKeys.allCases.reduce(into: [:]) { attributes, key in + let attribute = try attributesContainer.decodeIfPresent(key.decodeType, forKey: key) + attributes[key.rawValue] = attribute + } + } +} + +private extension Geometry { + /// The location of the phoenix airport. + static var phoenixAirport: Point { .init(latitude: 33.4352, longitude: -112.0101) } +} + +private extension URL { + /// The URL to a JSON file containing mock air traffic data around the Phoenix Sky Harbor International Airport. + static var phoenixAirTrafficJSON: URL { + Bundle.main.url(forResource: "phx_air_traffic", withExtension: "json")! + } +} diff --git a/Shared/Samples/Query dynamic entities/QueryDynamicEntitiesView.swift b/Shared/Samples/Query dynamic entities/QueryDynamicEntitiesView.swift new file mode 100644 index 000000000..88b71ca35 --- /dev/null +++ b/Shared/Samples/Query dynamic entities/QueryDynamicEntitiesView.swift @@ -0,0 +1,194 @@ +// Copyright 2025 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 QueryDynamicEntitiesView: View { + /// The view model for the sample. + @State private var model = Model() + + /// A Boolean value indicating whether the alert for entering a flight number is showing. + @State private var flightNumberAlertIsShowing = false + + /// The text entered into the flight number alert's text field. + @State private var flightNumber = "" + + /// The type of query currently being performed. + @State private var selectedQueryType: QueryType? + + /// The result of a dynamic entities query operation. + @State private var queryResult: Result<[DynamicEntity], any Error>? + + var body: some View { + MapView(map: model.map, graphicsOverlays: [model.graphicsOverlay]) + .contentInsets(.init(top: 0, leading: 0, bottom: 250, trailing: 0)) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Spacer() + Menu("Query Flights") { + Button("Within 15 Miles of PHX", systemImage: "dot.circle") { + selectedQueryType = .geometry + } + Button("Arriving in PHX", systemImage: "airplane.arrival") { + selectedQueryType = .attributes + } + Button("With Flight Number", systemImage: "magnifyingglass") { + flightNumberAlertIsShowing = true + } + } + .menuOrder(.fixed) + .alert("Enter a Flight Number to Query", isPresented: $flightNumberAlertIsShowing) { + TextField("Flight Number", text: $flightNumber, prompt: .init("Flight_396")) + + Button("Done") { + selectedQueryType = .trackID(flightNumber) + } + .disabled(flightNumber.isEmpty) + + Button("Cancel", role: .cancel, action: {}) + } + .task(id: selectedQueryType) { + guard let selectedQueryType else { return } + queryResult = await model.queryDynamicEntities(type: selectedQueryType) + } + .popover(item: $selectedQueryType) { queryType in + QueryResultView(result: queryResult, type: queryType) + .onDisappear(perform: model.resetDisplay) + .presentationDetents([.fraction(0.5), .large]) + .frame(idealWidth: 320, idealHeight: 380) + } + Spacer() + } + } + } +} + +private extension QueryDynamicEntitiesView { + /// A view that displays the result of a dynamic entities query operation. + struct QueryResultView: View { + /// The result of the dynamic entities query operation. + let result: Result<[DynamicEntity], any Error>? + + /// The type of query that was performed. + let type: QueryType + + /// The action to dismiss the view. + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + switch result { + case .success(let entities): + if !entities.isEmpty { + List { + Section(type.resultLabel) { + ForEach(entities, id: \.id) { entity in + DynamicEntityObservationView(entity: entity) + } + } + } + } else { + ContentUnavailableView( + "No Results", + systemImage: "airplane", + description: .init("There are no flights to display for this query.") + ) + } + case .failure(let error): + ContentUnavailableView( + "Error", + systemImage: "exclamationmark.triangle", + description: .init(String(reflecting: error)) + ) + case nil: + ProgressView("Querying dynamic entities") + } + } + .navigationTitle("Query Results") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + } + + /// A view that displays the latest observation of a dynamic entity. + struct DynamicEntityObservationView: View { + /// The dynamic entity to observe. + let entity: DynamicEntity + + /// The latest observation's attributes to display. + @State private var attributes: [String: any Sendable] = [:] + + /// A Boolean value indicating whether the disclosure group is expanded. + @State private var isExpanded = false + + var body: some View { + let flightNumber = entity.attributes["flight_number"] as? String + DisclosureGroup(flightNumber ?? "N/A", isExpanded: $isExpanded) { + let attributes = attributes.sorted(by: { $0.key < $1.key }) + ForEach(attributes, id: \.key) { name, value in + let label = PlaneAttributeKey(rawValue: name)?.label ?? name + if let string = value as? String { + LabeledContent(label, value: string) + } else if let double = value as? Double { + LabeledContent( + label, + value: double, + format: .number.precision(.fractionLength(0...2)) + ) + } + } + } + .task { + attributes = entity.latestObservation?.attributes ?? [:] + + for await changedInfo in entity.changes { + attributes = changedInfo.receivedObservation?.attributes ?? [:] + } + } + } + } +} + +private extension QueryDynamicEntitiesView.PlaneAttributeKey { + /// A human-readable label for the attribute. + var label: String { + switch self { + case .aircraft: "Aircraft" + case .altitudeFeet: "Altitude (ft)" + case .arrivalAirport: "Arrival Airport" + case .flightNumber: "Flight Number" + case .heading: "Heading" + case .speed: "Speed" + case .status: "Status" + } + } +} + +private extension QueryDynamicEntitiesView.QueryType { + /// The human-readable text describing the query results. + var resultLabel: String { + switch self { + case .geometry: "Flights within 15 miles of PHX" + case .attributes: "Flights arriving in PHX" + case .trackID(let trackID): "Flights matching number: \(trackID)" + } + } +} diff --git a/Shared/Samples/Query dynamic entities/README.md b/Shared/Samples/Query dynamic entities/README.md new file mode 100644 index 000000000..683b31768 --- /dev/null +++ b/Shared/Samples/Query dynamic entities/README.md @@ -0,0 +1,48 @@ +# Query dynamic entities + +Find dynamic entities from a data source that match a query. + +![Image of Query dynamic entities sample](query-dynamic-entities.png) + +## Use case + +Developers can query a `DynamicEntityDataSource` to find dynamic entities that meet spatial and/or attribute criteria. The query returns a collection of dynamic entities matching the `DynamicEntityQueryParameters` at the moment the query is executed. An example of this is a flight tracking app that monitors airspace near a particular airport, allowing the user to monitor flights based on different criteria such as arrival airport or flight number. + +## How to use the sample + +Tap the "Query Flights" button and select a query to perform from the menu. Once the query is complete, a list of the resulting flights will be displayed. Tap on a flight to see its latest attributes in real-time. + +## How it works + +1. Create a `DynamicEntityDataSource` to stream dynamic entity events. +2. Create `DynamicEntityQueryParameters` and set its properties to specify the parameters for the query: + 1. To spatially filter results, set the `geometry` and `spatialRelationship`. The spatial relationship is `intersects` by default. + 2. To query entities with certain attribute values, set the `whereClause`. + 3. To get entities with specific track IDs, modify the `trackIDs` collection. +3. To perform a dynamic entities query, call `DynamicEntityDataSource.queryDynamicEntities(using:)` passing in the parameters. +4. When complete, get the dynamic entities from the result using `DynamicEntityQueryResult.entities()`. +5. Use `DynamicEntity.changes` to get the entities' change notifications. +6. Get the new observation from the resulting `DynamicEntityChangedInfo` objects using `receivedObservation` and use `dynamicEntityWasPurged` to determine whether a dynamic entity has been purged. + +## Relevant API + +* DynamicEntity +* DynamicEntityChangedInfo +* DynamicEntityDataSource +* DynamicEntityLayer +* DynamicEntityObservation +* DynamicEntityObservationInfo +* DynamicEntityQueryParameters +* DynamicEntityQueryResult + +## About the data + +This sample uses the [PHX Air Traffic JSON](https://www.arcgis.com/home/item.html?id=c78e297e99ad4572a48cdcd0b54bed30) portal item, which is hosted on ArcGIS Online and downloaded automatically. The file contains JSON data for mock air traffic around the Phoenix Sky Harbor International Airport in Phoenix, AZ, USA. The decoded data is used to simulate dynamic entity events through a `CustomDynamicEntityDataSource`, which is displayed on the map with a `DynamicEntityLayer`. + +## Additional information + +A dynamic entities query is performed on the most recent observation of each dynamic entity in the data source at the time the query is executed. As the dynamic entities change, they may no longer match the query parameters. + +## Tags + +data, dynamic, entity, live, query, real-time, search, stream, track diff --git a/Shared/Samples/Query dynamic entities/README.metadata.json b/Shared/Samples/Query dynamic entities/README.metadata.json new file mode 100644 index 000000000..e16ccfc24 --- /dev/null +++ b/Shared/Samples/Query dynamic entities/README.metadata.json @@ -0,0 +1,46 @@ +{ + "category": "Search and Query", + "description": "Find dynamic entities from a data source that match a query.", + "ignore": false, + "images": [ + "query-dynamic-entities.png" + ], + "keywords": [ + "data", + "dynamic", + "entity", + "live", + "query", + "real-time", + "search", + "stream", + "track", + "DynamicEntity", + "DynamicEntityChangedInfo", + "DynamicEntityDataSource", + "DynamicEntityLayer", + "DynamicEntityObservation", + "DynamicEntityObservationInfo", + "DynamicEntityQueryParameters", + "DynamicEntityQueryResult" + ], + "offline_data": [ + "c78e297e99ad4572a48cdcd0b54bed30" + ], + "redirect_from": [], + "relevant_apis": [ + "DynamicEntity", + "DynamicEntityChangedInfo", + "DynamicEntityDataSource", + "DynamicEntityLayer", + "DynamicEntityObservation", + "DynamicEntityObservationInfo", + "DynamicEntityQueryParameters", + "DynamicEntityQueryResult" + ], + "snippets": [ + "QueryDynamicEntitiesView.swift", + "QueryDynamicEntitiesView.Model.swift" + ], + "title": "Query dynamic entities" +} diff --git a/Shared/Samples/Query dynamic entities/query-dynamic-entities.png b/Shared/Samples/Query dynamic entities/query-dynamic-entities.png new file mode 100644 index 000000000..51324fe91 Binary files /dev/null and b/Shared/Samples/Query dynamic entities/query-dynamic-entities.png differ diff --git a/Shared/Samples/Search for web map/SearchForWebMapView.Model.swift b/Shared/Samples/Search for web map/SearchForWebMapView.Model.swift index 99efae46b..47ba2dfc6 100644 --- a/Shared/Samples/Search for web map/SearchForWebMapView.Model.swift +++ b/Shared/Samples/Search for web map/SearchForWebMapView.Model.swift @@ -94,7 +94,6 @@ extension SearchForWebMapView { 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 diff --git a/Shared/Samples/Show device location history/ShowDeviceLocationHistoryView.swift b/Shared/Samples/Show device location history/ShowDeviceLocationHistoryView.swift index 4f24511f8..098195633 100644 --- a/Shared/Samples/Show device location history/ShowDeviceLocationHistoryView.swift +++ b/Shared/Samples/Show device location history/ShowDeviceLocationHistoryView.swift @@ -143,7 +143,6 @@ private extension ShowDeviceLocationHistoryView { /// Makes the simulated location data source for the sample. /// - Returns: A simulated location data source in Los Angeles, CA. private static func makeSimulatedLocationDataSource() -> SimulatedLocationDataSource { - // swiftlint:disable:next force_try let routePolyline = try! Polyline.fromJSON(polylineJSON) // Densify the simulated path to make it smoother. let densifiedRoute = GeometryEngine.geodeticDensify( diff --git a/Shared/Samples/Show geodesic path between two points/README.metadata.json b/Shared/Samples/Show geodesic path between two points/README.metadata.json index c9000b673..4097c434a 100644 --- a/Shared/Samples/Show geodesic path between two points/README.metadata.json +++ b/Shared/Samples/Show geodesic path between two points/README.metadata.json @@ -1,5 +1,5 @@ { - "category": "Edit and Manage Data", + "category": "Visualization", "description": "Calculate a geodesic path between two points and measure its distance.", "ignore": false, "images": [ diff --git a/Shared/Samples/Snap geometry edits with utility network rules/SnapGeometryEditsWithUtilityNetworkRulesView.Model.swift b/Shared/Samples/Snap geometry edits with utility network rules/SnapGeometryEditsWithUtilityNetworkRulesView.Model.swift index 77954b8bc..039eb544e 100644 --- a/Shared/Samples/Snap geometry edits with utility network rules/SnapGeometryEditsWithUtilityNetworkRulesView.Model.swift +++ b/Shared/Samples/Snap geometry edits with utility network rules/SnapGeometryEditsWithUtilityNetworkRulesView.Model.swift @@ -279,7 +279,7 @@ private extension Data { private extension Geodatabase { /// Returns a temporary geodatabase with gas utility network data for Naperville. static func napervilleGasUtilities() -> Geodatabase { - let temporaryGeodatabaseURL = try! FileManager.default // swiftlint:disable:this force_try + let temporaryGeodatabaseURL = try! FileManager.default .url( for: .itemReplacementDirectory, in: .userDomainMask, diff --git a/Shared/Samples/Update related features/README.md b/Shared/Samples/Update related features/README.md new file mode 100644 index 000000000..3e5cc97bb --- /dev/null +++ b/Shared/Samples/Update related features/README.md @@ -0,0 +1,46 @@ +# Update related features + +Update related features in an online feature service. + +![Image of Update related features sample](update-related-features.png) + +## Use case + +Updating related features is a helpful workflow when you have two features with shared or dependent attributes. In a data collection scenario where origin tree features are related to destination inspection records, trees might undergo inspection on some regular interval to assess their size, health, and other characteristics. When logging a new inspection record that captures the latest trunk diameter and condition of a tree, updating these attributes on the origin tree feature would permit the tree point to be symbolized most accurately according to these latest observations. + +## How to use the sample + +Once you launch the app, select a national park feature. The app will then identify it, perform a related table query, and will show you the annual visitors amount for the preserve. You can then update the visitor amount by tapping the drop-down in the `Callout` and selecting a different amount. This will apply the update on the server and update the Legend accordingly. + +## How it works + +1. Create two `ServiceFeatureTable`s from the Feature Service URLs. +2. Create two `FeatureLayer`s using the previously created service feature tables. +3. Add these feature layers to the map. +4. When a `Feature` is selected, identify and highlight the selected feature. +5. Retrieve related features by calling `ServiceFeatureTable.queryRelatedFeatures(to:using:)` and passing in the selected feature. +6. Updates can be applied to the server using `ServiceFeatureTable.update(_:)` and `ServiceFeatureTable.applyEdits()`. + +## Relevant API + +* ArcGISFeature +* RelatedFeatureQueryResult +* RelatedQueryParameters +* ServiceFeatureTable + +## About the data + +The map opens to a view of the State of Alaska. Two related feature layers are loaded to the map and display the Alaska National Parks and Preserves. + +## Additional information + +All the tables participating in a relationship must be present in the data source. ArcGIS Maps SDK supports related tables in the following data sources: + +* ArcGIS feature service +* ArcGIS map service +* Geodatabase downloaded from a feature service +* Geodatabase in a mobile map package + +## Tags + +editing, features, service, updating diff --git a/Shared/Samples/Update related features/README.metadata.json b/Shared/Samples/Update related features/README.metadata.json new file mode 100644 index 000000000..68c687afd --- /dev/null +++ b/Shared/Samples/Update related features/README.metadata.json @@ -0,0 +1,29 @@ +{ + "category": "Edit and Manage Data", + "description": "Update related features in an online feature service.", + "ignore": false, + "images": [ + "update-related-features.png" + ], + "keywords": [ + "editing", + "features", + "service", + "updating", + "ArcGISFeature", + "RelatedFeatureQueryResult", + "RelatedQueryParameters", + "ServiceFeatureTable" + ], + "redirect_from": [], + "relevant_apis": [ + "ArcGISFeature", + "RelatedFeatureQueryResult", + "RelatedQueryParameters", + "ServiceFeatureTable" + ], + "snippets": [ + "UpdateRelatedFeaturesView.swift" + ], + "title": "Update related features" +} diff --git a/Shared/Samples/Update related features/UpdateRelatedFeaturesView.swift b/Shared/Samples/Update related features/UpdateRelatedFeaturesView.swift new file mode 100644 index 000000000..d915be395 --- /dev/null +++ b/Shared/Samples/Update related features/UpdateRelatedFeaturesView.swift @@ -0,0 +1,279 @@ +// Copyright 2025 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 UpdateRelatedFeaturesView: View { + /// The model that holds the data for displaying and updating the view. + @State private var model = Model() + + /// A Boolean value indicating whether the feature data is being loaded. + @State private var isLoading = false + + /// The last locations in the screen and map where a tap occurred. + @State private var lastSingleTap: (screenPoint: CGPoint, mapPoint: Point)? + + /// The error shown in the error alert. + @State private var error: (any Error)? + + var body: some View { + MapViewReader { mapView in + MapView(map: model.map) + .onSingleTapGesture { screenPoint, mapPoint in + lastSingleTap = (screenPoint, mapPoint) + } + .callout(placement: $model.calloutPlacement) { _ in + // Show a callout with editable content when a feature is selected. + calloutContent + } + .task { + // Load initial map and data when the view appears. + isLoading = true + defer { isLoading = false } + do { + try await model.loadFeatures() + // Set initial viewpoint to Alaska. + await mapView.setViewpoint( + Viewpoint( + latitude: 65.399121, + longitude: -151.521682, + scale: 50000000 + ) + ) + } catch { + self.error = error + } + } + .task(id: lastSingleTap?.mapPoint) { + isLoading = true + defer { isLoading = false } + model.clearAll() + // Ensure parks feature layer is available and clear it. + guard let mapPoint = lastSingleTap?.mapPoint else { return } + guard let parksLayer = model.parksFeatureLayer else { return } + parksLayer.clearSelection() + + do { + let identifyResult = try await mapView.identify( + on: parksLayer, + screenPoint: lastSingleTap?.screenPoint ?? .zero, + tolerance: 5 + ) + // If a feature is found, select and query related data. + if let identifiedFeature = identifyResult.geoElements.first as? ArcGISFeature { + parksLayer.selectFeature(identifiedFeature) + model.selectedFeature = identifiedFeature + // Query for related preserve data. + try await model.queryRelatedFeatures(for: identifiedFeature) + // Display a callout at the feature's location. + model.calloutIsVisible = true + model.calloutPlacement = .location(mapPoint) + // Center the map on the tapped feature. + await mapView.setViewpointCenter(mapPoint) + } + } catch { + self.error = error + } + } + .overlay(alignment: .center) { + // Show a loading spinner when `isLoading` is true. + if isLoading { + loadingView + } + } + .errorAlert(presentingError: $error) + } + } + + /// A view displaying callout content, including editable "Annual Visitors" values. + /// Includes a picker to allow updating the selected visitor range. + var calloutContent: some View { + VStack(alignment: .leading, spacing: 8) { + Text("\(model.parkName)") + .font(.headline) + if !model.attributeValue.isEmpty { + Text("Annual Visitors:") + // Picker to allow the user to update visitor range. + Picker("Annual Visitors", selection: $model.selectedVisitorValue) { + ForEach(model.visitorOptions, id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(.menu) + .onChange(of: model.selectedVisitorValue) { _, newValue in + Task { + do { + try await model.updateRelatedFeature(using: newValue) + } catch { + self.error = error + } + } + } + } + } + .padding() + } + + /// The loading indicator overlay shown during data fetches. + var loadingView: some View { + ProgressView( + """ + Fetching + data + """ + ) + .padding() + .background(.ultraThinMaterial) + .clipShape(.rect(cornerRadius: 10)) + .shadow(radius: 50) + .multilineTextAlignment(.center) + } +} + +extension UpdateRelatedFeaturesView { + @MainActor + @Observable + class Model { + /// A map with a topographic basemap style. + @ObservationIgnored var map = Map(basemapStyle: .arcGISTopographic) + + /// A Boolean value indicating whether the callout should be shown or not. + var calloutIsVisible = false + + /// The parks feature layer for querying. + var parksFeatureLayer: FeatureLayer? + + /// The parks feature table. + var parksFeatureTable: ServiceFeatureTable? + + /// The preserves feature table. + var preservesTable: ServiceFeatureTable? + + /// The feature currently selected by the user. + var selectedFeature: ArcGISFeature? + + /// The feature that is related to the selected feature. + var relatedSelectedFeature: ArcGISFeature? + + /// The location of the callout on the map. + var calloutPlacement: CalloutPlacement? + + /// The current visitor count attribute value. + var attributeValue = "" + + /// The name of the selected park. + var parkName = "" + + /// Visitor options for selection. + @ObservationIgnored var visitorOptions = ["0-1,000", "1,000–10,000", "10,000-50,000", "50,000-100,000", "100,000+"] + + /// The currently selected visitor option. + var selectedVisitorValue = "0-1,000" + + /// Clears selected data and callout. + func clearAll() { + relatedSelectedFeature = nil + attributeValue = "" + calloutPlacement = nil + } + + /// Loads feature tables from the Alaska parks feature service + /// and adds them as operational layers to the map. + /// + /// - Throws: An error if the service geodatabase or tables fail to load. + func loadFeatures() async throws { + let geodatabase = ServiceGeodatabase(url: .alaskaParksFeatureService) + try await geodatabase.load() + // Load parks layer. + parksFeatureTable = geodatabase.table(withLayerID: 1) + if let parksFeatureTable { + parksFeatureLayer = FeatureLayer(featureTable: parksFeatureTable) + map.addOperationalLayer(parksFeatureLayer!) + } + // Load preserves layer. + preservesTable = geodatabase.table(withLayerID: 0) + if let preservesTable { + let preservesLayer = FeatureLayer(featureTable: preservesTable) + map.addOperationalLayer(preservesLayer) + } + } + + /// Updates the related preserve feature with the new "Annual Visitors" value + /// and applies the changes to the service geodatabase. + /// + /// - Parameter value: The value to assign to the `ANNUAL_VISITORS` attribute. + /// - Throws: An error if the feature fails to load, update, or if apply edits fail. + func updateRelatedFeature(using value: String) async throws { + guard let relatedSelectedFeature else { return } + try await relatedSelectedFeature.load() + relatedSelectedFeature.setAttributeValue(value, forKey: .annualVisitorsKey) + attributeValue = value + try await preservesTable?.update(relatedSelectedFeature) + // Apply edits to the service geodatabase. + if let geodatabase = preservesTable?.serviceGeodatabase { + let editResults = try await geodatabase.applyEdits() + if let first = editResults.first, + first.editResults[0].didCompleteWithErrors == false { + parksFeatureLayer?.clearSelection() + } + } + } + + /// Queries related features (preserves) for a selected park feature + /// and stores the result to display and edit. + /// + /// - Parameter feature: The selected park feature to query related data for. + /// - Throws: An error if the related features query fails. + func queryRelatedFeatures(for feature: ArcGISFeature) async throws { + guard let parksTable = parksFeatureTable else { return } + let attributes = feature.attributes + // Default to park name from the selected park feature. + parkName = attributes[.parkNameKey] as? String ?? "Unknown" + // Reset attribute value in case there are no related feature results. + attributeValue = "" + let relatedResultsQuery = try await parksTable.queryRelatedFeatures(to: feature) + for relatedResult in relatedResultsQuery { + for relatedFeature in relatedResult.features() { + if let relatedArcGISFeature = relatedFeature as? ArcGISFeature { + let attributes = relatedArcGISFeature.attributes + attributeValue = attributes[.annualVisitorsKey] as? String ?? "" + parkName = attributes[.parkNameKey] as? String ?? "Unknown" + selectedVisitorValue = attributeValue + relatedSelectedFeature = relatedArcGISFeature + } + } + } + } + } +} + +extension String { + /// The attribute key for the "Annual Visitors" field. + static var annualVisitorsKey: String { + "ANNUAL_VISITORS" + } + + /// The attribute key for the "Unit Name" (park name) field. + static var parkNameKey: String { + "UNIT_NAME" + } +} + +extension URL { + /// The URL of the Alaska Parks and Preserves feature service. + static var alaskaParksFeatureService: URL { + URL(string: "https://services2.arcgis.com/ZQgQTuoyBrtmoGdP/ArcGIS/rest/services/AlaskaNationalParksPreserves_Update/FeatureServer")! + } +} diff --git a/Shared/Samples/Update related features/update-related-features.png b/Shared/Samples/Update related features/update-related-features.png new file mode 100644 index 000000000..e084946f2 Binary files /dev/null and b/Shared/Samples/Update related features/update-related-features.png differ diff --git a/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.Model.swift b/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.Model.swift index b4a3b7a57..b942eef01 100644 --- a/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.Model.swift +++ b/Shared/Samples/Validate utility network topology/ValidateUtilityNetworkTopologyView.Model.swift @@ -23,7 +23,7 @@ extension ValidateUtilityNetworkTopologyView { // MARK: Properties /// A map with no specified style. - let map = Map() + private(set) var map = Map() /// The graphics overlay for the starting location graphic. let graphicsOverlay: GraphicsOverlay = { @@ -254,8 +254,8 @@ extension ValidateUtilityNetworkTopologyView { 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 + // Create and load a map using the portal item. + map = Map(item: portalItem) map.initialViewpoint = Viewpoint(center: Point(x: -9815160, y: 5128880), scale: 3640) try await map.load() diff --git a/Shared/Supporting Files/Extensions/StringProtocol.swift b/Shared/Supporting Files/Extensions/StringProtocol.swift new file mode 100644 index 000000000..c3bacf4e9 --- /dev/null +++ b/Shared/Supporting Files/Extensions/StringProtocol.swift @@ -0,0 +1,21 @@ +// Copyright 2025 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. + +extension StringProtocol { + /// Returns the upper camel cased version of this string. + /// - Note: For example, "Display map" -> "DisplayMap". + func upperCamelCased() -> String { + capitalized.filter { !$0.isWhitespace && !$0.isPunctuation } + } +} diff --git a/Shared/Supporting Files/Models/OnDemandResource.swift b/Shared/Supporting Files/Models/OnDemandResource.swift index dc48c281c..296cbeb3e 100644 --- a/Shared/Supporting Files/Models/OnDemandResource.swift +++ b/Shared/Supporting Files/Models/OnDemandResource.swift @@ -12,18 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Combine import Foundation /// A wrapper class that manages on-demand resource request. @MainActor -final class OnDemandResource: ObservableObject { +@Observable +final class OnDemandResource { /// The state of an on-demand resource request. enum RequestState { - /// A request that has not started. + /// A request that has been set up but not started. case notStarted /// A request that has started and is downloading. - case inProgress(Double) + case inProgress /// A request that has completed successfully. case downloaded /// A request that was cancelled. @@ -35,54 +35,50 @@ final class OnDemandResource: ObservableObject { /// The progress of the on-demand resource request. var progress: Progress { request.progress } + /// A Boolean value indicating whether a resource request can be initiated. + var isDownloadable: Bool { + requestState != .inProgress && requestState != .downloaded + } + /// The current state of the on-demand resource request. - @Published private(set) var requestState: RequestState = .notStarted + private(set) var requestState: RequestState /// The error occurred in downloading resources. - @Published private(set) var error: (any Error)? + private(set) var error: (any Error)? /// The on-demand resource request. private let request: NSBundleResourceRequest - /// A set of cancellable instances for the request progress subscription. - private var cancellables: Set = [] - /// Initializes a request with a set of Resource Tags. - init(tags: Set) { + init(tags: Set) async { request = NSBundleResourceRequest(tags: tags) request.loadingPriority = NSBundleResourceRequestLoadingPriorityUrgent - request.progress - .publisher(for: \.fractionCompleted, options: .new) - .receive(on: DispatchQueue.main) - .map { .inProgress($0) } - .sink { [weak self] in self?.requestState = $0 } - .store(in: &cancellables) + + let isResourceAvailable = await request.conditionallyBeginAccessingResources() + requestState = isResourceAvailable ? .downloaded : .notStarted } /// Cancels the on-demand resource request. func cancel() { progress.cancel() - cancellables.removeAll() request.endAccessingResources() requestState = .cancelled } /// Starts the on-demand resource request. func download() async { - // Initiates download when it is not being/already downloaded. - // Checks if the resource is already on device. - let isResourceAvailable = await request.conditionallyBeginAccessingResources() - if isResourceAvailable { + guard isDownloadable else { return } + + requestState = .inProgress + do { + try await request.beginAccessingResources() requestState = .downloaded - } else { - do { - try await request.beginAccessingResources() - requestState = .downloaded - } catch { - if (error as NSError).code != NSUserCancelledError { - self.error = error - requestState = .error - } + } catch { + if (error as NSError).code != NSUserCancelledError { + self.error = error + requestState = .error + } else { + cancel() } } } diff --git a/Shared/Supporting Files/Models/Sample.swift b/Shared/Supporting Files/Models/Sample.swift index a07c543db..0e4efc24d 100644 --- a/Shared/Supporting Files/Models/Sample.swift +++ b/Shared/Supporting Files/Models/Sample.swift @@ -69,12 +69,12 @@ extension Sample { snippets.compactMap { Bundle.main.url(forResource: $0, withExtension: nil) } } - /// The sample's name in UpperCamelCase. - /// - Note: For example, "Display map" -> "DisplayMap". - var nameInUpperCamelCase: String { - name.capitalized.filter { !$0.isWhitespace && !$0.isPunctuation } - } - /// By default, a sample doesn't have dependencies. var hasDependencies: Bool { false } + + /// The on-demand resource tags for this sample. + var odrTags: Set { + guard hasDependencies else { return [] } + return [name.upperCamelCased()] + } } diff --git a/Shared/Supporting Files/Views/AboutView.swift b/Shared/Supporting Files/Views/AboutView.swift index 7375f1da8..07227f93f 100644 --- a/Shared/Supporting Files/Views/AboutView.swift +++ b/Shared/Supporting Files/Views/AboutView.swift @@ -28,6 +28,8 @@ struct AboutView: View { /// A Boolean value indicating whether the API key alert is presented. @State private var isAPIKeyAlertPresented = false + /// A Boolean value indicating whether the download offline resources cover is presented. + @State private var isResourceDownloaderPresented = false /// The API key entered in the alert. @State private var apiKey = "" @@ -70,6 +72,16 @@ struct AboutView: View { } footer: { Text("View details about the API.") } +#if !targetEnvironment(macCatalyst) + Section { + Button("Download Offline Resources") { + isResourceDownloaderPresented = true + } + .fullScreenCover(isPresented: $isResourceDownloaderPresented) { + DownloadOfflineResourcesView() + } + } +#endif #if DEBUG debugSection #endif diff --git a/Shared/Supporting Files/Views/DownloadOfflineResourcesView.swift b/Shared/Supporting Files/Views/DownloadOfflineResourcesView.swift new file mode 100644 index 000000000..81a2d0eed --- /dev/null +++ b/Shared/Supporting Files/Views/DownloadOfflineResourcesView.swift @@ -0,0 +1,207 @@ +// Copyright 2025 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 + +/// A view with controls for downloading the app's on-demand resources. +struct DownloadOfflineResourcesView: View { + /// The samples that require offline resources. + let samples = SamplesApp.samples.filter(\.hasDependencies) + + /// The action to dismiss the view. + @Environment(\.dismiss) private var dismiss + + /// The view models for the on-demand resource requests. + @State private var onDemandResources: [String: OnDemandResource] = [:] + + /// The current state of the "download all on-demand resources" request. + @State private var downloadAllRequestState: OnDemandResource.RequestState = .notStarted + + /// A Boolean value indicating whether confirm cancel alert is showing. + @State private var confirmCancelAlertIsShowing = false + + /// A Boolean value indicating whether all of the `onDemandResources` have successfully downloaded. + private var allResourcesAreDownloaded: Bool { + guard !onDemandResources.isEmpty else { return false } + return onDemandResources.values + .allSatisfy { $0.requestState == .downloaded } + } + + /// Returns the on-demand resource for the given sample. + func resource(for sample: Sample) -> OnDemandResource? { + return onDemandResources[sample.name] + } + + var body: some View { + NavigationStack { + Form { + Section { + Button { + downloadAllRequestState = .inProgress + } label: { + Label { + Text("Download All") + } icon: { + switch downloadAllRequestState { + case .inProgress: + ProgressView() + case .downloaded: + Image(systemName: "checkmark.circle") + .foregroundStyle(.secondary) + default: + Image(systemName: "arrow.down.circle") + } + } + .frame(maxWidth: .infinity) + } + .disabled(onDemandResources.isEmpty || downloadAllRequestState != .notStarted || allResourcesAreDownloaded) + .task(id: downloadAllRequestState) { + guard downloadAllRequestState == .inProgress else { return } + await downloadAll() + } + .onChange(of: allResourcesAreDownloaded) { + guard allResourcesAreDownloaded else { return } + downloadAllRequestState = .downloaded + } + } + Section { + List(samples, id: \.name) { sample in + if let resource = resource(for: sample) { + DownloadOnDemandResourceView(name: sample.name, resource: resource) + } + } + } footer: { + Text("**Note**: The system may purge downloads at any time.") + } + } + .navigationTitle("Download Offline Resources") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + if onDemandResources.values.allSatisfy({ $0.requestState != .inProgress }) { + dismiss() + } else { + confirmCancelAlertIsShowing = true + } + } + .alert("Cancel downloads?", isPresented: $confirmCancelAlertIsShowing) { + Button("Don't Cancel", role: .cancel, action: {}) + + Button("Cancel") { + for resource in onDemandResources.values where resource.requestState == .inProgress { + resource.cancel() + } + dismiss() + } + } + } + } + .task { + guard onDemandResources.isEmpty else { return } + onDemandResources = await withTaskGroup { group in + for sample in samples { + group.addTask { + let resource = await OnDemandResource(tags: sample.odrTags) + return (sample.name, resource) + } + } + var resources: [String: OnDemandResource] = [:] + for await (name, resource) in group { + resources[name] = resource + } + return resources + } + } + } + } + + /// Downloads all of the on-demand resources that haven't started a request yet. + /// - Note: The system may purge the resources at any time after the request object is deallocated. + private func downloadAll() async { + await withTaskGroup { group in + for resource in onDemandResources.values where resource.isDownloadable { + group.addTask(operation: resource.download) + } + } + + // Automatically dismisses the view if all of the resources have downloaded successfully. + if allResourcesAreDownloaded { + dismiss() + } + } +} + +/// A view for downloading an `OnDemandResource` and displaying its request state. +private struct DownloadOnDemandResourceView: View { + /// The name of the resource. + let name: String + /// The on-demand resource to download. + let resource: OnDemandResource + + /// A Boolean value indicating whether on-demand resource is currently downloading. + @State private var isDownloading = false + + var body: some View { + Button { + isDownloading = true + } label: { + Label { + Text(name) + + if resource.requestState == .error, let error = resource.error { + Text("Error: \(error.localizedDescription)") + .foregroundStyle(.red) + } + } icon: { + switch resource.requestState { + case .inProgress: + ProgressView(resource.progress) + .progressViewStyle(GaugeProgressViewStyle()) + case .downloaded: + Image(systemName: "checkmark.circle") + .foregroundStyle(.secondary) + default: + Image(systemName: "arrow.down.circle") + } + } + } + .disabled(!resource.isDownloadable) + .task(id: isDownloading) { + guard isDownloading else { return } + defer { isDownloading = false } + + await resource.download() + } + } +} + +/// A circular gauge progress view style. +private struct GaugeProgressViewStyle: ProgressViewStyle { + func makeBody(configuration: Configuration) -> some View { + if let fractionCompleted = configuration.fractionCompleted { + let gradientStops: [Gradient.Stop] = [ + .init(color: .accent, location: 0), + .init(color: .accent, location: fractionCompleted), + .init(color: .init(.tertiarySystemFill), location: fractionCompleted), + .init(color: .init(.tertiarySystemFill), location: 1) + ] + let gradient = AngularGradient(gradient: .init(stops: gradientStops), center: .center) + + Image(systemName: "circle") + .foregroundStyle(gradient) + .rotationEffect(.degrees(-90)) + } + } +} diff --git a/Shared/Supporting Files/Views/FavoritesView.swift b/Shared/Supporting Files/Views/FavoritesView.swift index 7b9d38997..237ad4d2c 100644 --- a/Shared/Supporting Files/Views/FavoritesView.swift +++ b/Shared/Supporting Files/Views/FavoritesView.swift @@ -24,7 +24,10 @@ struct FavoritesView: View { /// The names of the favorite samples loaded from user defaults. @AppFavorites private var favoriteNames - /// A list of the favorite samples. + /// The favorited samples to show in the list. + /// + /// This may contain less elements than `favoriteNames` if the user defaults has invalid values, + /// such as the name of a sample that was added in another branch. private var favoriteSamples: [Sample] { favoriteNames.compactMap { name in SamplesApp.samples.first(where: { $0.name == name }) @@ -36,12 +39,8 @@ struct FavoritesView: View { 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) - } + .onMove(perform: moveFavorites(fromOffsets:toOffset:)) + .onDelete(perform: deleteFavorites(atOffsets:)) } .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { @@ -62,6 +61,35 @@ struct FavoritesView: View { editMode?.wrappedValue = .inactive } } + + /// Moves favorites in `favoriteNames` using `favoriteSamples` offsets. + /// - Parameters: + /// - source: The `favoriteSamples` offsets of the favorites to move. + /// - destination: The `favoriteSamples` offset of the favorite before which to insert the favorites. + private func moveFavorites(fromOffsets source: IndexSet, toOffset destination: Int) { + let favoriteSamples = favoriteSamples + let newSource = source.reduce(into: IndexSet()) { indexSet, offset in + let index = favoriteNames.firstIndex(of: favoriteSamples[offset].name)! + indexSet.insert(index) + } + let newDestination = destination < favoriteSamples.count + ? favoriteNames.firstIndex(of: favoriteSamples[destination].name)! + : favoriteNames.count + + favoriteNames.move(fromOffsets: newSource, toOffset: newDestination) + } + + /// Removes favorites from `favoriteNames` using `favoriteSamples` offsets. + /// - Parameter offsets: The `favoriteSamples` offsets of the favorites to remove. + private func deleteFavorites(atOffsets offsets: IndexSet) { + let favoriteSamples = favoriteSamples + let newOffsets = offsets.reduce(into: IndexSet()) { indexSet, offset in + let index = favoriteNames.firstIndex(of: favoriteSamples[offset].name)! + indexSet.insert(index) + } + + favoriteNames.remove(atOffsets: newOffsets) + } } private extension FavoritesView { diff --git a/Shared/Supporting Files/Views/SampleDetailView.swift b/Shared/Supporting Files/Views/SampleDetailView.swift index f01978fe5..fe98675f6 100644 --- a/Shared/Supporting Files/Views/SampleDetailView.swift +++ b/Shared/Supporting Files/Views/SampleDetailView.swift @@ -25,7 +25,7 @@ struct SampleDetailView: View { @State private var isSampleInfoViewPresented = false /// An object to manage on-demand resources for a sample with dependencies. - @StateObject private var onDemandResource: OnDemandResource + @State private var onDemandResource: OnDemandResource? /// A Boolean value indicating whether a sample should use on-demand resources. private var usesOnDemandResources: Bool { @@ -51,9 +51,6 @@ struct SampleDetailView: View { init(sample: Sample) { self.sample = sample - self._onDemandResource = StateObject( - wrappedValue: OnDemandResource(tags: [sample.nameInUpperCamelCase]) - ) } var body: some View { @@ -61,12 +58,17 @@ struct SampleDetailView: View { if usesOnDemandResources { // 'onDemandResource' is created in this branch. Group { - switch onDemandResource.requestState { - case .notStarted, .inProgress: + switch onDemandResource?.requestState { + case .none, .notStarted, .inProgress: VStack { - ProgressView(onDemandResource.progress) + if let progress = onDemandResource?.progress { + ProgressView(progress) + } else { + ProgressView() + .progressViewStyle(.circular) + } Button("Cancel") { - onDemandResource.cancel() + onDemandResource?.cancel() } } .padding() @@ -79,7 +81,7 @@ struct SampleDetailView: View { case .error: VStack { Image(systemName: "x.circle") - Text(onDemandResource.error!.localizedDescription) + Text(onDemandResource!.error!.localizedDescription) } .padding() case .downloaded: @@ -87,8 +89,9 @@ struct SampleDetailView: View { } } .task { - guard case .notStarted = onDemandResource.requestState else { return } - await onDemandResource.download() + guard onDemandResource == nil else { return } + onDemandResource = await OnDemandResource(tags: sample.odrTags) + await onDemandResource!.download() } } else { // 'onDemandResource' is not created in this branch. @@ -117,5 +120,5 @@ struct SampleDetailView: View { } extension SampleDetailView: Identifiable { - nonisolated var id: String { sample.nameInUpperCamelCase } + nonisolated var id: String { sample.name } }