Skip to content

Commit d32edd9

Browse files
authored
Merge pull request #630 from Esri/Forms
Merge `FeatureFormView` into v.next
2 parents 9b4ebd7 + cd8dbfa commit d32edd9

39 files changed

+4157
-14
lines changed

Examples/Examples.xcodeproj/project.pbxproj

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
1C40F3322B46118800C00ED5 /* WorldScaleExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C40F3312B46118800C00ED5 /* WorldScaleExampleView.swift */; };
1111
1CC376D42ABA0B3700A83300 /* TableTopExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC376D32ABA0B3700A83300 /* TableTopExampleView.swift */; };
1212
4D19FCB52881C8F3002601E8 /* PopupExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D19FCB42881C8F3002601E8 /* PopupExampleView.swift */; };
13+
4D995CD72B5B01DB00AD45FE /* FeatureFormExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B615012AD76158009D19B6 /* FeatureFormExampleView.swift */; };
1314
75230DAE28614369009AF501 /* UtilityNetworkTraceExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75230DAD28614369009AF501 /* UtilityNetworkTraceExampleView.swift */; };
1415
752A4FC4294D268000A49479 /* MapDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752A4FC2294D268000A49479 /* MapDataModel.swift */; };
1516
752A4FC5294D268000A49479 /* SceneDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752A4FC3294D268000A49479 /* SceneDataModel.swift */; };
@@ -54,6 +55,7 @@
5455
752A4FC2294D268000A49479 /* MapDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataModel.swift; sourceTree = "<group>"; };
5556
752A4FC3294D268000A49479 /* SceneDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDataModel.swift; sourceTree = "<group>"; };
5657
75657E4727ABAC8400EE865B /* CompassExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassExampleView.swift; sourceTree = "<group>"; };
58+
75B615012AD76158009D19B6 /* FeatureFormExampleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFormExampleView.swift; sourceTree = "<group>"; };
5759
75C37C9127BEDBD800FC9DCE /* BookmarksExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksExampleView.swift; sourceTree = "<group>"; };
5860
75D41B2A27C6F21400624D7C /* ScalebarExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScalebarExampleView.swift; sourceTree = "<group>"; };
5961
882899FC2AB5099300A0BDC1 /* FlyoverExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlyoverExampleView.swift; sourceTree = "<group>"; };
@@ -103,6 +105,7 @@
103105
E4C389D426B8A12C002BC255 /* BasemapGalleryExampleView.swift */,
104106
75C37C9127BEDBD800FC9DCE /* BookmarksExampleView.swift */,
105107
75657E4727ABAC8400EE865B /* CompassExampleView.swift */,
108+
75B615012AD76158009D19B6 /* FeatureFormExampleView.swift */,
106109
E4AA9315276BF5ED000E6289 /* FloatingPanelExampleView.swift */,
107110
E4624A24278CE815000D2A38 /* FloorFilterExampleView.swift */,
108111
882899FC2AB5099300A0BDC1 /* FlyoverExampleView.swift */,
@@ -273,6 +276,7 @@
273276
isa = PBXSourcesBuildPhase;
274277
buildActionMask = 2147483647;
275278
files = (
279+
4D995CD72B5B01DB00AD45FE /* FeatureFormExampleView.swift in Sources */,
276280
1C40F3322B46118800C00ED5 /* WorldScaleExampleView.swift in Sources */,
277281
1CC376D42ABA0B3700A83300 /* TableTopExampleView.swift in Sources */,
278282
752A4FC4294D268000A49479 /* MapDataModel.swift in Sources */,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1520"
4+
version = "1.7">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES">
8+
<BuildActionEntries>
9+
<BuildActionEntry
10+
buildForTesting = "YES"
11+
buildForRunning = "YES"
12+
buildForProfiling = "YES"
13+
buildForArchiving = "YES"
14+
buildForAnalyzing = "YES">
15+
<BuildableReference
16+
BuildableIdentifier = "primary"
17+
BlueprintIdentifier = "E47ABE3F2652FE0900FD2FE3"
18+
BuildableName = "Toolkit Examples.app"
19+
BlueprintName = "Toolkit Examples"
20+
ReferencedContainer = "container:Examples.xcodeproj">
21+
</BuildableReference>
22+
</BuildActionEntry>
23+
</BuildActionEntries>
24+
</BuildAction>
25+
<TestAction
26+
buildConfiguration = "Debug"
27+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29+
shouldUseLaunchSchemeArgsEnv = "YES"
30+
shouldAutocreateTestPlan = "YES">
31+
</TestAction>
32+
<LaunchAction
33+
buildConfiguration = "Debug"
34+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
35+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
36+
launchStyle = "0"
37+
useCustomWorkingDirectory = "NO"
38+
ignoresPersistentStateOnLaunch = "NO"
39+
debugDocumentVersioning = "YES"
40+
debugServiceExtension = "internal"
41+
allowLocationSimulation = "YES">
42+
<BuildableProductRunnable
43+
runnableDebuggingMode = "0">
44+
<BuildableReference
45+
BuildableIdentifier = "primary"
46+
BlueprintIdentifier = "E47ABE3F2652FE0900FD2FE3"
47+
BuildableName = "Toolkit Examples.app"
48+
BlueprintName = "Toolkit Examples"
49+
ReferencedContainer = "container:Examples.xcodeproj">
50+
</BuildableReference>
51+
</BuildableProductRunnable>
52+
</LaunchAction>
53+
<ProfileAction
54+
buildConfiguration = "Release"
55+
shouldUseLaunchSchemeArgsEnv = "YES"
56+
savedToolIdentifier = ""
57+
useCustomWorkingDirectory = "NO"
58+
debugDocumentVersioning = "YES">
59+
<BuildableProductRunnable
60+
runnableDebuggingMode = "0">
61+
<BuildableReference
62+
BuildableIdentifier = "primary"
63+
BlueprintIdentifier = "E47ABE3F2652FE0900FD2FE3"
64+
BuildableName = "Toolkit Examples.app"
65+
BlueprintName = "Toolkit Examples"
66+
ReferencedContainer = "container:Examples.xcodeproj">
67+
</BuildableReference>
68+
</BuildableProductRunnable>
69+
</ProfileAction>
70+
<AnalyzeAction
71+
buildConfiguration = "Debug">
72+
</AnalyzeAction>
73+
<ArchiveAction
74+
buildConfiguration = "Release"
75+
revealArchiveInOrganizer = "YES">
76+
</ArchiveAction>
77+
</Scheme>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright 2023 Esri
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import ArcGIS
16+
import ArcGISToolkit
17+
import SwiftUI
18+
19+
struct FeatureFormExampleView: View {
20+
/// The height to present the form at.
21+
@State private var detent: FloatingPanelDetent = .full
22+
23+
/// The `Map` displayed in the `MapView`.
24+
@State private var map = Map(url: .sampleData)!
25+
26+
/// The point on the screen the user tapped on to identify a feature.
27+
@State private var identifyScreenPoint: CGPoint?
28+
29+
/// A Boolean value indicating whether the alert confirming the user's intent to cancel is displayed.
30+
@State private var isCancelConfirmationPresented = false
31+
32+
/// The validation error visibility configuration of the form.
33+
@State var validationErrorVisibility = FeatureFormView.ValidationErrorVisibility.automatic
34+
35+
/// The form view model provides a channel of communication between the form view and its host.
36+
@StateObject private var model = Model()
37+
38+
/// The height of the map view's attribution bar.
39+
@State private var attributionBarHeight: CGFloat = 0
40+
41+
var body: some View {
42+
MapViewReader { mapViewProxy in
43+
MapView(map: map)
44+
.onAttributionBarHeightChanged {
45+
attributionBarHeight = $0
46+
}
47+
.onSingleTapGesture { screenPoint, _ in
48+
if model.isFormPresented {
49+
isCancelConfirmationPresented = true
50+
} else {
51+
identifyScreenPoint = screenPoint
52+
}
53+
}
54+
.task(id: identifyScreenPoint) {
55+
if let feature = await identifyFeature(with: mapViewProxy),
56+
let formDefinition = (feature.table?.layer as? FeatureLayer)?.featureFormDefinition,
57+
let featureForm = FeatureForm(feature: feature, definition: formDefinition) {
58+
model.featureForm = featureForm
59+
}
60+
}
61+
.ignoresSafeArea(.keyboard)
62+
.floatingPanel(
63+
attributionBarHeight: attributionBarHeight,
64+
selectedDetent: $detent,
65+
horizontalAlignment: .leading,
66+
isPresented: $model.isFormPresented
67+
) {
68+
if let featureForm = model.featureForm {
69+
FeatureFormView(featureForm: featureForm)
70+
.validationErrors(validationErrorVisibility)
71+
.padding([.horizontal])
72+
}
73+
}
74+
.onChange(of: model.isFormPresented) { isFormPresented in
75+
if !isFormPresented { validationErrorVisibility = .automatic }
76+
}
77+
.alert("Discard edits", isPresented: $isCancelConfirmationPresented) {
78+
Button("Discard edits", role: .destructive) {
79+
model.discardEdits()
80+
}
81+
Button("Continue editing", role: .cancel) { }
82+
} message: {
83+
Text("Updates to this feature will be lost.")
84+
}
85+
.navigationBarBackButtonHidden(model.isFormPresented)
86+
.toolbar {
87+
// Once iOS 16.0 is the minimum supported, the two conditionals to show the
88+
// buttons can be merged and hoisted up as the root content of the toolbar.
89+
90+
ToolbarItem(placement: .navigationBarLeading) {
91+
if model.isFormPresented {
92+
Button("Cancel", role: .cancel) {
93+
isCancelConfirmationPresented = true
94+
}
95+
}
96+
}
97+
98+
ToolbarItem(placement: .navigationBarTrailing) {
99+
if model.isFormPresented {
100+
Button("Submit") {
101+
validationErrorVisibility = .visible
102+
Task {
103+
await model.submitChanges()
104+
}
105+
}
106+
}
107+
}
108+
}
109+
}
110+
}
111+
}
112+
113+
extension FeatureFormExampleView {
114+
/// Identifies features, if any, at the current screen point.
115+
/// - Parameter proxy: The proxy to use for identification.
116+
/// - Returns: The first identified feature in a layer with
117+
/// a feature form definition.
118+
func identifyFeature(with proxy: MapViewProxy) async -> ArcGISFeature? {
119+
guard let identifyScreenPoint else { return nil }
120+
let identifyResult = try? await proxy.identifyLayers(
121+
screenPoint: identifyScreenPoint,
122+
tolerance: 10
123+
)
124+
.first(where: { result in
125+
if let feature = result.geoElements.first as? ArcGISFeature,
126+
(feature.table?.layer as? FeatureLayer)?.featureFormDefinition != nil {
127+
return true
128+
} else {
129+
return false
130+
}
131+
})
132+
return identifyResult?.geoElements.first as? ArcGISFeature
133+
}
134+
}
135+
136+
private extension URL {
137+
static var sampleData: Self {
138+
.init(string: "<#URL#>")!
139+
}
140+
}
141+
142+
/// The model class for the form example view
143+
@MainActor
144+
class Model: ObservableObject {
145+
/// The feature form.
146+
@Published var featureForm: FeatureForm? {
147+
willSet {
148+
if let featureForm = newValue {
149+
featureForm.featureLayer?.selectFeature(featureForm.feature)
150+
} else if let featureForm = self.featureForm {
151+
featureForm.featureLayer?.unselectFeature(featureForm.feature)
152+
}
153+
}
154+
didSet {
155+
isFormPresented = featureForm != nil
156+
}
157+
}
158+
159+
/// A Boolean value indicating whether or not the form is displayed.
160+
@Published var isFormPresented = false
161+
162+
/// Reverts any local edits that haven't yet been saved to service geodatabase.
163+
func discardEdits() {
164+
featureForm?.discardEdits()
165+
featureForm = nil
166+
}
167+
168+
/// Submit the changes made to the form.
169+
func submitChanges() async {
170+
guard let featureForm = featureForm,
171+
let table = featureForm.feature.table as? ServiceFeatureTable,
172+
table.isEditable,
173+
let database = table.serviceGeodatabase else {
174+
print("A precondition to submit the changes wasn't met.")
175+
return
176+
}
177+
178+
guard featureForm.validationErrors.isEmpty else { return }
179+
180+
try? await table.update(featureForm.feature)
181+
182+
guard database.hasLocalEdits else {
183+
print("No submittable changes found.")
184+
return
185+
}
186+
187+
let results = try? await database.applyEdits()
188+
189+
if results?.first?.editResults.first?.didCompleteWithErrors ?? false {
190+
print("An error occurred while submitting the changes.")
191+
}
192+
193+
self.featureForm = nil
194+
}
195+
}
196+
197+
private extension FeatureForm {
198+
/// The layer to which the feature belongs.
199+
var featureLayer: FeatureLayer? {
200+
feature.table?.layer as? FeatureLayer
201+
}
202+
}

Examples/ExamplesApp/Examples.swift

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ extension ExampleList {
4949
AnyExample("Basemap Gallery", content: BasemapGalleryExampleView()),
5050
AnyExample("Bookmarks", content: BookmarksExampleView()),
5151
AnyExample("Compass", content: CompassExampleView()),
52+
AnyExample("Feature Form", content: FeatureFormExampleView()),
5253
AnyExample("Floor Filter", content: FloorFilterExampleView()),
5354
AnyExample("Overview Map", content: OverviewMapExampleView()),
5455
AnyExample("Popup", content: PopupExampleView()),

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ To use Toolkit in your project:
1414
* **[BasemapGallery](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/basemapgallery)** - Displays a collection of basemaps.
1515
* **[Bookmarks](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/bookmarks)** - Shows bookmarks, from a map, scene, or a list.
1616
* **[Compass](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/compass)** - Shows a compass direction when the map is rotated. Auto-hides when the map points north.
17+
* **[FeatureFormView](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/featureformview)** - Enables users to edit field values of a feature using pre-configured forms.
1718
* **[FloatingPanel](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/floatingpanel)** - Allows display of view-related content in a "bottom sheet".
1819
* **[FloorFilter](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/floorfilter)** - Allows filtering of floor plan data in a geo view by a site, a building in the site, or a floor in the building.
1920
* **[FlyoverSceneView](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/flyoversceneview)** - Allows you to explore a scene using your device as a window into the virtual world.

Sources/ArcGISToolkit/Components/Bookmarks/BookmarksHeader.swift

+2-6
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,8 @@ struct BookmarksHeader: View {
5252
Button {
5353
isPresented.toggle()
5454
} label: {
55-
Text(
56-
"Done",
57-
bundle: .toolkitModule,
58-
comment: "A button to close the bookmark selection menu."
59-
)
60-
.fontWeight(.semibold)
55+
Text.done
56+
.fontWeight(.semibold)
6157
}
6258
}
6359
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2023 Esri
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import SwiftUI
16+
17+
/// A circular button with a cross in the center, intended to be used to clear form inputs.
18+
struct ClearButton: View {
19+
/// The action to be performed when the button is pressed.
20+
let action: () -> Void
21+
22+
var body: some View {
23+
Button {
24+
action()
25+
} label: {
26+
Image(systemName: "xmark.circle.fill")
27+
.foregroundColor(.secondary)
28+
}
29+
.buttonStyle(.plain)
30+
.padding(2)
31+
}
32+
}

0 commit comments

Comments
 (0)