diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 603d02671b..667d5370a5 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 75C37C9227BEDBD800FC9DCE /* BookmarksExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75C37C9127BEDBD800FC9DCE /* BookmarksExampleView.swift */; }; 75D41B2B27C6F21400624D7C /* ScalebarExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D41B2A27C6F21400624D7C /* ScalebarExampleView.swift */; }; 882899FD2AB5099300A0BDC1 /* FlyoverExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 882899FC2AB5099300A0BDC1 /* FlyoverExampleView.swift */; }; + 88A2AF8B2D1F762C006B60DE /* FeatureTemplatePickerExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A2AF8A2D1F762C006B60DE /* FeatureTemplatePickerExampleView.swift */; }; E42BFBE92672BF9500159107 /* SearchExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E42BFBE82672BF9500159107 /* SearchExampleView.swift */; }; E4624A25278CE815000D2A38 /* FloorFilterExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4624A24278CE815000D2A38 /* FloorFilterExampleView.swift */; }; E47ABE442652FE0900FD2FE3 /* ExamplesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47ABE432652FE0900FD2FE3 /* ExamplesApp.swift */; }; @@ -55,6 +56,7 @@ 75C37C9127BEDBD800FC9DCE /* BookmarksExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksExampleView.swift; sourceTree = ""; }; 75D41B2A27C6F21400624D7C /* ScalebarExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScalebarExampleView.swift; sourceTree = ""; }; 882899FC2AB5099300A0BDC1 /* FlyoverExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlyoverExampleView.swift; sourceTree = ""; }; + 88A2AF8A2D1F762C006B60DE /* FeatureTemplatePickerExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTemplatePickerExampleView.swift; sourceTree = ""; }; E42BFBE82672BF9500159107 /* SearchExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchExampleView.swift; sourceTree = ""; }; E4624A24278CE815000D2A38 /* FloorFilterExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloorFilterExampleView.swift; sourceTree = ""; }; E47ABE402652FE0900FD2FE3 /* Toolkit Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Toolkit Examples.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -93,6 +95,7 @@ 75C37C9127BEDBD800FC9DCE /* BookmarksExampleView.swift */, 75657E4727ABAC8400EE865B /* CompassExampleView.swift */, 75B615012AD76158009D19B6 /* FeatureFormExampleView.swift */, + 88A2AF8A2D1F762C006B60DE /* FeatureTemplatePickerExampleView.swift */, E4AA9315276BF5ED000E6289 /* FloatingPanelExampleView.swift */, E4624A24278CE815000D2A38 /* FloorFilterExampleView.swift */, 882899FC2AB5099300A0BDC1 /* FlyoverExampleView.swift */, @@ -281,6 +284,7 @@ 882899FD2AB5099300A0BDC1 /* FlyoverExampleView.swift in Sources */, E48A73462658227100F5C118 /* ExampleView.swift in Sources */, E42BFBE92672BF9500159107 /* SearchExampleView.swift in Sources */, + 88A2AF8B2D1F762C006B60DE /* FeatureTemplatePickerExampleView.swift in Sources */, E4C389D526B8A12C002BC255 /* BasemapGalleryExampleView.swift in Sources */, 75D41B2B27C6F21400624D7C /* ScalebarExampleView.swift in Sources */, ); diff --git a/Examples/Examples/FeatureTemplatePickerExampleView.swift b/Examples/Examples/FeatureTemplatePickerExampleView.swift new file mode 100644 index 0000000000..0609060396 --- /dev/null +++ b/Examples/Examples/FeatureTemplatePickerExampleView.swift @@ -0,0 +1,83 @@ +// Copyright 2024 Esri +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ArcGIS +import ArcGISToolkit +import SwiftUI + +/// Allows a user to select a feature template and displays +/// the name of the template that was selected. +struct FeatureTemplatePickerExampleView: View { + static func makeMap() -> Map { + let map = Map(basemapStyle: .arcGISTopographic) + let featureTable = ServiceFeatureTable(url: URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/DamageAssessment/FeatureServer/0")!) + let featureLayer = FeatureLayer(featureTable: featureTable) + map.addOperationalLayer(featureLayer) + return map + } + + /// The `Map` displayed in the `MapView`. + @State private var map = makeMap() + + /// A Boolean value indicating if the feature template picker + /// is presented. + @State private var templatePickerIsPresented = false + + /// The selection of the feature template picker. + @State private var selection: FeatureTemplateInfo? + + var body: some View { + MapView(map: map) + .sheet(isPresented: $templatePickerIsPresented) { + NavigationStack { + FeatureTemplatePicker( + geoModel: map, + selection: $selection, + includeNonCreatableFeatureTemplates: true + ) + .onAppear { + // Reset selection when the picker appears. + selection = nil + } + .navigationTitle("Feature Templates") + } + } + .onChange(of: selection) { _ in + // Dismiss the template picker upon selection. + if selection != nil { + templatePickerIsPresented = false + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + templatePickerIsPresented = true + } label: { + Text("Templates") + } + } + } + .safeAreaInset(edge: .top) { + if let selection { + HStack { + if let image = selection.image { + Image(uiImage: image) + } + Text("\(selection.template.name) Template Selected") + } + .font(.subheadline) + } + } + } +} diff --git a/Examples/ExamplesApp/Examples.swift b/Examples/ExamplesApp/Examples.swift index 707f55e603..4cf7b0467b 100644 --- a/Examples/ExamplesApp/Examples.swift +++ b/Examples/ExamplesApp/Examples.swift @@ -59,6 +59,7 @@ extension ExampleList { AnyExample("Bookmarks", content: BookmarksExampleView()), AnyExample("Compass", content: CompassExampleView()), AnyExample("Feature Form", content: FeatureFormExampleView()), + AnyExample("Feature Template Picker", content: FeatureTemplatePickerExampleView()), AnyExample("Floor Filter", content: FloorFilterExampleView()), AnyExample("Overview Map", content: OverviewMapExampleView()), AnyExample("Popup", content: PopupExampleView()), diff --git a/Sources/ArcGISToolkit/Components/FeatureTemplatePicker/FeatureTemplatePicker.swift b/Sources/ArcGISToolkit/Components/FeatureTemplatePicker/FeatureTemplatePicker.swift new file mode 100644 index 0000000000..80310c21b9 --- /dev/null +++ b/Sources/ArcGISToolkit/Components/FeatureTemplatePicker/FeatureTemplatePicker.swift @@ -0,0 +1,365 @@ +// Copyright 2024 Esri +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ArcGIS +import SwiftUI + +/// A view that displays feature templates from a geo model +/// and allows the user to choose a template. +public struct FeatureTemplatePicker: View { + /// The model backing the feature template picker. + @StateObject private var model: Model + /// The selection + @Binding private var selection: FeatureTemplateInfo? + + /// Creates a feature template picker. + /// - Parameters: + /// - geoModel: The geo model from which feature templates will be displayed. + /// - selection: The selected template. + /// - includeNonCreatableFeatureTemplates: Include feature templates from tables where features cannot be created. + public init(geoModel: GeoModel, selection: Binding, includeNonCreatableFeatureTemplates: Bool = false) { + _model = StateObject( + wrappedValue: Model( + geoModel: geoModel, + includeNonCreatableFeatureTemplates: includeNonCreatableFeatureTemplates + ) + ) + _selection = selection + } + + public var body: some View { + Group { + if model.isGeneratingFeatureTemplates { + ProgressView() + } else if model.showContentUnavailable { + if #available(iOS 17.0, *) { + ContentUnavailableView( + String.noFeatureTemplatesTitle, + systemImage: "mappin.slash.circle", + description: Text(String.noFeatureTemplatesDetail) + ) + } else { + // Fallback on earlier versions + Text(String.noFeatureTemplatesDetail) + } + } else { + List { + ForEach(model.featureTemplateSections) { section in + Section(section.table.displayName) { + ForEach(section.infos) { info in + FeatureTemplateView(info: info, selection: _selection) + } + } + } + } + .searchable(text: $model.searchText, prompt: String.searchTemplatesPrompt) + .overlay { + if model.showNoTemplatesFound { + if #available(iOS 17.0, *) { + ContentUnavailableView( + String.noFeatureTemplatesFoundTitle, + systemImage: "magnifyingglass.circle", + description: Text(String.noFeatureTemplatesFoundDetail) + ) + } else { + // Fallback on earlier versions + Text(String.noFeatureTemplatesFoundDetail) + } + } + } + } + } + .task { + await model.generateFeatureTemplates() + } + } +} + +private extension String { + static var noFeatureTemplatesTitle: String { + String( + localized: "No Feature Templates", + bundle: .toolkitModule, + comment: """ + A title for showing a view that tells the user there are no feature templates. + """ + ) + } + static var noFeatureTemplatesDetail: String { + String( + localized: "There are no feature templates available.", + bundle: .toolkitModule, + comment: """ + Details for showing a view that tells the user there are no feature templates + available. + """ + ) + } + static var searchTemplatesPrompt: String { + String( + localized: "Search templates", + bundle: .toolkitModule, + comment: """ + A prompt in the search templates text box that instructs the user that they + can type in the field to search for templates. + """ + ) + } + static var noFeatureTemplatesFoundTitle: String { + String( + localized: "Nothing Found", + bundle: .toolkitModule, + comment: """ + A title for showing a view that tells the user there were no feature templates + found that match their search criteria. + """ + ) + } + static var noFeatureTemplatesFoundDetail: String { + String( + localized: "There were no feature templates found that match the search criteria.", + bundle: .toolkitModule, + comment: """ + Details for showing a view that tells the user there were no feature templates + found that match their search criteria. + """ + ) + } +} + +/// View of a feature template. +private struct FeatureTemplateView: View { + let info: FeatureTemplateInfo + @Binding var selection: FeatureTemplateInfo? + + // The maximum size of the image when the font is body. + @ScaledMetric(relativeTo: .body) var maxImageSize = 44 + + var body: some View { + HStack { + Label { + Text(info.template.name) + .lineLimit(1) + } icon: { + if let image = info.image { + let size = size(for: image, maxSize: maxImageSize) + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(width: size.width, height: size.height) + } else { + Image(systemName: "minus") + .foregroundStyle(.secondary) + } + } + .font(.body) + Spacer() + if info == selection { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + .contentShape(Rectangle()) + .onTapGesture { + selection = info + } + } + + /// Returns the size that the icon should be in the feature template view. + /// This will scale down images that are bigger than the max, + /// but for images smaller than that it will not scale them up. + private func size(for image: UIImage, maxSize: CGFloat) -> (width: CGFloat, height: CGFloat) { + let xScale = min(1, maxSize / image.size.width) + let yScale = min(1, maxSize / image.size.height) + let scale = min(xScale, yScale) + return ( + width: image.size.width * scale, + height: image.size.height * scale + ) + } +} + +extension FeatureTemplatePicker { + /// The model for the feature template picker. + @MainActor + final class Model: ObservableObject { + /// The associated geo model. + let geoModel: GeoModel + /// Include feature templates from tables where features cannot be created. + let includeNonCreatableFeatureTemplates: Bool + /// Search text for filtering the list of templates. + var searchText: String = "" { + didSet { templatesOrSearchTextDidChange() } + } + /// The complete unfiltered list of feature template sections. + private(set) var unfilteredFeatureTemplateSections = [FeatureTemplateSectionInfo]() { + didSet { templatesOrSearchTextDidChange() } + } + + /// The sections. + @Published private(set) var featureTemplateSections = [FeatureTemplateSectionInfo]() + /// A Boolean value indicating whether templates are being generated. + @Published private(set) var isGeneratingFeatureTemplates = false + /// A Boolean value indicating if content is unavailable. + @Published private(set) var showContentUnavailable = false + /// A Boolean value indicating if no templates were found with the given search text. + @Published private(set) var showNoTemplatesFound = false + + /// Creates a model for a given geo model. + init(geoModel: GeoModel, includeNonCreatableFeatureTemplates: Bool) { + self.geoModel = geoModel + self.includeNonCreatableFeatureTemplates = includeNonCreatableFeatureTemplates + } + + /// Generates the feature templates. + func generateFeatureTemplates() async { + isGeneratingFeatureTemplates = true + defer { isGeneratingFeatureTemplates = false } + + unfilteredFeatureTemplateSections = await makeFeatureTemplateSections() + } + + /// The feature template section information for the geo model. + private func makeFeatureTemplateSections() async -> [FeatureTemplateSectionInfo] { + try? await geoModel.load() + var sections = [FeatureTemplateSectionInfo]() + let layersAndTables = geoModel.arcGISFeatureLayersAndTables + await layersAndTables.map(\.table).load() + for (layer, table) in layersAndTables { + guard includeNonCreatableFeatureTemplates || table.canAddFeature, + // If the table failed to load then makeFeature will precondition fail. + table.loadStatus == .loaded + else { continue } + var infos = [FeatureTemplateInfo]() + for template in table.allTemplates { + let feature = table.makeFeature(template: template) + let symbol = layer.renderer?.symbol(for: feature) + let scale: CGFloat +#if os(visionOS) + scale = 1 +#else + scale = UIScreen.main.scale +#endif + let image = try? await symbol?.makeSwatch(scale: scale) + infos.append( + FeatureTemplateInfo( + layer: layer, + table: table, + template: template, + image: image + ) + ) + } + sections.append( + .init(table: table, infos: infos) + ) + } + return sections.filter { !$0.infos.isEmpty } + } + + /// Re-filters based on the templates or the search text. + private func templatesOrSearchTextDidChange() { + featureTemplateSections = unfilteredFeatureTemplateSections.map { + $0.filtered(by: searchText) + } + .filter { !$0.infos.isEmpty } + showContentUnavailable = unfilteredFeatureTemplateSections.isEmpty + showNoTemplatesFound = !showContentUnavailable && featureTemplateSections.isEmpty + } + } +} + +private extension Array { + /// A flattened list of the nested layers that this array of layers may contain. + var flattened: [Layer] { + flatMap { layer in + guard let groupLayer = layer as? GroupLayer else { return [layer] } + return groupLayer.layers.flattened + } + } +} + +private extension GeoModel { + /// A list containing tuples of the feature layers and the associated + /// ArcGIS feature tables in the geo model. + var arcGISFeatureLayersAndTables: [(layer: FeatureLayer, table: ArcGISFeatureTable)] { + featureLayers + .map { (layer: $0, table: $0.featureTable) } + .compactMap { tuple in + if let table = tuple.table as? ArcGISFeatureTable { + return (layer: tuple.layer, table: table) + } else { + return nil + } + } + } + + /// All the feature layers in the geo model. + var featureLayers: [FeatureLayer] { + flattenedLayers + .compactMap { $0 as? FeatureLayer } + } + + /// A flattened list of the layers in the geo model. + var flattenedLayers: [Layer] { + layers.flattened + } + + /// All the layers in the geo model. + var layers: [Layer] { + operationalLayers + + (basemap?.baseLayers ?? []) + + (basemap?.referenceLayers ?? []) + } +} + +private extension ArcGISFeatureTable { + /// A flattened list of the feature templates in the table. + var allTemplates: [FeatureTemplate] { + let typeTemplates = featureTypes + .lazy + .flatMap { $0.templates } + return featureTemplates + typeTemplates + } +} + +/// A value that represents a section in the feature template picker. +struct FeatureTemplateSectionInfo: Identifiable { + let table: ArcGISFeatureTable + var infos: [FeatureTemplateInfo] + let id: UUID = UUID() + + func filtered(by searchText: String) -> FeatureTemplateSectionInfo { + guard !searchText.isEmpty else { return self } + var copy = self + copy.infos = copy.infos.filter { $0.template.name.localizedStandardContains(searchText) } + return copy + } +} + +/// A value that represents a feature template in the picker. +public struct FeatureTemplateInfo: Identifiable, Equatable { + public static func == (lhs: FeatureTemplateInfo, rhs: FeatureTemplateInfo) -> Bool { + lhs.template === rhs.template + } + + public let layer: FeatureLayer + public let table: ArcGISFeatureTable + public let template: FeatureTemplate + public let image: UIImage? + + public var id: ObjectIdentifier { + ObjectIdentifier(template) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/ArcGISToolkit.md b/Sources/ArcGISToolkit/Documentation.docc/ArcGISToolkit.md index afb0365ada..08458e5aeb 100644 --- a/Sources/ArcGISToolkit/Documentation.docc/ArcGISToolkit.md +++ b/Sources/ArcGISToolkit/Documentation.docc/ArcGISToolkit.md @@ -16,6 +16,7 @@ Learn how to use ArcGISToolkit with - ``Bookmarks`` - ``Compass`` - ``FeatureFormView`` +- ``FeatureTemplatePicker`` - - ``FloorFilter`` - ``JobManager`` diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePicker.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePicker.png new file mode 100644 index 0000000000..2df6aafed3 Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePicker.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep1.swift new file mode 100644 index 0000000000..0b80509aa6 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep1.swift @@ -0,0 +1,21 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct FeatureTemplatePickerExampleView: View { + /// Creates a map with a feature table that has templates. + static func makeMap() -> Map { + let map = Map(basemapStyle: .arcGISTopographic) + let featureTable = ServiceFeatureTable(url: URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/DamageAssessment/FeatureServer/0")!) + let featureLayer = FeatureLayer(featureTable: featureTable) + map.addOperationalLayer(featureLayer) + return map + } + + /// The `Map` displayed in the `MapView`. + @State private var map = makeMap() + + var body: some View { + MapView(map: map) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep2.swift new file mode 100644 index 0000000000..90cd13fae2 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep2.swift @@ -0,0 +1,34 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct FeatureTemplatePickerExampleView: View { + /// Creates a map with a feature table that has templates. + static func makeMap() -> Map { + let map = Map(basemapStyle: .arcGISTopographic) + let featureTable = ServiceFeatureTable(url: URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/DamageAssessment/FeatureServer/0")!) + let featureLayer = FeatureLayer(featureTable: featureTable) + map.addOperationalLayer(featureLayer) + return map + } + + /// The `Map` displayed in the `MapView`. + @State private var map = makeMap() + + /// A Boolean value indicating if the feature template picker + /// is presented. + @State private var templatePickerIsPresented = false + + var body: some View { + MapView(map: map) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + templatePickerIsPresented = true + } label: { + Text("Templates") + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep3.swift new file mode 100644 index 0000000000..b90c23febb --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep3.swift @@ -0,0 +1,47 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct FeatureTemplatePickerExampleView: View { + /// Creates a map with a feature table that has templates. + static func makeMap() -> Map { + let map = Map(basemapStyle: .arcGISTopographic) + let featureTable = ServiceFeatureTable(url: URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/DamageAssessment/FeatureServer/0")!) + let featureLayer = FeatureLayer(featureTable: featureTable) + map.addOperationalLayer(featureLayer) + return map + } + + /// The `Map` displayed in the `MapView`. + @State private var map = makeMap() + + /// A Boolean value indicating if the feature template picker + /// is presented. + @State private var templatePickerIsPresented = false + + /// The selection of the feature template picker. + @State private var selection: FeatureTemplateInfo? + + var body: some View { + MapView(map: map) + .sheet(isPresented: $templatePickerIsPresented) { + NavigationStack { + FeatureTemplatePicker( + geoModel: map, + selection: $selection, + includeNonCreatableFeatureTemplates: true + ) + .navigationTitle("Feature Templates") + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + templatePickerIsPresented = true + } label: { + Text("Templates") + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep4.swift new file mode 100644 index 0000000000..8544b21200 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep4.swift @@ -0,0 +1,51 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct FeatureTemplatePickerExampleView: View { + /// Creates a map with a feature table that has templates. + static func makeMap() -> Map { + let map = Map(basemapStyle: .arcGISTopographic) + let featureTable = ServiceFeatureTable(url: URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/DamageAssessment/FeatureServer/0")!) + let featureLayer = FeatureLayer(featureTable: featureTable) + map.addOperationalLayer(featureLayer) + return map + } + + /// The `Map` displayed in the `MapView`. + @State private var map = makeMap() + + /// A Boolean value indicating if the feature template picker + /// is presented. + @State private var templatePickerIsPresented = false + + /// The selection of the feature template picker. + @State private var selection: FeatureTemplateInfo? + + var body: some View { + MapView(map: map) + .sheet(isPresented: $templatePickerIsPresented) { + NavigationStack { + FeatureTemplatePicker( + geoModel: map, + selection: $selection, + includeNonCreatableFeatureTemplates: true + ) + .onAppear { + // Reset selection when the picker appears. + selection = nil + } + .navigationTitle("Feature Templates") + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + templatePickerIsPresented = true + } label: { + Text("Templates") + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep5.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep5.swift new file mode 100644 index 0000000000..4f7058fa32 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStep5.swift @@ -0,0 +1,60 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct FeatureTemplatePickerExampleView: View { + /// Creates a map with a feature table that has templates. + static func makeMap() -> Map { + let map = Map(basemapStyle: .arcGISTopographic) + let featureTable = ServiceFeatureTable(url: URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/DamageAssessment/FeatureServer/0")!) + let featureLayer = FeatureLayer(featureTable: featureTable) + map.addOperationalLayer(featureLayer) + return map + } + + /// The `Map` displayed in the `MapView`. + @State private var map = makeMap() + + /// A Boolean value indicating if the feature template picker + /// is presented. + @State private var templatePickerIsPresented = false + + /// The selection of the feature template picker. + @State private var selection: FeatureTemplateInfo? + + var body: some View { + MapView(map: map) + .sheet(isPresented: $templatePickerIsPresented) { + NavigationStack { + FeatureTemplatePicker( + geoModel: map, + selection: $selection, + includeNonCreatableFeatureTemplates: true + ) + .onAppear { + // Reset selection when the picker appears. + selection = nil + } + .navigationTitle("Feature Templates") + } + } + .onChange(of: selection) { _ in + guard let selection else { return } + + // Dismiss the template picker upon selection. + templatePickerIsPresented = false + + // Print out selected template name. + print("\(selection.template.name) Template Selected") + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + templatePickerIsPresented = true + } label: { + Text("Templates") + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStepFinal.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStepFinal.swift new file mode 100644 index 0000000000..4f7058fa32 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FeatureTemplatePicker/FeatureTemplatePickerStepFinal.swift @@ -0,0 +1,60 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct FeatureTemplatePickerExampleView: View { + /// Creates a map with a feature table that has templates. + static func makeMap() -> Map { + let map = Map(basemapStyle: .arcGISTopographic) + let featureTable = ServiceFeatureTable(url: URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/DamageAssessment/FeatureServer/0")!) + let featureLayer = FeatureLayer(featureTable: featureTable) + map.addOperationalLayer(featureLayer) + return map + } + + /// The `Map` displayed in the `MapView`. + @State private var map = makeMap() + + /// A Boolean value indicating if the feature template picker + /// is presented. + @State private var templatePickerIsPresented = false + + /// The selection of the feature template picker. + @State private var selection: FeatureTemplateInfo? + + var body: some View { + MapView(map: map) + .sheet(isPresented: $templatePickerIsPresented) { + NavigationStack { + FeatureTemplatePicker( + geoModel: map, + selection: $selection, + includeNonCreatableFeatureTemplates: true + ) + .onAppear { + // Reset selection when the picker appears. + selection = nil + } + .navigationTitle("Feature Templates") + } + } + .onChange(of: selection) { _ in + guard let selection else { return } + + // Dismiss the template picker upon selection. + templatePickerIsPresented = false + + // Print out selected template name. + print("\(selection.template.name) Template Selected") + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + templatePickerIsPresented = true + } label: { + Text("Templates") + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/FeatureTemplatePickerTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/FeatureTemplatePickerTutorial.tutorial new file mode 100644 index 0000000000..ec43bcd177 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/FeatureTemplatePickerTutorial.tutorial @@ -0,0 +1,39 @@ +@Tutorial(time: 10) { + @Intro(title: "Feature Template Picker Tutorial") { + A `FeatureTemplatePicker` allows the user to pick feature templates from a `Map` or `Scene`. One situation this component is useful is the first step when you are allowing a user to create a new feature. + @Image(source: FeatureTemplatePicker, alt: "The FeatureTemplatePicker component") + } + + @Section(title: "Using the Feature Template Picker") { + @ContentAndMedia { + @Image(source: FeatureTemplatePicker, alt: "The FeatureTemplatePicker component") + } + + @Steps { + @Step { + Create a new view with a `MapView`. + @Code(name: "FeatureTemplatePicker.swift", file: FeatureTemplatePickerStep1.swift) + } + + @Step { + Add a button that, when tapped, will display the feature template picker. + @Code(name: "FeatureTemplatePicker.swift", file: FeatureTemplatePickerStep2.swift) + } + + @Step { + Add the sheet with the feature template picker that is presented when the button is tapped. + @Code(name: "FeatureTemplatePicker.swift", file: FeatureTemplatePickerStep3.swift) + } + + @Step { + When the sheet appears, clear the selection. + @Code(name: "FeatureTemplatePicker.swift", file: FeatureTemplatePickerStep4.swift) + } + + @Step { + When the user selects a template, dismiss the sheet and perform some action. In this case we just print the name of the template that was chosen. + @Code(name: "FeatureTemplatePicker.swift", file: FeatureTemplatePickerStep5.swift) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/ToolkitTutorials.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/ToolkitTutorials.tutorial index 6b47d4782f..0e6105ab47 100644 --- a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/ToolkitTutorials.tutorial +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/ToolkitTutorials.tutorial @@ -47,7 +47,15 @@ @Image(source: FeatureFormView, alt: "An image of the FeatureFormView component.") @TutorialReference(tutorial: "doc:FeatureFormViewTutorial") } - + + @Chapter(name: "FeatureTemplatePicker") { + A `FeatureTemplatePicker` allows the user to pick feature templates from a `Map` or `Scene`. + One situation this component is useful is the first step when you are allowing a user to + create a new feature. + @Image(source: FeatureTemplatePicker, alt: "An image of the FeatureTemplatePicker component.") + @TutorialReference(tutorial: "doc:FeatureTemplatePickerTutorial") + } + @Chapter(name: "FloatingPanel") { A `FloatingPanel` is a view that overlays a view and supplies view-related content. @Image(source: FloatingPanel, alt: "An image of the FloatingPanel component.") diff --git a/Tests/ArcGISToolkitTests/FeatureTemplatePickerTests.swift b/Tests/ArcGISToolkitTests/FeatureTemplatePickerTests.swift new file mode 100644 index 0000000000..200000a837 --- /dev/null +++ b/Tests/ArcGISToolkitTests/FeatureTemplatePickerTests.swift @@ -0,0 +1,99 @@ +// Copyright 2024 Esri +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ArcGIS +@testable import ArcGISToolkit +import Foundation +import os +import Testing + +@Suite("FeatureTemplatePicker Tests") +struct FeatureTemplatePickerTests { + @Test + @MainActor + func testGeoModelWithTemplates() async throws { + let map = makeMapWithTemplates() + try await map.load() + + let model = FeatureTemplatePicker.Model(geoModel: map, includeNonCreatableFeatureTemplates: true) + #expect(model.includeNonCreatableFeatureTemplates) + #expect(!model.isGeneratingFeatureTemplates) + #expect(!model.showContentUnavailable) + #expect(!model.showNoTemplatesFound) + + let lockedValues = OSAllocatedUnfairLock(initialState: Array()) + let subscription = model.$isGeneratingFeatureTemplates.sink { isGeneratingFeatureTemplates in + lockedValues.withLock { $0.append(isGeneratingFeatureTemplates) } + } + + #expect(model.featureTemplateSections.isEmpty) + await model.generateFeatureTemplates() + #expect(model.featureTemplateSections.count == 1) + if let section = model.featureTemplateSections.first { + #expect(section.infos.count == 5) + } + #expect(!model.showContentUnavailable) + #expect(!model.showNoTemplatesFound) + + model.searchText = "foo" + #expect(!model.showContentUnavailable) + #expect(model.showNoTemplatesFound) + + let values = lockedValues.withLock { $0 } + #expect(values == [false, true, false]) + + subscription.cancel() + } + + @Test + @MainActor + func testGeoModelNoTemplates() async throws { + let map = Map(spatialReference: .webMercator) + try await map.load() + + let model = FeatureTemplatePicker.Model(geoModel: map, includeNonCreatableFeatureTemplates: false) + #expect(!model.includeNonCreatableFeatureTemplates) + #expect(!model.isGeneratingFeatureTemplates) + #expect(!model.showContentUnavailable) + #expect(!model.showNoTemplatesFound) + + let lockedValues = OSAllocatedUnfairLock(initialState: Array()) + let subscription = model.$isGeneratingFeatureTemplates.sink { isGeneratingFeatureTemplates in + lockedValues.withLock { $0.append(isGeneratingFeatureTemplates) } + } + + #expect(model.featureTemplateSections.isEmpty) + await model.generateFeatureTemplates() + #expect(model.featureTemplateSections.isEmpty) + #expect(model.showContentUnavailable) + #expect(!model.showNoTemplatesFound) + + model.searchText = "foo" + #expect(model.showContentUnavailable) + #expect(!model.showNoTemplatesFound) + + let values = lockedValues.withLock { $0 } + #expect(values == [false, true, false]) + + subscription.cancel() + } +} + +private func makeMapWithTemplates() -> Map { + let map = Map(spatialReference: .webMercator) + let featureTable = ServiceFeatureTable(url: URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/DamageAssessment/FeatureServer/0")!) + let featureLayer = FeatureLayer(featureTable: featureTable) + map.addOperationalLayer(featureLayer) + return map +}