diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..a85ee760 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,63 @@ +opt_in_rules: + - anyobject_protocol + - array_init + - attributes + - block_based_kvo + - closure_end_indentation + - closure_spacing + - collection_alignment + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - convenience_type + - discouraged_direct_init + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - explicit_init + - extension_access_modifier + - fatal_error_message + - first_where + - function_default_parameter_at_end + - identical_operands + - joined_default_parameter + - legacy_random + - let_var_whitespace + - literal_expression_end_indentation + - lower_acl_than_parent + - modifier_order + - multiline_arguments + - multiline_function_chains + - multiline_parameters + - operator_usage_whitespace + - operator_whitespace + - overridden_super_call + - override_in_extension + - prohibited_super_call + - redundant_nil_coalescing + - redundant_type_annotation + - sorted_first_last + - static_operator + - toggle_bool + - trailing_closure + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xctfail_message + - yoda_condition +disabled_rules: + - file_length + - for_where + - force_cast + - function_body_length + - function_parameter_count + - identifier_name + - large_tuple + - line_length + - nesting + - todo + - trailing_whitespace + - type_body_length diff --git a/Documentation/AR/README.md b/Documentation/AR/README.md new file mode 100644 index 00000000..5841ab45 --- /dev/null +++ b/Documentation/AR/README.md @@ -0,0 +1,53 @@ +# AR + +Augmented reality experiences are designed to "augment" the physical world with virtual content that respects real world scale, position, and orientation of a device. In the case of Runtime, a SceneView displays 3D geographic data as virtual content on top of a camera feed which represents the real, physical world. + +The Augmented Reality (AR) toolkit component allows quick and easy integration of AR into your application for a wide variety of scenarios. The toolkit recognizes the following common patterns for AR:  +* **Flyover**: Flyover AR allows you to explore a scene using your device as a window into the virtual world. A typical flyover AR scenario will start with the scene’s virtual camera positioned over an area of interest. You can walk around and reorient the device to focus on specific content in the scene.  +* **Tabletop**: Scene content is anchored to a physical surface, as if it were a 3D-printed model.  +* **Real-scale**: Scene content is rendered exactly where it would be in the physical world. A camera feed is shown and GIS content is rendered on top of that feed. This is used in scenarios ranging from viewing hidden infrastructure to displaying waypoints for navigation. + +The AR toolkit component is comprised of one class: `ArcGISARView`. This is a subclass of `UIView` that contains the functionality needed to display an AR experience in your application. It uses `ARKit`, Apple's augmented reality framework to display the live camera feed and handle real world tracking and synchronization with the Runtime SDK's `AGSSceneView`. The `ArcGISARView` is responsible for starting and managing an `ARKit` session. It uses a user-provided `AGSLocationDataSource` for getting an initial GPS location and when continuous GPS tracking is required. + +### Features of the AR component + +- Allows display of the live camera feed +- Manages `ARKit` `ARSession` lifecycle +- Tracks user location and device orientation through a combination of `ARKit` and the device GPS +- Provides access to an `AGSSceneView` to display your GIS 3D data over the live camera feed +- `ARScreenToLocation` method to convert a screen point to a real-world coordinate +- Easy access to all `ARKit` and `AGSLocationDataSource` delegate methods + +### Usage + +```swift +let arView = ArcGISARView(renderVideoFeed: true) +view.addSubview(arView) +arView.translatesAutoresizingMaskIntoConstraints = false +NSLayoutConstraint.activate([ + arView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + arView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + arView.topAnchor.constraint(equalTo: view.topAnchor), + arView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + +// Create a simple scene. +arView.sceneView.scene = AGSScene(basemapType: .imagery) + +// Set a AGSCLLocationDataSource, used to get our initial real-world location. +arView.locationDataSource = AGSCLLocationDataSource() + +// Start tracking our location and device orientation +arView.startTracking(.initial) { (error) in + print("Start tracking error: \(String(describing: error))") +} + +``` + +You must also add the following entries to your application's `Info.plist` file. These are required to allow access to the camera (for the live video feed) and to allow access to location services (when using the `AGSCLLocationDataSource`): + +* Privacy – Camera Usage Description ([NSCameraUsageDescription](https://developer.apple.com/documentation/bundleresources/information_property_list/nscamerausagedescription)) +* Privacy – Location When In Use Usage Description ([NSLocationWhenInUseUsageDescription](https://developer.apple.com/documentation/bundleresources/information_property_list/nslocationwheninuseusagedescription)) + +To see it in action, try out the [Examples](../../Examples) and refer to [ARExample.swift](../../Examples/ArcGISToolkitExamples/ARExample.swift) in the project. diff --git a/Documentation/README.md b/Documentation/README.md index 077d7263..bbd6a561 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -6,3 +6,4 @@ * [Measure Toolbar](MeasureToolbar) * [Scalebar](Scalebar) * [TimeSlider](TimeSlider) +* [AR](AR) diff --git a/Examples/.swiftlint.yml b/Examples/.swiftlint.yml new file mode 120000 index 00000000..9e225e41 --- /dev/null +++ b/Examples/.swiftlint.yml @@ -0,0 +1 @@ +../.swiftlint.yml \ No newline at end of file diff --git a/Examples/ArcGISToolkitExamples.xcodeproj/project.pbxproj b/Examples/ArcGISToolkitExamples.xcodeproj/project.pbxproj index 5c878bc2..74e99c42 100644 --- a/Examples/ArcGISToolkitExamples.xcodeproj/project.pbxproj +++ b/Examples/ArcGISToolkitExamples.xcodeproj/project.pbxproj @@ -23,7 +23,13 @@ 88B689CE1E96EDF400B67FAB /* ScalebarExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B689C41E96EDF400B67FAB /* ScalebarExample.swift */; }; 88B689D11E96EDF400B67FAB /* VCListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B689C71E96EDF400B67FAB /* VCListViewController.swift */; }; 88DBC2A11FE83D6000255921 /* JobManagerExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DBC2A01FE83D6000255921 /* JobManagerExample.swift */; }; + E447A12B2267BB9500578C0B /* ARExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E447A12A2267BB9500578C0B /* ARExample.swift */; }; + E464AA9122E62DC600969DBA /* Plane.swift in Sources */ = {isa = PBXBuildFile; fileRef = E464AA9022E62DC600969DBA /* Plane.swift */; }; E46893271FEDAE29008ADA79 /* CompassExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E46893261FEDAE29008ADA79 /* CompassExample.swift */; }; + E47B16FA22F8DECC000C9C8B /* ARStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47B16F822F8DECC000C9C8B /* ARStatusViewController.swift */; }; + E47B16FB22F8DECC000C9C8B /* ARStatusViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E47B16F922F8DECC000C9C8B /* ARStatusViewController.storyboard */; }; + E47B17362304AB7D000C9C8B /* UserDirectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47B17342304AB7D000C9C8B /* UserDirectionsView.swift */; }; + E47B17372304AB7D000C9C8B /* CalibrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47B17352304AB7D000C9C8B /* CalibrationView.swift */; }; E48405751E9BE7E600927208 /* LegendExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E48405741E9BE7E600927208 /* LegendExample.swift */; }; /* End PBXBuildFile section */ @@ -83,7 +89,13 @@ 88B689C41E96EDF400B67FAB /* ScalebarExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScalebarExample.swift; sourceTree = ""; }; 88B689C71E96EDF400B67FAB /* VCListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VCListViewController.swift; sourceTree = ""; }; 88DBC2A01FE83D6000255921 /* JobManagerExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobManagerExample.swift; sourceTree = ""; }; + E447A12A2267BB9500578C0B /* ARExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ARExample.swift; sourceTree = ""; }; + E464AA9022E62DC600969DBA /* Plane.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Plane.swift; sourceTree = ""; }; E46893261FEDAE29008ADA79 /* CompassExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassExample.swift; sourceTree = ""; }; + E47B16F822F8DECC000C9C8B /* ARStatusViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ARStatusViewController.swift; sourceTree = ""; }; + E47B16F922F8DECC000C9C8B /* ARStatusViewController.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = ARStatusViewController.storyboard; sourceTree = ""; }; + E47B17342304AB7D000C9C8B /* UserDirectionsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDirectionsView.swift; sourceTree = ""; }; + E47B17352304AB7D000C9C8B /* CalibrationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationView.swift; sourceTree = ""; }; E48405741E9BE7E600927208 /* LegendExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegendExample.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -121,6 +133,7 @@ 8839043D1DF6022A001F3188 /* ArcGISToolkitExamples */ = { isa = PBXGroup; children = ( + E464AA8F22E62D5B00969DBA /* Misc */, 883904421DF6022A001F3188 /* Main.storyboard */, 883904451DF6022A001F3188 /* Assets.xcassets */, 883904471DF6022A001F3188 /* LaunchScreen.storyboard */, @@ -152,10 +165,23 @@ 2140781D209B629000FBFDCC /* TimeSliderExample.swift */, 883EA74A20741A56006D6F72 /* PopupExample.swift */, 8800656D2228577A00F76945 /* TemplatePickerExample.swift */, + E447A12A2267BB9500578C0B /* ARExample.swift */, ); name = Examples; sourceTree = ""; }; + E464AA8F22E62D5B00969DBA /* Misc */ = { + isa = PBXGroup; + children = ( + E47B17352304AB7D000C9C8B /* CalibrationView.swift */, + E47B17342304AB7D000C9C8B /* UserDirectionsView.swift */, + E47B16F922F8DECC000C9C8B /* ARStatusViewController.storyboard */, + E47B16F822F8DECC000C9C8B /* ARStatusViewController.swift */, + E464AA9022E62DC600969DBA /* Plane.swift */, + ); + path = Misc; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -168,6 +194,7 @@ 883904391DF6022A001F3188 /* Resources */, 883904531DF60296001F3188 /* Embed Frameworks */, 88AE77111EFC267A00AFC80A /* ShellScript */, + E47ED066233AC27B0032440E /* Run Linter */, ); buildRules = ( ); @@ -243,6 +270,7 @@ files = ( 883904491DF6022A001F3188 /* LaunchScreen.storyboard in Resources */, 883904461DF6022A001F3188 /* Assets.xcassets in Resources */, + E47B16FB22F8DECC000C9C8B /* ARStatusViewController.storyboard in Resources */, 883904441DF6022A001F3188 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -261,7 +289,25 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "bash \"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/ArcGIS.framework/strip-frameworks.sh\""; + shellScript = "bash \"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/ArcGIS.framework/strip-frameworks.sh\"\n"; + }; + E47ED066233AC27B0032440E /* Run Linter */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Linter"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which swiftlint >/dev/null; then\nswiftlint\nelse\necho \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -273,12 +319,17 @@ 883EA74B20741A56006D6F72 /* PopupExample.swift in Sources */, 88B689C81E96EDF400B67FAB /* AppDelegate.swift in Sources */, 2140781E209B629000FBFDCC /* TimeSliderExample.swift in Sources */, + E47B17372304AB7D000C9C8B /* CalibrationView.swift in Sources */, + E464AA9122E62DC600969DBA /* Plane.swift in Sources */, 88B689CB1E96EDF400B67FAB /* MeasureExample.swift in Sources */, 88DBC2A11FE83D6000255921 /* JobManagerExample.swift in Sources */, 88B689D11E96EDF400B67FAB /* VCListViewController.swift in Sources */, + E47B16FA22F8DECC000C9C8B /* ARStatusViewController.swift in Sources */, 8800656E2228577A00F76945 /* TemplatePickerExample.swift in Sources */, + E47B17362304AB7D000C9C8B /* UserDirectionsView.swift in Sources */, 88B689CE1E96EDF400B67FAB /* ScalebarExample.swift in Sources */, 88B689C91E96EDF400B67FAB /* ExamplesViewController.swift in Sources */, + E447A12B2267BB9500578C0B /* ARExample.swift in Sources */, E48405751E9BE7E600927208 /* LegendExample.swift in Sources */, E46893271FEDAE29008ADA79 /* CompassExample.swift in Sources */, ); diff --git a/Examples/ArcGISToolkitExamples/ARExample.swift b/Examples/ArcGISToolkitExamples/ARExample.swift new file mode 100644 index 00000000..7d0483c1 --- /dev/null +++ b/Examples/ArcGISToolkitExamples/ARExample.swift @@ -0,0 +1,705 @@ +// Copyright 2019 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 +// http://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 UIKit +import ARKit +import ArcGISToolkit +import ArcGIS + +class ARExample: UIViewController { + typealias SceneInitFunction = () -> AGSScene + typealias SceneInfoType = (sceneFunction: SceneInitFunction, label: String, tableTop: Bool, trackingMode: ARLocationTrackingMode) + + /// The scene creation functions plus labels and whether it represents a table top experience. The functions create a new scene and perform any necessary `ArcGISARView` initialization. This allows for changing the scene and AR "mode" (table top or full-scale). + private var sceneInfo: [SceneInfoType] = [] + + /// The current scene info. + private var currentSceneInfo: SceneInfoType? { + didSet { + guard let label = currentSceneInfo?.label else { return } + statusViewController?.currentScene = label + } + } + + /// The `ArcGISARView` that displays the camera feed and handles ARKit functionality. + private let arView = ArcGISARView(renderVideoFeed: true) + + /// Denotes whether we've placed the scene in table top experiences. + private var didPlaceScene: Bool = false + + // View controller displaying current status of `ARExample`. + private let statusViewController: ARStatusViewController? = { + let storyBoard = UIStoryboard(name: "ARStatusViewController", bundle: nil) + let vc = storyBoard.instantiateInitialViewController() as? ARStatusViewController + return vc + }() + + /// Used when calculating framerate. + private var lastUpdateTime: TimeInterval = 0 + + /// Overlay used to display user-placed graphics. + private let graphicsOverlay: AGSGraphicsOverlay = { + let overlay = AGSGraphicsOverlay() + overlay.sceneProperties = AGSLayerSceneProperties(surfacePlacement: .absolute) + return overlay + }() + + /// View for displaying directions to the user. + private let userDirectionsView = UserDirectionsView(effect: UIBlurEffect(style: .light)) + + /// The observation for the `SceneView`'s `translationFactor` property. + private var translationFactorObservation: NSKeyValueObservation? + + /// View for displaying calibration controls to the user. + private var calibrationView: CalibrationView? + + /// The toolbar used to display controls for calibration, changing scenes, and status. + private var toolbar = UIToolbar(frame: .zero) + + /// Button used to display the `CalibrationView`. + private let calibrationItem = UIBarButtonItem(title: "Calibration", style: .plain, target: self, action: #selector(displayCalibration(_:))) + + /// Button used to change the current scene. + private let sceneItem = UIBarButtonItem(title: "Change Scene", style: .plain, target: self, action: #selector(changeScene(_:))) + + // MARK: Initialization + + override func viewDidLoad() { + super.viewDidLoad() + + // Set ourself as delegate so we can get ARSCNViewDelegate method calls. + arView.arSCNViewDelegate = self + + // Set ourself as touch delegate so we can get touch events. + arView.sceneView.touchDelegate = self + + // Set ourself as location change delegate so we can get location data source events. + arView.locationChangeHandlerDelegate = self + + // Disble user interactions on the sceneView. + arView.sceneView.interactionOptions.isEnabled = false + + // Set ourself as the ARKit session delegate. + arView.arSCNView.session.delegate = self + + // Add our graphics overlay to the sceneView. + arView.sceneView.graphicsOverlays.add(graphicsOverlay) + + // Observe the `arView.translationFactor` property and update status when it changes. + translationFactorObservation = arView.observe(\ArcGISARView.translationFactor, options: [.initial, .new]) { [weak self] arView, _ in + self?.statusViewController?.translationFactor = arView.translationFactor + } + + // Add arView to the view and setup the constraints. + view.addSubview(arView) + arView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + arView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + arView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + arView.topAnchor.constraint(equalTo: view.topAnchor), + arView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + // Add a Toolbar for displaying user controls. + addToolbar() + + // Add the status view and setup constraints. + addStatusViewController() + + // Add the UserDirectionsView. + addUserDirectionsView() + + // Create the CalibrationView. + calibrationView = CalibrationView(arView) + calibrationView?.alpha = 0.0 + + // Set up the `sceneInfo` array with our scene init functions and labels. + sceneInfo.append(contentsOf: [(sceneFunction: streetsScene, label: "Streets - Full Scale", tableTop: false, trackingMode: .continuous), + (sceneFunction: imageryScene, label: "Imagery - Full Scale", tableTop: false, trackingMode: .initial), + (sceneFunction: pointCloudScene, label: "Point Cloud - Tabletop", tableTop: true, trackingMode: .ignore), + (sceneFunction: yosemiteScene, label: "Yosemite - Tabletop", tableTop: true, trackingMode: .ignore), + (sceneFunction: borderScene, label: "US - Mexico Border - Tabletop", tableTop: true, trackingMode: .ignore), + (sceneFunction: emptyScene, label: "Empty - Full Scale", tableTop: false, trackingMode: .initial)]) + + // Use the first sceneInfo to create and set the scene. + if let info = sceneInfo.first { + selectSceneInfo(info) + } + + // Debug options for showing world origin and point cloud scene analysis points. +// arView.arSCNView.debugOptions = [.showWorldOrigin, .showFeaturePoints] + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + arView.startTracking(currentSceneInfo?.trackingMode ?? .ignore) { [weak self] (error) in + self?.statusViewController?.errorMessage = error?.localizedDescription + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + arView.stopTracking() + } + + // MARK: Toolbar button actions + + /// Initialize scene location/heading/elevation calibration. + /// + /// - Parameter sender: The bar button item tapped on. + @objc + func displayCalibration(_ sender: UIBarButtonItem) { + // If the sceneView's alpha is 0.0, that means we are not in calibration mode and we need to start calibrating. + let startCalibrating = (calibrationView?.alpha == 0.0) + + // Enable/disable sceneView touch interactions. + arView.sceneView.interactionOptions.isEnabled = startCalibrating + userDirectionsView.updateUserDirections(nil) + + // Display calibration view. + UIView.animate(withDuration: 0.25, + animations: { + if startCalibrating { + self.arView.sceneView.isAttributionTextVisible = false + self.addCalibrationView() + } + self.calibrationView?.alpha = startCalibrating ? 1.0 : 0.0 + }, + completion: { (_) in + if !startCalibrating { + self.removeCalibrationView() + self.arView.sceneView.isAttributionTextVisible = true + } + }) + + // Dim the sceneView if we're calibrating. + UIView.animate(withDuration: 0.25) { [weak self] in + self?.arView.sceneView.alpha = startCalibrating ? 0.65 : 1.0 + } + + // Hide directions view if we're calibrating. + userDirectionsView.isHidden = startCalibrating + + // Disable changing scenes if we're calibrating. + sceneItem.isEnabled = !startCalibrating + } + + /// Sets up the required functionality in order to display the scene and AR experience represented by `sceneInfo`. + /// + /// - Parameter sceneInfo: The sceneInfo used to set up the scene and AR experience. + fileprivate func selectSceneInfo(_ sceneInfo: SceneInfoType) { + title = sceneInfo.label + + // Set currentSceneInfo to the selected scene info. + currentSceneInfo = sceneInfo + + // Stop tracking, update the scene with the selected Scene and reset tracking. + arView.stopTracking() + arView.sceneView.scene = sceneInfo.sceneFunction() + if sceneInfo.tableTop { + // Dim the SceneView until the user taps on a surface. + arView.sceneView.alpha = 0.5 + } + + // Reset AR tracking and then start tracking. + arView.resetTracking() + arView.startTracking(sceneInfo.trackingMode) { [weak self] (error) in + self?.statusViewController?.errorMessage = error?.localizedDescription + } + + // Disable elevation control if we're using continuous GPS. + calibrationView?.elevationControlVisibility = (sceneInfo.trackingMode != .continuous) + + // Disable calibration if we're in table top + calibrationItem.isEnabled = !sceneInfo.tableTop + + // Reset didPlaceScene variable + didPlaceScene = false + } + + /// Allow users to change the current scene. + /// + /// - Parameter sender: The bar button item tapped on. + @objc + func changeScene(_ sender: UIBarButtonItem) { + // Display an alert controller displaying the scenes to choose from. + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: UIAlertController.Style.actionSheet) + alertController.popoverPresentationController?.barButtonItem = sender + + // Loop through all sceneInfos and add `UIAlertActions` for each. + sceneInfo.forEach { info in + let action = UIAlertAction(title: info.label, style: .default) { [weak self] (_) in + self?.selectSceneInfo(info) + } + + // Display current scene as disabled. + action.isEnabled = (info.label != currentSceneInfo?.label) + alertController.addAction(action) + } + + // Add "cancel" action. + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + + present(alertController, animated: true) + } + + /// Dislays the status view controller. + /// + /// - Parameter sender: The bar button item tapped on. + @objc + func showStatus(_ sender: UIBarButtonItem) { + UIView.animate(withDuration: 0.25) { [weak self] in + self?.statusViewController?.view.alpha = self?.statusViewController?.view.alpha == 1.0 ? 0.0 : 1.0 + } + } + + /// Sets up the toolbar and add it to the view. + private func addToolbar() { + // Add it to the arView and set up constraints. + view.addSubview(toolbar) + toolbar.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + toolbar.bottomAnchor.constraint(equalTo: arView.sceneView.attributionTopAnchor) + ]) + + // Create a toolbar button to display the status. + let statusItem = UIBarButtonItem(title: "Status", style: .plain, target: self, action: #selector(showStatus(_:))) + + toolbar.setItems([calibrationItem, + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + sceneItem, + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + statusItem], animated: false) + } + + /// Set up the status view controller and adds it to the view. + private func addStatusViewController() { + if let statusVC = statusViewController { + addChild(statusVC) + view.addSubview(statusVC.view) + statusVC.didMove(toParent: self) + statusVC.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + statusVC.view.heightAnchor.constraint(equalToConstant: 176), + statusVC.view.widthAnchor.constraint(equalToConstant: 350), + statusVC.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -8), + statusVC.view.bottomAnchor.constraint(equalTo: toolbar.topAnchor, constant: -8) + ]) + + statusVC.view.alpha = 0.0 + } + } +} + +// MARK: ARSCNViewDelegate +extension ARExample: ARSCNViewDelegate { + func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) { + // Place content only for anchors found by plane detection. + guard let planeAnchor = anchor as? ARPlaneAnchor else { return } + + // Create a custom object to visualize the plane geometry and extent. + let plane = Plane(anchor: planeAnchor, in: arView.arSCNView) + + // Add the visualization to the ARKit-managed node so that it tracks + // changes in the plane anchor as plane estimation continues. + node.addChildNode(plane) + } + + func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) { + // Update only anchors and nodes set up by `renderer(_:didAdd:for:)`. + guard let planeAnchor = anchor as? ARPlaneAnchor, + let plane = node.childNodes.first as? Plane + else { return } + + // Update extent visualization to the anchor's new bounding rectangle. + if let extentGeometry = plane.node.geometry as? SCNPlane { + extentGeometry.width = CGFloat(planeAnchor.extent.x) + extentGeometry.height = CGFloat(planeAnchor.extent.z) + plane.node.simdPosition = planeAnchor.center + } + } + + func session(_ session: ARSession, didFailWithError error: Error) { + guard error is ARError else { return } + + let errorWithInfo = error as NSError + let messages = [ + errorWithInfo.localizedDescription, + errorWithInfo.localizedFailureReason, + errorWithInfo.localizedRecoverySuggestion + ] + + // Remove optional error messages. + let errorMessage = messages.compactMap { $0 }.joined(separator: "\n") + + // Set the error message on the status vc. + statusViewController?.errorMessage = errorMessage + + DispatchQueue.main.async { [weak self] in + // Present an alert describing the error. + let alertController = UIAlertController(title: "Could not start tracking.", message: errorMessage, preferredStyle: .alert) + let restartAction = UIAlertAction(title: "Restart Tracking", style: .default) { _ in + self?.arView.startTracking(self?.currentSceneInfo?.trackingMode ?? .ignore) { (error) in + self?.statusViewController?.errorMessage = error?.localizedDescription + } + } + alertController.addAction(restartAction) + + self?.present(alertController, animated: true) + } + } + + func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { + // Set the tracking state on the status vc. + statusViewController?.trackingState = camera.trackingState + updateUserDirections(session.currentFrame!, trackingState: camera.trackingState) + } + + func renderer(_ renderer: SCNSceneRenderer, didRenderScene scene: SCNScene, atTime time: TimeInterval) { + // Calculate frame rate and set on the statuc vc. + let frametime = time - lastUpdateTime + statusViewController?.frameRate = Int((1.0 / frametime).rounded()) + lastUpdateTime = time + } +} + +// MARK: ARSessionDelegate +extension ARExample: ARSessionDelegate { + func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { + guard let frame = session.currentFrame else { return } + updateUserDirections(frame, trackingState: frame.camera.trackingState) + } + + func session(_ session: ARSession, didRemove anchors: [ARAnchor]) { + guard let frame = session.currentFrame else { return } + updateUserDirections(frame, trackingState: frame.camera.trackingState) + } +} + +// MARK: AGSGeoViewTouchDelegate +extension ARExample: AGSGeoViewTouchDelegate { + public func geoView(_ geoView: AGSGeoView, didTapAtScreenPoint screenPoint: CGPoint, mapPoint: AGSPoint) { + if let sceneInfo = currentSceneInfo, sceneInfo.tableTop { + // We're in table-top mode and haven't placed the scene yet. Place the scene at the given point by setting the initial transformation. + if arView.setInitialTransformation(using: screenPoint) { + // Show the SceneView now that the user has tapped on the surface. + UIView.animate(withDuration: 0.5) { [weak self] in + self?.arView.sceneView.alpha = 1.0 + } + + // Clear the user directions. + userDirectionsView.updateUserDirections(nil) + didPlaceScene = true + } + } else { + // We're in full-scale AR mode or have already placed the scene. Get the real world location for screen point from arView. + guard let point = arView.arScreenToLocation(screenPoint: screenPoint) else { return } + + // Create and place a graphic and shadown at the real world location. + let shadowColor = UIColor.lightGray.withAlphaComponent(0.5) + let shadow = AGSSimpleMarkerSceneSymbol(style: .sphere, color: shadowColor, height: 0.01, width: 0.25, depth: 0.25, anchorPosition: .center) + let shadowGraphic = AGSGraphic(geometry: point, symbol: shadow) + graphicsOverlay.graphics.add(shadowGraphic) + + let sphere = AGSSimpleMarkerSceneSymbol(style: .sphere, color: .red, height: 0.25, width: 0.25, depth: 0.25, anchorPosition: .bottom) + let sphereGraphic = AGSGraphic(geometry: point, symbol: sphere) + graphicsOverlay.graphics.add(sphereGraphic) + } + } +} + +// MARK: User Directions View +extension ARExample { + /// Add user directions view to view and setup constraints. + func addUserDirectionsView() { + view.addSubview(userDirectionsView) + userDirectionsView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + userDirectionsView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + userDirectionsView.topAnchor.constraint(equalToSystemSpacingBelow: view.safeAreaLayoutGuide.topAnchor, multiplier: 1) + ]) + } + + /// Update the displayed message in the user directions view for the current frame and tracking state. + /// + /// - Parameters: + /// - frame: The current ARKit frame. + /// - trackingState: The current ARKit tracking state. + private func updateUserDirections(_ frame: ARFrame, trackingState: ARCamera.TrackingState) { + var message = "" + + switch trackingState { + case .normal: + if let sceneInfo = currentSceneInfo, sceneInfo.tableTop, !didPlaceScene { + if frame.anchors.isEmpty { + message = "Move the device around to detect horizontal surfaces." + } else { + message = "Tap to place the Scene on a surface." + } + } + case .notAvailable: + message = "Location not available." + case .limited(let reason): + switch reason { + case .excessiveMotion: + message = "Try moving your device more slowly." + case .initializing: + // Because ARKit gets reset often when using continuous GPS, only dipslay initializing message if we're not in continuous tracking mode. + if let sceneInfo = currentSceneInfo, sceneInfo.trackingMode != .continuous { + message = "Keep moving your device." + } else { + message = "" + } + case .insufficientFeatures: + message = "Try turning on more lights and moving around." + default: + break + } + } + + userDirectionsView.updateUserDirections(message) + } +} + +// MARK: Calibration View +extension ARExample { + /// Add the calibration view to the view and setup constraints. + func addCalibrationView() { + guard let calibrationView = calibrationView else { return } + view.addSubview(calibrationView) + calibrationView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + calibrationView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + calibrationView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + calibrationView.topAnchor.constraint(equalTo: view.topAnchor), + calibrationView.bottomAnchor.constraint(equalTo: toolbar.topAnchor) + ]) + } + + /// Add the calibration view to the view and setup constraints. + func removeCalibrationView() { + guard let calibrationView = calibrationView else { return } + calibrationView.removeFromSuperview() + } +} + +// MARK: Scene creation methods +extension ARExample { + // + // These methods create the scenes and perform other intitialization required to set up the AR experiences. + // + + /// Creates a scene based on the Streets base map. + /// Mode: Full-Scale AR + /// + /// - Returns: The new scene. + private func streetsScene() -> AGSScene { + // Create scene with the streets basemap. + let scene = AGSScene(basemapType: .streets) + scene.addElevationSource() + + // Set the location data source so we use our GPS location as the originCamera. + arView.locationDataSource = AGSCLLocationDataSource() + arView.translationFactor = 1 + return scene + } + + /// Creates a scene based on the ImageryWithLabels base map. + /// Mode: Full-Scale AR + /// + /// - Returns: The new scene. + private func imageryScene() -> AGSScene { + // Create scene with the streets basemap. + let scene = AGSScene(basemapType: .imageryWithLabels) + scene.addElevationSource() + + // Set the location data source so we use our GPS location as the originCamera. + arView.locationDataSource = AGSCLLocationDataSource() + arView.translationFactor = 1 + return scene + } + + /// Creates a scene based on a point cloud layer. + /// Mode: Tabletop AR + /// + /// - Returns: The new scene. + private func pointCloudScene() -> AGSScene { + // Create scene using a portalItem of the point cloud layer. + let portal = AGSPortal.arcGISOnline(withLoginRequired: false) + let portalItem = AGSPortalItem(portal: portal, itemID: "fc3f4a4919394808830cd11df4631a54") + let layer = AGSPointCloudLayer(item: portalItem) + let scene = AGSScene() + scene.addElevationSource() + scene.operationalLayers.add(layer) + + layer.load { [weak self] (error) in + self?.statusViewController?.errorMessage = error?.localizedDescription + if let extent = layer.fullExtent, error == nil { + let center = extent.center + + // Create the origin camera at the center point of the data. This will ensure the data is anchored to the table. + let camera = AGSCamera(latitude: center.y, longitude: center.x, altitude: 0, heading: 0, pitch: 90.0, roll: 0) + self?.arView.originCamera = camera + self?.arView.translationFactor = 2000 + } + } + + // Clear the location data source, as we're setting the originCamera directly. + arView.locationDataSource = nil + return scene + } + + /// Creates a scene centered on Yosemite National Park. + /// Mode: Tabletop AR + /// + /// - Returns: The new scene. + private func yosemiteScene() -> AGSScene { + let scene = AGSScene() + scene.addElevationSource() + + // Create the Yosemite layer. + let layer = AGSIntegratedMeshLayer(url: URL(string: "https://tiles.arcgis.com/tiles/FQD0rKU8X5sAQfh8/arcgis/rest/services/VRICON_Yosemite_Sample_Integrated_Mesh_scene_layer/SceneServer")!) + scene.operationalLayers.add(layer) + scene.load { [weak self, weak scene] (error) in + self?.statusViewController?.errorMessage = error?.localizedDescription + if error != nil { + return + } + + // Get the center point of the layer's extent. + guard let layer = scene?.operationalLayers.firstObject as? AGSLayer else { return } + guard let extent = layer.fullExtent else { return } + let center = extent.center + + scene?.baseSurface?.elevationSources.first?.load { (error) in + self?.statusViewController?.errorMessage = error?.localizedDescription + if error != nil { + return + } + + // Find the elevation of the layer at the center point. + scene?.baseSurface?.elevation(for: center) { (elevation, error) in + self?.statusViewController?.errorMessage = error?.localizedDescription + if error != nil { + return + } + + // Create the origin camera at the center point and elevation of the data. This will ensure the data is anchored to the table. + let camera = AGSCamera(latitude: center.y, longitude: center.x, altitude: elevation, heading: 0, pitch: 90, roll: 0) + self?.arView.originCamera = camera + self?.arView.translationFactor = 18000 + } + } + } + + // Clear the location data source, as we're setting the originCamera directly. + arView.locationDataSource = nil + return scene + } + + /// Creates a scene centered the US-Mexico border. + /// Mode: Tabletop AR + /// + /// - Returns: The new scene. + private func borderScene() -> AGSScene { + let scene = AGSScene() + scene.addElevationSource() + + // Create the border layer. + let layer = AGSIntegratedMeshLayer(url: URL(string: "https://tiles.arcgis.com/tiles/FQD0rKU8X5sAQfh8/arcgis/rest/services/VRICON_SW_US_Sample_Integrated_Mesh_scene_layer/SceneServer")!) + scene.operationalLayers.add(layer) + scene.load { [weak self, weak scene] (error) in + self?.statusViewController?.errorMessage = error?.localizedDescription + if error != nil { + return + } + + // Get the center point of the layer's extent. + guard let layer = scene?.operationalLayers.firstObject as? AGSLayer else { return } + guard let extent = layer.fullExtent else { return } + let center = extent.center + + scene?.baseSurface?.elevationSources.first?.load { (error) in + self?.statusViewController?.errorMessage = error?.localizedDescription + if error != nil { + return + } + + // Find the elevation of the layer at the center point. + scene?.baseSurface?.elevation(for: center) { (elevation, error) in + self?.statusViewController?.errorMessage = error?.localizedDescription + if error != nil { + return + } + + // Create the origin camera at the center point and elevation of the data. This will ensure the data is anchored to the table. + let camera = AGSCamera(latitude: center.y, longitude: center.x, altitude: elevation, heading: 0, pitch: 90.0, roll: 0) + self?.arView.originCamera = camera + self?.arView.translationFactor = 1000 + } + } + } + + // Clear the location data source, as we're setting the originCamera directly. + arView.locationDataSource = nil + return scene + } + + /// Creates an empty scene with an elevation source. + /// Mode: Full-Scale AR + /// + /// - Returns: The new scene. + private func emptyScene() -> AGSScene { + let scene = AGSScene() + scene.addElevationSource() + + // Set the location data source so we use our GPS location as the originCamera. + arView.locationDataSource = AGSCLLocationDataSource() + arView.translationFactor = 1 + return scene + } +} + +// MARK: AGSScene extension. +extension AGSScene { + /// Adds an elevation source to the given `scene`. + /// + /// - Parameter scene: The scene to add the elevation source to. + func addElevationSource() { + let elevationSource = AGSArcGISTiledElevationSource(url: URL(string: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer")!) + let surface = AGSSurface() + surface.elevationSources = [elevationSource] + surface.name = "baseSurface" + surface.isEnabled = true + surface.backgroundGrid.isVisible = false + surface.navigationConstraint = .none + baseSurface = surface + } +} + +// MARK: AGSLocationChangeHandlerDelegate methods +extension ARExample: AGSLocationChangeHandlerDelegate { + func locationDataSource(_ locationDataSource: AGSLocationDataSource, locationDidChange location: AGSLocation) { + // When we get a new location, update the status view controller with the new horizontal and vertical accuracy. + statusViewController?.horizontalAccuracyMeasurement.value = location.horizontalAccuracy + statusViewController?.verticalAccuracyMeasurement.value = location.verticalAccuracy + } + + func locationDataSource(_ locationDataSource: AGSLocationDataSource, statusDidChange status: AGSLocationDataSourceStatus) { + // Update the data source status. + statusViewController?.locationDataSourceStatus = status + } +} diff --git a/Examples/ArcGISToolkitExamples/AppDelegate.swift b/Examples/ArcGISToolkitExamples/AppDelegate.swift index 006d7a72..9dce979a 100644 --- a/Examples/ArcGISToolkitExamples/AppDelegate.swift +++ b/Examples/ArcGISToolkitExamples/AppDelegate.swift @@ -16,10 +16,8 @@ import ArcGISToolkit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { // Override point for customization after application launch. return true @@ -50,9 +48,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Here is where we forward background fetch to the JobManager // so that jobs can be updated in the background func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - JobManager.shared.application(application: application, performFetchWithCompletionHandler: completionHandler) } - } - diff --git a/Examples/ArcGISToolkitExamples/Base.lproj/Main.storyboard b/Examples/ArcGISToolkitExamples/Base.lproj/Main.storyboard index d658c310..9a9afb56 100644 --- a/Examples/ArcGISToolkitExamples/Base.lproj/Main.storyboard +++ b/Examples/ArcGISToolkitExamples/Base.lproj/Main.storyboard @@ -1,47 +1,58 @@ - + - + + - - - - - - - - - - - - - - - - - - - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/ArcGISToolkitExamples/CompassExample.swift b/Examples/ArcGISToolkitExamples/CompassExample.swift index b3cfe8a9..bcd0725b 100644 --- a/Examples/ArcGISToolkitExamples/CompassExample.swift +++ b/Examples/ArcGISToolkitExamples/CompassExample.swift @@ -16,7 +16,6 @@ import ArcGISToolkit import ArcGIS class CompassExample: MapViewController { - var map: AGSMap? override func viewDidLoad() { @@ -39,5 +38,4 @@ class CompassExample: MapViewController { compass.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12.0).isActive = true compass.trailingAnchor.constraint(equalTo: margins.trailingAnchor).isActive = true } - } diff --git a/Examples/ArcGISToolkitExamples/ExamplesViewController.swift b/Examples/ArcGISToolkitExamples/ExamplesViewController.swift index cd7530d6..c8b9a357 100644 --- a/Examples/ArcGISToolkitExamples/ExamplesViewController.swift +++ b/Examples/ArcGISToolkitExamples/ExamplesViewController.swift @@ -15,7 +15,6 @@ import UIKit import ArcGISToolkit class ExamplesViewController: VCListViewController { - override func viewDidLoad() { super.viewDidLoad() @@ -29,9 +28,8 @@ class ExamplesViewController: VCListViewController { ("Job Manager", JobManagerExample.self, nil), ("Time Slider", TimeSliderExample.self, nil), ("Popup Controller", PopupExample.self, nil), - ("Template Picker", TemplatePickerExample.self, nil) + ("Template Picker", TemplatePickerExample.self, nil), + ("AR", ARExample.self, nil) ] - } - } diff --git a/Examples/ArcGISToolkitExamples/Info.plist b/Examples/ArcGISToolkitExamples/Info.plist index 24d7c13d..39f45da8 100644 --- a/Examples/ArcGISToolkitExamples/Info.plist +++ b/Examples/ArcGISToolkitExamples/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 100.5 + 100.6 CFBundleVersion 1 LSRequiresIPhoneOS @@ -27,6 +27,8 @@ NSCameraUsageDescription For collecting attachments in popups + NSLocationWhenInUseUsageDescription + For showing the current location in a map NSPhotoLibraryUsageDescription For collecting attachments in popups UIBackgroundModes diff --git a/Examples/ArcGISToolkitExamples/JobManagerExample.swift b/Examples/ArcGISToolkitExamples/JobManagerExample.swift index 7399e503..0ae6df04 100644 --- a/Examples/ArcGISToolkitExamples/JobManagerExample.swift +++ b/Examples/ArcGISToolkitExamples/JobManagerExample.swift @@ -30,8 +30,7 @@ import UserNotifications // We forward that call to the shared JobManager so that it can perform the background fetch. // -class JobTableViewCell: UITableViewCell{ - +class JobTableViewCell: UITableViewCell { var job: AGSJob? var statusObservation: NSKeyValueObservation? @@ -43,8 +42,7 @@ class JobTableViewCell: UITableViewCell{ fatalError("init(coder:) has not been implemented") } - func configureWithJob(job: AGSJob?){ - + func configureWithJob(job: AGSJob?) { // invalidate previous observation statusObservation?.invalidate() statusObservation = nil @@ -54,16 +52,15 @@ class JobTableViewCell: UITableViewCell{ self.updateUI() // observe job status - statusObservation = self.job?.observe(\.status, options: .new) { [weak self] (job, changes) in + statusObservation = self.job?.observe(\.status, options: .new) { [weak self] (_, _) in DispatchQueue.main.async { self?.updateUI() } } } - func updateUI(){ - - guard let job = job else{ + func updateUI() { + guard let job = job else { return } @@ -74,46 +71,36 @@ class JobTableViewCell: UITableViewCell{ } override func prepareForReuse() { + super.prepareForReuse() self.textLabel?.text = "" self.detailTextLabel?.text = "" } - - class func jobTypeString(_ job: AGSJob)->String{ - if job is AGSGenerateGeodatabaseJob{ + class func jobTypeString(_ job: AGSJob) -> String { + if job is AGSGenerateGeodatabaseJob { return "Generate GDB" - } - else if job is AGSSyncGeodatabaseJob{ + } else if job is AGSSyncGeodatabaseJob { return "Sync GDB" - } - else if job is AGSExportTileCacheJob{ + } else if job is AGSExportTileCacheJob { return "Export Tiles" - } - else if job is AGSEstimateTileCacheSizeJob{ + } else if job is AGSEstimateTileCacheSizeJob { return "Estimate Tile Cache Size" - } - else if job is AGSGenerateOfflineMapJob{ + } else if job is AGSGenerateOfflineMapJob { return "Offline Map" - } - else if job is AGSOfflineMapSyncJob{ + } else if job is AGSOfflineMapSyncJob { return "Offline Map Sync" - } - else if job is AGSGeoprocessingJob{ + } else if job is AGSGeoprocessingJob { return "Geoprocessing" - } - else if job is AGSExportVectorTilesJob{ + } else if job is AGSExportVectorTilesJob { return "Export Vector Tiles" - } - else if job is AGSDownloadPreplannedOfflineMapJob{ + } else if job is AGSDownloadPreplannedOfflineMapJob { return "Download Preplanned Offline Map" } return "Other" } - } class JobManagerExample: TableViewController { - // array to hold onto tasks while they are loading var tasks = [AGSGeodatabaseSyncTask]() @@ -123,7 +110,7 @@ class JobManagerExample: TableViewController { var toolbar: UIToolbar? - override open func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() // create a Toolbar and add it to the view controller @@ -158,7 +145,7 @@ class JobManagerExample: TableViewController { // request authorization for user notifications, this way we can notify user in bg when job complete let center = UNUserNotificationCenter.current() center.requestAuthorization(options: [.alert, .badge, .sound]) { (granted, _) in - if !granted{ + if !granted { print("You must grant access for user notifications for all the features of this sample to work") } } @@ -167,26 +154,26 @@ class JobManagerExample: TableViewController { tableView.register(JobTableViewCell.self, forCellReuseIdentifier: "JobCell") } - @objc func resumeAllPausedJobs(){ + @objc + func resumeAllPausedJobs() { JobManager.shared.resumeAllPausedJobs(statusHandler: self.jobStatusHandler, completion: self.jobCompletionHandler) } - @objc func clearFinishedJobs(){ + @objc + func clearFinishedJobs() { JobManager.shared.clearFinishedJobs() tableView.reloadData() } var i = 0 - @objc func kickOffJob(){ - - if (i % 2) == 0{ + @objc + func kickOffJob() { + if (i % 2) == 0 { let url = URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Sync/WildfireSync/FeatureServer")! generateGDB(URL: url, syncModel: .layer, extent: nil) - } - else{ - - let portalItem = AGSPortalItem(url: URL(string:"https://www.arcgis.com/home/item.html?id=acc027394bc84c2fb04d1ed317aac674")!)! + } else { + let portalItem = AGSPortalItem(url: URL(string: "https://www.arcgis.com/home/item.html?id=acc027394bc84c2fb04d1ed317aac674")!)! let map = AGSMap(item: portalItem) // naperville let env = AGSEnvelope(xMin: -9825684.031125, yMin: 5102237.935062, xMax: -9798254.961608, yMax: 5151000.725314, spatialReference: AGSSpatialReference.webMercator()) @@ -196,7 +183,7 @@ class JobManagerExample: TableViewController { i += 1 } - required public init?(coder aDecoder: NSCoder) { + required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } @@ -204,11 +191,11 @@ class JobManagerExample: TableViewController { super.init(nibName: nil, bundle: nil) } - override open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return jobs.count } - override open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "JobCell") as! JobTableViewCell let job = jobs[indexPath.row] cell.configureWithJob(job: job) @@ -219,58 +206,55 @@ class JobManagerExample: TableViewController { tableView.deselectRow(at: indexPath, animated: true) } - var documentsPath: String{ + var documentsPath: String { return NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] } - func generateGDB(URL: URL, syncModel: AGSSyncModel, extent: AGSEnvelope?){ - + func generateGDB(URL: URL, syncModel: AGSSyncModel, extent: AGSEnvelope?) { let task = AGSGeodatabaseSyncTask(url: URL) // hold on to task so that it stays retained while it's loading self.tasks.append(task) - task.load{ [weak self, weak task] error in - + task.load { [weak self, weak task] error in // make sure we are still around... guard let self = self else { return } - guard let strongTask = task else{ + guard let strongTask = task else { return } // remove task from array now that it's loaded - if let index = self.tasks.index(where: {return $0 === strongTask}){ + if let index = self.tasks.index(where: { return $0 === strongTask }) { self.tasks.remove(at: index) } // return if error or no featureServiceInfo - guard error == nil else{ + guard error == nil else { return } - guard let fsi = strongTask.featureServiceInfo else{ + guard let fsi = strongTask.featureServiceInfo else { return } let params = AGSGenerateGeodatabaseParameters() params.extent = extent - if params.extent == nil{ + if params.extent == nil { params.extent = fsi.fullExtent } params.outSpatialReference = AGSSpatialReference.webMercator() - if syncModel == .geodatabase{ + if syncModel == .geodatabase { params.syncModel = .geodatabase - } - else{ + } else { params.syncModel = .layer var options = [AGSGenerateLayerOption]() - for li in fsi.layerInfos{ + for li in fsi.layerInfos { let option = AGSGenerateLayerOption(layerID: li.id) options.append(option) } @@ -294,21 +278,19 @@ class JobManagerExample: TableViewController { } } - func takeOffline(map: AGSMap, extent: AGSEnvelope){ - + func takeOffline(map: AGSMap, extent: AGSEnvelope) { let task = AGSOfflineMapTask(onlineMap: map) let uuid = NSUUID() let offlineMapURL = URL(fileURLWithPath: "\(self.documentsPath)/\(uuid.uuidString)") as URL - task.defaultGenerateOfflineMapParameters(withAreaOfInterest: extent){ [weak self] params, error in - + task.defaultGenerateOfflineMapParameters(withAreaOfInterest: extent) { [weak self] params, error in // make sure we are still around... guard let self = self else { return } - if let params = params{ + if let params = params { let job = task.generateOfflineMapJob(with: params, downloadDirectory: offlineMapURL) // register the job with our JobManager shared instance @@ -319,25 +301,22 @@ class JobManagerExample: TableViewController { // refresh the tableview self.tableView.reloadData() - } - else{ + } else { // if could not get default parameters, then fire completion with the error self.jobCompletionHandler(result: nil, error: error) } } - } - func jobStatusHandler(status: AGSJobStatus){ + func jobStatusHandler(status: AGSJobStatus) { print("status: \(status.asString())") } - func jobCompletionHandler(result: Any?, error: Error?){ + func jobCompletionHandler(result: Any?, error: Error?) { print("job completed") - if let error = error{ + if let error = error { print(" - error: \(error)") - } - else if let result = result{ + } else if let result = result { print(" - result: \(result)") } @@ -351,9 +330,8 @@ class JobManagerExample: TableViewController { } } - -extension AGSJobStatus{ - func asString() -> String{ +extension AGSJobStatus { + func asString() -> String { switch self { case .failed: return "Failed" @@ -368,4 +346,3 @@ extension AGSJobStatus{ } } } - diff --git a/Examples/ArcGISToolkitExamples/LegendExample.swift b/Examples/ArcGISToolkitExamples/LegendExample.swift index 107cb69e..77ea95b0 100644 --- a/Examples/ArcGISToolkitExamples/LegendExample.swift +++ b/Examples/ArcGISToolkitExamples/LegendExample.swift @@ -16,7 +16,6 @@ import ArcGISToolkit import ArcGIS class LegendExample: MapViewController { - let portal = AGSPortal.arcGISOnline(withLoginRequired: false) var portalItem: AGSPortalItem? var map: AGSMap? @@ -37,11 +36,10 @@ class LegendExample: MapViewController { navigationItem.rightBarButtonItem = bbi } - @objc func showLegendAction(){ - if let legendVC = legendVC{ + @objc + func showLegendAction() { + if let legendVC = legendVC { navigationController?.pushViewController(legendVC, animated: true) } } - } - diff --git a/Examples/ArcGISToolkitExamples/MeasureExample.swift b/Examples/ArcGISToolkitExamples/MeasureExample.swift index 5ddbc1e6..3df3393c 100644 --- a/Examples/ArcGISToolkitExamples/MeasureExample.swift +++ b/Examples/ArcGISToolkitExamples/MeasureExample.swift @@ -15,8 +15,7 @@ import UIKit import ArcGISToolkit import ArcGIS -class MeasureExample: MapViewController{ - +class MeasureExample: MapViewController { var measureToolbar: MeasureToolbar! override func viewDidLoad() { @@ -35,7 +34,6 @@ class MeasureExample: MapViewController{ measureToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true measureToolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true measureToolbar.heightAnchor.constraint(equalToConstant: 44).isActive = true - } override func viewDidLayoutSubviews() { @@ -44,5 +42,4 @@ class MeasureExample: MapViewController{ // update content inset for mapview mapView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: measureToolbar.frame.height, right: 0) } - } diff --git a/Examples/ArcGISToolkitExamples/Misc/ARStatusViewController.storyboard b/Examples/ArcGISToolkitExamples/Misc/ARStatusViewController.storyboard new file mode 100644 index 00000000..52daece8 --- /dev/null +++ b/Examples/ArcGISToolkitExamples/Misc/ARStatusViewController.storyboard @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/ArcGISToolkitExamples/Misc/ARStatusViewController.swift b/Examples/ArcGISToolkitExamples/Misc/ARStatusViewController.swift new file mode 100644 index 00000000..f3ea06ac --- /dev/null +++ b/Examples/ArcGISToolkitExamples/Misc/ARStatusViewController.swift @@ -0,0 +1,155 @@ +// Copyright 2019 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 +// http://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 UIKit +import ARKit +import ArcGIS + +extension ARCamera.TrackingState { + var description: String { + switch self { + case .normal: + return "Normal" + case .notAvailable: + return "Tracking unavailable" + case .limited(.excessiveMotion): + return "Limited - Excessive Motion" + case .limited(.insufficientFeatures): + return "Limited - Insufficient Features" + case .limited(.initializing): + return "Limited - Initializing" + default: + return "" + } + } +} + +extension AGSLocationDataSourceStatus { + var description: String { + switch self { + case .stopped: + return "Stopped" + case .starting: + return "Starting" + case .started: + return "Started" + case .failedToStart: + return "Failed to start" + } + } +} + +/// A view controller for display AR-related status information. +class ARStatusViewController: UITableViewController { + @IBOutlet var trackingStateLabel: UILabel! + @IBOutlet var frameRateLabel: UILabel! + @IBOutlet var errorDescriptionLabel: UILabel! + @IBOutlet var sceneLabel: UILabel! + @IBOutlet var translationFactorLabel: UILabel! + @IBOutlet var horizontalAccuracyLabel: UILabel! + @IBOutlet var verticalAccuracyLabel: UILabel! + @IBOutlet var locationDataSourceStatusLabel: UILabel! + + /// The `ARKit` camera tracking state. + var trackingState: ARCamera.TrackingState = .notAvailable { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.trackingStateLabel?.text = self.trackingState.description + } + } + } + + /// The calculated frame rate of the `SceneView` and `ARKit` display. + var frameRate: Int = 0 { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.frameRateLabel?.text = "\(self.frameRate) fps" + } + } + } + + /// The current error message. + var errorMessage: String? { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.errorDescriptionLabel?.text = self.errorMessage + } + } + } + + /// The label for the currently selected scene. + var currentScene: String = "None" { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.sceneLabel?.text = self.currentScene + } + } + } + + /// The translation factor applied to the current scene. + var translationFactor: Double = 1.0 { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.translationFactorLabel?.text = String(format: "%.1f", self.translationFactor) + } + } + } + + /// The horizontal accuracy of the last location. + var horizontalAccuracyMeasurement = Measurement(value: 1, unit: UnitLength.meters) { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.horizontalAccuracyLabel?.text = self.measurementFormatter.string(from: self.horizontalAccuracyMeasurement) + } + } + } + + /// The vertical accuracy of the last location. + var verticalAccuracyMeasurement = Measurement(value: 1, unit: UnitLength.meters) { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.verticalAccuracyLabel?.text = self.measurementFormatter.string(from: self.verticalAccuracyMeasurement) + } + } + } + + /// The status of the location data source. + var locationDataSourceStatus: AGSLocationDataSourceStatus = .stopped { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.locationDataSourceStatusLabel?.text = self.locationDataSourceStatus.description + } + } + } + + private let measurementFormatter: MeasurementFormatter = { + let formatter = MeasurementFormatter() + formatter.unitOptions = [.naturalScale, .providedUnit] + return formatter + }() + + override func viewDidLoad() { + super.viewDidLoad() + + // Add a blur effect behind the table view. + tableView.backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + } +} diff --git a/Examples/ArcGISToolkitExamples/Misc/CalibrationView.swift b/Examples/ArcGISToolkitExamples/Misc/CalibrationView.swift new file mode 100644 index 00000000..97541e50 --- /dev/null +++ b/Examples/ArcGISToolkitExamples/Misc/CalibrationView.swift @@ -0,0 +1,254 @@ +// +// Copyright 2019 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 +// http://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 UIKit +import ArcGIS +import ArcGISToolkit + +/// A view displaying controls for adjusting a scene view's location, heading, and elevation. Used to calibrate an AR session. +class CalibrationView: UIView { + /// Denotes whether to show the elevation control and label; defaults to `true`. + var elevationControlVisibility: Bool = true { + didSet { + elevationSlider.isHidden = !elevationControlVisibility + elevationLabel.isHidden = !elevationControlVisibility + } + } + + /// The `ArcGISARView` containing the origin camera we will be updating. + private var arcgisARView: ArcGISARView! + + /// The label displaying calibration directions. + private let calibrationDirectionsLabel: UILabel = { + let label = UILabel(frame: .zero) + label.textAlignment = .center + label.font = UIFont.systemFont(ofSize: 24.0) + label.textColor = .darkText + label.numberOfLines = 0 + label.text = "Calibrating..." + return label + }() + + /// The UISlider used to adjust elevation. + private let elevationSlider: UISlider = { + let slider = UISlider(frame: .zero) + slider.minimumValue = -50.0 + slider.maximumValue = 50.0 + return slider + }() + + /// The UISlider used to adjust heading. + private let headingSlider: UISlider = { + let slider = UISlider(frame: .zero) + slider.minimumValue = -10.0 + slider.maximumValue = 10.0 + return slider + }() + + /// The elevation label.. + private let elevationLabel = UILabel(frame: .zero) + + /// Initialized a new calibration view with the `ArcGISARView`. + /// + /// - Parameters: + /// - arcgisARView: The `ArcGISARView` containing the originCamera we're updating. + init(_ arcgisARView: ArcGISARView) { + self.arcgisARView = arcgisARView + + super.init(frame: .zero) + + // Create visual effects view to show the label on a blurred background. + let labelView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) + labelView.layer.cornerRadius = 8.0 + labelView.layer.masksToBounds = true + + // Add the label to our label view and set up constraints. + labelView.contentView.addSubview(calibrationDirectionsLabel) + calibrationDirectionsLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + calibrationDirectionsLabel.leadingAnchor.constraint(equalTo: labelView.leadingAnchor, constant: 8), + calibrationDirectionsLabel.trailingAnchor.constraint(equalTo: labelView.trailingAnchor, constant: -8), + calibrationDirectionsLabel.topAnchor.constraint(equalTo: labelView.topAnchor, constant: 8), + calibrationDirectionsLabel.bottomAnchor.constraint(equalTo: labelView.bottomAnchor, constant: -8) + ]) + + // Add the label view to our view and set up constraints. + addSubview(labelView) + labelView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + labelView.centerXAnchor.constraint(equalTo: centerXAnchor), + labelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8.0) + ]) + + // Add the heading label and slider. + let headingLabel = UILabel(frame: .zero) + headingLabel.text = "Heading" + headingLabel.textColor = .yellow + addSubview(headingLabel) + headingLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + headingLabel.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 16), + headingLabel.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -16) + ]) + + addSubview(headingSlider) + headingSlider.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + headingSlider.leadingAnchor.constraint(equalTo: headingLabel.trailingAnchor, constant: 16), + headingSlider.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -16), + headingSlider.centerYAnchor.constraint(equalTo: headingLabel.centerYAnchor) + ]) + + // Add the elevation label and slider. + elevationLabel.text = "Elevation" + elevationLabel.textColor = .yellow + addSubview(elevationLabel) + elevationLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + elevationLabel.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 16), + elevationLabel.bottomAnchor.constraint(equalTo: headingLabel.topAnchor, constant: -24) + ]) + + addSubview(elevationSlider) + elevationSlider.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + elevationSlider.leadingAnchor.constraint(equalTo: elevationLabel.trailingAnchor, constant: 16), + elevationSlider.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -16), + elevationSlider.centerYAnchor.constraint(equalTo: elevationLabel.centerYAnchor) + ]) + + // Setup actions for the two sliders. The sliders operate as "joysticks", where moving the slider thumb will start a timer + // which roates or elevates the current camera when the timer fires. The elevation and heading delta + // values increase the further you move away from center. Moving and holding the thumb a little bit from center + // will roate/elevate just a little bit, but get progressively more the further from center the thumb is moved. + headingSlider.addTarget(self, action: #selector(headingChanged(_:)), for: .valueChanged) + headingSlider.addTarget(self, action: #selector(touchUpHeading(_:)), for: [.touchUpInside, .touchUpOutside]) + + elevationSlider.addTarget(self, action: #selector(elevationChanged(_:)), for: .valueChanged) + elevationSlider.addTarget(self, action: #selector(touchUpElevation(_:)), for: [.touchUpInside, .touchUpOutside]) + + elevationSlider.isHidden = !elevationControlVisibility + elevationLabel.isHidden = !elevationControlVisibility + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // If the user tapped in the view (and not in the sliders), do not handle the event. + // This allows the view below the calibration view to handle touch events. In this case, + // that view is the SceneView. + let hitView = super.hitTest(point, with: event) + if hitView == self { + return nil + } else { + return hitView + } + } + + // The timers for the "joystick" behavior. + private var elevationTimer: Timer? + private var headingTimer: Timer? + + /// Handle an elevation slider value-changed event. + /// + /// - Parameter sender: The slider tapped on. + @objc + func elevationChanged(_ sender: UISlider) { + if elevationTimer == nil { + // Create a timer which elevates the camera when fired. + elevationTimer = Timer(timeInterval: 0.25, repeats: true) { [weak self] (_) in + let delta = self?.joystickElevation() ?? 0.0 +// print("elevate delta = \(delta)") + self?.elevate(delta) + } + + // Add the timer to the main run loop. + guard let timer = elevationTimer else { return } + RunLoop.main.add(timer, forMode: .default) + } + } + + /// Handle an heading slider value-changed event. + /// + /// - Parameter sender: The slider tapped on. + @objc + func headingChanged(_ sender: UISlider) { + if headingTimer == nil { + // Create a timer which rotates the camera when fired. + headingTimer = Timer(timeInterval: 0.1, repeats: true) { [weak self] (_) in + let delta = self?.joystickHeading() ?? 0.0 +// print("rotate delta = \(delta)") + self?.rotate(delta) + } + + // Add the timer to the main run loop. + guard let timer = headingTimer else { return } + RunLoop.main.add(timer, forMode: .default) + } + } + + /// Handle an elevation slider touchUp event. This will stop the timer. + /// + /// - Parameter sender: The slider tapped on. + @objc + func touchUpElevation(_ sender: UISlider) { + elevationTimer?.invalidate() + elevationTimer = nil + sender.value = 0.0 + } + + /// Handle a heading slider touchUp event. This will stop the timer. + /// + /// - Parameter sender: The slider tapped on. + @objc + func touchUpHeading(_ sender: UISlider) { + headingTimer?.invalidate() + headingTimer = nil + sender.value = 0.0 + } + + /// Rotates the camera by `deltaHeading`. + /// + /// - Parameter deltaHeading: The amount to rotate the camera. + private func rotate(_ deltaHeading: Double) { + let camera = arcgisARView.originCamera + let newHeading = camera.heading + deltaHeading + arcgisARView.originCamera = camera.rotate(toHeading: newHeading, pitch: camera.pitch, roll: camera.roll) + } + + /// Change the cameras altitude by `deltaAltitude`. + /// + /// - Parameter deltaAltitude: The amount to elevate the camera. + private func elevate(_ deltaAltitude: Double) { + arcgisARView.originCamera = arcgisARView.originCamera.elevate(withDeltaAltitude: deltaAltitude) + } + + /// Calculates the elevation delta amount based on the elevation slider value. + /// + /// - Returns: The elevation delta. + private func joystickElevation() -> Double { + let deltaElevation = Double(elevationSlider.value) + return pow(deltaElevation, 2) / 50.0 * (deltaElevation < 0 ? -1.0 : 1.0) + } + + /// Calculates the heading delta amount based on the heading slider value. + /// + /// - Returns: The heading delta. + private func joystickHeading() -> Double { + let deltaHeading = Double(headingSlider.value) + return pow(deltaHeading, 2) / 25.0 * (deltaHeading < 0 ? -1.0 : 1.0) + } +} diff --git a/Examples/ArcGISToolkitExamples/Misc/Plane.swift b/Examples/ArcGISToolkitExamples/Misc/Plane.swift new file mode 100644 index 00000000..3d65a572 --- /dev/null +++ b/Examples/ArcGISToolkitExamples/Misc/Plane.swift @@ -0,0 +1,46 @@ +// +// Copyright 2019 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 +// http://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 ARKit + +/// Helper class to visualize a plane found by ARKit +class Plane: SCNNode { + let node: SCNNode + + init(anchor: ARPlaneAnchor, in sceneView: ARSCNView) { + // Create a node to visualize the plane's bounding rectangle. + let extent = SCNPlane(width: CGFloat(anchor.extent.x), height: CGFloat(anchor.extent.z)) + node = SCNNode(geometry: extent) + node.simdPosition = anchor.center + + // `SCNPlane` is vertically oriented in its local coordinate space, so + // rotate it to match the orientation of `ARPlaneAnchor`. + node.eulerAngles.x = -.pi / 2 + + super.init() + + node.opacity = 0.6 + guard let material = node.geometry?.firstMaterial + else { fatalError("SCNPlane always has one material") } + + material.diffuse.contents = UIColor.white + + // Add the plane node as child node so they appear in the scene. + addChildNode(node) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Examples/ArcGISToolkitExamples/Misc/UserDirectionsView.swift b/Examples/ArcGISToolkitExamples/Misc/UserDirectionsView.swift new file mode 100644 index 00000000..abbe5160 --- /dev/null +++ b/Examples/ArcGISToolkitExamples/Misc/UserDirectionsView.swift @@ -0,0 +1,59 @@ +// +// Copyright 2019 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 +// http://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 UIKit + +/// A custom view for dislaying directions to the user. +class UserDirectionsView: UIVisualEffectView { + private let userDirectionsLabel: UILabel = { + let label = UILabel(frame: .zero) + label.textAlignment = .center + label.font = UIFont.systemFont(ofSize: 24.0) + label.textColor = .darkText + label.numberOfLines = 0 + label.text = "Initializing ARKit..." + return label + }() + + override init(effect: UIVisualEffect?) { + super.init(effect: effect) + + // Set a corner radius. + layer.cornerRadius = 8.0 + layer.masksToBounds = true + + contentView.addSubview(userDirectionsLabel) + userDirectionsLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + userDirectionsLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + userDirectionsLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + userDirectionsLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + userDirectionsLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8) + ]) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Updates the displayed user directions string. If `message` is nil or empty, this will hide the view. If `message` is not empty, it will display the view. + /// + /// - Parameter message: the new string to display. + func updateUserDirections(_ message: String?) { + UIView.animate(withDuration: 0.25) { [weak self] in + self?.alpha = (message?.isEmpty ?? true) ? 0.0 : 1.0 + self?.userDirectionsLabel.text = message + } + } +} diff --git a/Examples/ArcGISToolkitExamples/PopupExample.swift b/Examples/ArcGISToolkitExamples/PopupExample.swift index ff00c205..4ed99ad6 100644 --- a/Examples/ArcGISToolkitExamples/PopupExample.swift +++ b/Examples/ArcGISToolkitExamples/PopupExample.swift @@ -16,7 +16,6 @@ import ArcGISToolkit import ArcGIS class PopupExample: MapViewController { - var map: AGSMap? var popupController: PopupController? @@ -34,7 +33,7 @@ class PopupExample: MapViewController { // We have to load it first to create a default popup definition. // If you create the map from a portal item, you can define the popup definition // in the webmap and avoid this step. - featureLayer.load{ _ in + featureLayer.load { _ in featureLayer.popupDefinition = AGSPopupDefinition(popupSource: featureLayer) } @@ -47,8 +46,8 @@ class PopupExample: MapViewController { mapView.map = map // Log if there is any error loading the map - map?.load{ error in - if let error = error{ + map?.load { error in + if let error = error { print("error loading map: \(error)") } } @@ -56,6 +55,4 @@ class PopupExample: MapViewController { // instantiate the popup controller popupController = PopupController(geoViewController: self, geoView: mapView) } - } - diff --git a/Examples/ArcGISToolkitExamples/ScalebarExample.swift b/Examples/ArcGISToolkitExamples/ScalebarExample.swift index 4f179a36..322b493a 100644 --- a/Examples/ArcGISToolkitExamples/ScalebarExample.swift +++ b/Examples/ArcGISToolkitExamples/ScalebarExample.swift @@ -16,7 +16,6 @@ import ArcGISToolkit import ArcGIS class ScalebarExample: MapViewController, AGSGeoViewTouchDelegate { - var map: AGSMap? var scalebar: Scalebar? @@ -44,6 +43,4 @@ class ScalebarExample: MapViewController, AGSGeoViewTouchDelegate { sb.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: xMargin).isActive = true scalebar = sb } - } - diff --git a/Examples/ArcGISToolkitExamples/TemplatePickerExample.swift b/Examples/ArcGISToolkitExamples/TemplatePickerExample.swift index e2605196..728084ee 100644 --- a/Examples/ArcGISToolkitExamples/TemplatePickerExample.swift +++ b/Examples/ArcGISToolkitExamples/TemplatePickerExample.swift @@ -16,7 +16,6 @@ import ArcGISToolkit import ArcGIS class TemplatePickerExample: MapViewController { - var map: AGSMap? override func viewDidLoad() { @@ -33,7 +32,7 @@ class TemplatePickerExample: MapViewController { // We have to load it first to create a default popup definition. // If you create the map from a portal item, you can define the popup definition // in the webmap and avoid this step. - featureLayer.load{ _ in + featureLayer.load { _ in featureLayer.popupDefinition = AGSPopupDefinition(popupSource: featureLayer) } @@ -41,8 +40,8 @@ class TemplatePickerExample: MapViewController { mapView.map = map // Log if there is any error loading the map - map?.load{ error in - if let error = error{ + map?.load { error in + if let error = error { print("error loading map: \(error)") } } @@ -52,8 +51,8 @@ class TemplatePickerExample: MapViewController { navigationItem.rightBarButtonItem = bbi } - @objc private func showTemplates(){ - + @objc + private func showTemplates() { guard let map = map else { return } // Instantiate the TemplatePickerViewController @@ -65,13 +64,10 @@ class TemplatePickerExample: MapViewController { // Present the template picker self.navigationController?.pushViewController(templatePicker, animated: true) } - } extension TemplatePickerExample: TemplatePickerViewControllerDelegate { - public func templatePickerViewControllerDidCancel(_ templatePickerViewController: TemplatePickerViewController) { - // This is where you handle the user canceling the template picker // dismiss the template picker @@ -85,7 +81,6 @@ extension TemplatePickerExample: TemplatePickerViewControllerDelegate { } public func templatePickerViewController(_ templatePickerViewController: TemplatePickerViewController, didSelect featureTemplateInfo: FeatureTemplateInfo) { - // This is where you handle the user making a selection with the template picker // dismiss the template picker @@ -98,6 +93,3 @@ extension TemplatePickerExample: TemplatePickerViewControllerDelegate { present(alert, animated: true) } } - - - diff --git a/Examples/ArcGISToolkitExamples/TimeSliderExample.swift b/Examples/ArcGISToolkitExamples/TimeSliderExample.swift index ddc2a8a9..6c51866a 100644 --- a/Examples/ArcGISToolkitExamples/TimeSliderExample.swift +++ b/Examples/ArcGISToolkitExamples/TimeSliderExample.swift @@ -17,7 +17,6 @@ import ArcGISToolkit import ArcGIS class TimeSliderExample: MapViewController { - private var map = AGSMap(basemap: AGSBasemap.topographic()) private var timeSlider = TimeSlider() @@ -44,8 +43,7 @@ class TimeSliderExample: MapViewController { // Add layer let mapImageLayer = AGSArcGISMapImageLayer(url: URL(string: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/911CallsHotspot/MapServer")!) mapView.map?.operationalLayers.add(mapImageLayer) - mapImageLayer.load(completion: { [weak self] (error) in - + mapImageLayer.load { [weak self] (error) in // Make sure self is around guard let self = self else { return @@ -63,8 +61,7 @@ class TimeSliderExample: MapViewController { self.mapView.setViewpoint(AGSViewpoint(targetExtent: fullExtent), completion: nil) } - self.timeSlider.initializeTimeProperties(geoView: self.mapView, observeGeoView: true, completion: { [weak self] (error) in - + self.timeSlider.initializeTimeProperties(geoView: self.mapView, observeGeoView: true) { [weak self] (error) in // Make sure self is around guard let self = self else { return @@ -79,17 +76,18 @@ class TimeSliderExample: MapViewController { // Show the time slider self.timeSlider.isHidden = false - }) - }) + } + } } - @objc func timeSliderValueChanged(timeSlider: TimeSlider) { + @objc + func timeSliderValueChanged(timeSlider: TimeSlider) { if mapView.timeExtent != timeSlider.currentExtent { mapView.timeExtent = timeSlider.currentExtent } } - //MARK: - Show Error + // MARK: - Show Error private func showError(_ error: Error) { let alertController = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert) @@ -97,5 +95,3 @@ class TimeSliderExample: MapViewController { present(alertController, animated: true) } } - - diff --git a/Examples/ArcGISToolkitExamples/VCListViewController.swift b/Examples/ArcGISToolkitExamples/VCListViewController.swift index 8b611946..f080ce49 100644 --- a/Examples/ArcGISToolkitExamples/VCListViewController.swift +++ b/Examples/ArcGISToolkitExamples/VCListViewController.swift @@ -14,65 +14,49 @@ import UIKit import ArcGISToolkit -open class VCListViewController: TableViewController { - +open class VCListViewController: UITableViewController { public var storyboardName: String? - public var viewControllerInfos: [(vcName: String, viewControllerType: UIViewController.Type, nibName: String?)] = [ - ]{ - didSet{ + public var viewControllerInfos: [(vcName: String, viewControllerType: UIViewController.Type, nibName: String?)] = [] { + didSet { self.tableView.reloadData() } } - override open func viewDidLoad() { - super.viewDidLoad() - } - - required public init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { - super.init(nibName: nil, bundle: nil) - } - override open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return viewControllerInfos.count } override open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier)! + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) cell.textLabel?.text = viewControllerInfos[indexPath.row].vcName return cell } - public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - + override open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let t = viewControllerInfos[indexPath.row].viewControllerType let nibName = viewControllerInfos[indexPath.row].nibName - var vcOpt: UIViewController? = nil + var vcOpt: UIViewController? // first check storyboard - if let storyboardName = self.storyboardName{ + if let storyboardName = self.storyboardName { let sb = UIStoryboard(name: storyboardName, bundle: nil) - if let nibName = nibName{ + if let nibName = nibName { // this is how you can check to see if that identifier is in the nib, based on http://stackoverflow.com/a/34650505/1687195 - if let dictionary = sb.value(forKey: "identifierToNibNameMap") as? NSDictionary{ - if dictionary.value(forKey: nibName) != nil{ + if let dictionary = sb.value(forKey: "identifierToNibNameMap") as? NSDictionary { + if dictionary.value(forKey: nibName) != nil { vcOpt = sb.instantiateViewController(withIdentifier: nibName) } } } } - if vcOpt == nil{ + if vcOpt == nil { vcOpt = t.init(nibName: nibName, bundle: nil) } - if let vc = vcOpt{ + if let vc = vcOpt { navigationController?.pushViewController(vc, animated: true) } } - } diff --git a/Examples/exportOptions.plist b/Examples/exportOptions.plist new file mode 100644 index 00000000..6631ffa6 --- /dev/null +++ b/Examples/exportOptions.plist @@ -0,0 +1,6 @@ + + + + + + diff --git a/README.md b/README.md index bc4c5482..659566d6 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,10 @@ Toolkit components that will simplify your iOS app development with ArcGIS Runti * [TimeSlider](Documentation/TimeSlider) * [PopupController](Documentation/PopupController) * [TemplatePickerViewController](Documentation/TemplatePicker) +* [AR](Documentation/AR) ## Requirements -* [ArcGIS Runtime SDK for iOS](https://developers.arcgis.com/en/ios/) 100.5.0 (or higher) +* [ArcGIS Runtime SDK for iOS](https://developers.arcgis.com/en/ios/) 100.6.0 (or higher) * Xcode 10.1 (or higher) The *ArcGIS Runtime Toolkit for iOS* has a *Target SDK* version of *11.0*, meaning that it can run on devices with *iOS 11.0* or newer. @@ -28,6 +29,21 @@ The *ArcGIS Runtime Toolkit for iOS* has a *Target SDK* version of *11.0*, meani New to cocoapods? Visit [cocoapods.org](https://cocoapods.org/) +### Carthage + +[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) + +Carthage is a decentralized dependency manager that builds your dependencies and provides you with binary frameworks. + + 1. Add `github "esri/arcgis-runtime-toolkit-ios"` to your Cartfile + 2. Run `carthage update` + 3. Drag the `ArcGISToolkit.framework ` from the `Carthage/Build ` folder to the "TARGETS" settings for your application and drop it in the "Embedded Binaries" section in the "General" tab + 4. Add `import ArcGISToolkit` in your source code and start using the toolkit components + +New to Carthage? Visit the Carthage [GitHub](https://github.com/Carthage/Carthage) page. + +Note that you must also have the __ArcGIS Runtime SDK for iOS__ installed and your project set up as per the instructions [here](https://developers.arcgis.com/ios/latest/swift/guide/install.htm#ESRI_SECTION1_D57435A2BEBC4D29AFA3A4CAA722506A). + ### Manual 1. Ensure you have downloaded and installed __ArcGIS Runtime SDK for iOS__ as described [here](https://developers.arcgis.com/ios/latest/swift/guide/install.htm#ESRI_SECTION1_D57435A2BEBC4D29AFA3A4CAA722506A) 2. Clone or download this repo. @@ -35,6 +51,8 @@ The *ArcGIS Runtime Toolkit for iOS* has a *Target SDK* version of *11.0*, meani 4. Drag the `ArcGISToolkit.framework` from the `ArcGISToolkit.xcodeproj/ArcGISToolkit/Products` folder to the "TARGETS" settings for your application and drop it in the "Embedded Binaries" section in the "General" tab 5. Add `import ArcGISToolkit` in your source code and start using the toolkit components +## SwiftLint +New in the 100.6.0 release is SwiftLint support for both the Toolkit and Examples app. You can install SwiftLint from [here](https://github.com/realm/SwiftLint). It is not necessary to have it installed in order to build, but you will get a warning without it. The specific rules the linter uses can be found in the `swiftlint.yml` files in the `Toolkit` and `Examples` directories. ## Additional Resources @@ -51,7 +69,7 @@ Find a bug or want to request a new feature? Please let us know by submitting a Esri welcomes contributions from anyone and everyone. Please see our [guidelines for contributing](https://github.com/esri/contributing). ## Licensing -Copyright 2017 Esri +Copyright 2017 - 2019 Esri Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Toolkit/.swiftlint.yml b/Toolkit/.swiftlint.yml new file mode 120000 index 00000000..9e225e41 --- /dev/null +++ b/Toolkit/.swiftlint.yml @@ -0,0 +1 @@ +../.swiftlint.yml \ No newline at end of file diff --git a/Toolkit/ArcGISToolkit.xcodeproj/project.pbxproj b/Toolkit/ArcGISToolkit.xcodeproj/project.pbxproj index 71b64e03..eb0a0625 100644 --- a/Toolkit/ArcGISToolkit.xcodeproj/project.pbxproj +++ b/Toolkit/ArcGISToolkit.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 88DBC29F1FE83D4400255921 /* JobManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DBC29E1FE83D4400255921 /* JobManager.swift */; }; 88DBC2A31FE83DB800255921 /* CancelGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DBC2A21FE83DB800255921 /* CancelGroup.swift */; }; 88ECCC931DF92F22000C967E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88ECCC921DF92F22000C967E /* Assets.xcassets */; }; + E447A1262266629600578C0B /* ArcGISARView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E447A1252266629600578C0B /* ArcGISARView.swift */; }; E46893291FEDAE36008ADA79 /* Compass.swift in Sources */ = {isa = PBXBuildFile; fileRef = E46893281FEDAE36008ADA79 /* Compass.swift */; }; E48405731E9BE7B700927208 /* LegendViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E48405721E9BE7B700927208 /* LegendViewController.swift */; }; E484057A1E9C262D00927208 /* Legend.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E48405791E9C262D00927208 /* Legend.storyboard */; }; @@ -45,6 +46,7 @@ 88DBC29E1FE83D4400255921 /* JobManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobManager.swift; sourceTree = ""; }; 88DBC2A21FE83DB800255921 /* CancelGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelGroup.swift; sourceTree = ""; }; 88ECCC921DF92F22000C967E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = ArcGISToolkit/Assets.xcassets; sourceTree = ""; }; + E447A1252266629600578C0B /* ArcGISARView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArcGISARView.swift; sourceTree = ""; }; E46893281FEDAE36008ADA79 /* Compass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Compass.swift; sourceTree = ""; }; E48405721E9BE7B700927208 /* LegendViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegendViewController.swift; sourceTree = ""; }; E48405791E9C262D00927208 /* Legend.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Legend.storyboard; sourceTree = ""; }; @@ -116,6 +118,7 @@ 88B689EE1E96EF3300B67FAB /* Components */ = { isa = PBXGroup; children = ( + E447A1242266628300578C0B /* AR */, E46893281FEDAE36008ADA79 /* Compass.swift */, E48405721E9BE7B700927208 /* LegendViewController.swift */, 88B689F41E96EFD700B67FAB /* MeasureToolbar.swift */, @@ -128,6 +131,14 @@ name = Components; sourceTree = ""; }; + E447A1242266628300578C0B /* AR */ = { + isa = PBXGroup; + children = ( + E447A1252266629600578C0B /* ArcGISARView.swift */, + ); + path = AR; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -150,6 +161,7 @@ 8812336C1DF601A700B2EA8E /* Frameworks */, 8812336D1DF601A700B2EA8E /* Headers */, 8812336E1DF601A700B2EA8E /* Resources */, + E47ED065233AA6110032440E /* Run Linter */, ); buildRules = ( ); @@ -206,6 +218,27 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + E47ED065233AA6110032440E /* Run Linter */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Linter"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which swiftlint >/dev/null; then\nswiftlint\nelse\necho \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 8812336B1DF601A700B2EA8E /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -215,6 +248,7 @@ 88B68A041E96EFD700B67FAB /* Scalebar.swift in Sources */, 88B68A081E96EFD700B67FAB /* UnitsViewController.swift in Sources */, E48405731E9BE7B700927208 /* LegendViewController.swift in Sources */, + E447A1262266629600578C0B /* ArcGISARView.swift in Sources */, 883EA74F20741B9C006D6F72 /* TemplatePickerViewController.swift in Sources */, 883EA74920741A4C006D6F72 /* PopupController.swift in Sources */, 88B68A011E96EFD700B67FAB /* MeasureToolbar.swift in Sources */, diff --git a/Toolkit/ArcGISToolkit.xcodeproj/xcshareddata/xcschemes/ArcGISToolkit.xcscheme b/Toolkit/ArcGISToolkit.xcodeproj/xcshareddata/xcschemes/ArcGISToolkit.xcscheme new file mode 100644 index 00000000..e9e79808 --- /dev/null +++ b/Toolkit/ArcGISToolkit.xcodeproj/xcshareddata/xcschemes/ArcGISToolkit.xcscheme @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Toolkit/ArcGISToolkit/AR/ArcGISARView.swift b/Toolkit/ArcGISToolkit/AR/ArcGISARView.swift new file mode 100644 index 00000000..dc954c15 --- /dev/null +++ b/Toolkit/ArcGISToolkit/AR/ArcGISARView.swift @@ -0,0 +1,529 @@ +// +// Copyright 2019 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 +// http://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 UIKit +import ARKit +import ArcGIS + +/// Controls how the locations generated from the location data source are used during AR tracking. +/// +/// - ignore: Ignore all location data source locations. +/// - initial: Use only the initial location from the location data source and ignore all subsequent locations. +/// - continuous: Use all locations from the location data source. +public enum ARLocationTrackingMode { + case ignore + case initial + case continuous +} + +public class ArcGISARView: UIView { + // MARK: public properties + + /// The view used to display the `ARKit` camera image and 3D `SceneKit` content. + /// - Since: 100.6.0 + public let arSCNView = ARSCNView(frame: .zero) + + /// The initial transformation used for a table top experience. Defaults to the Identity Matrix. + /// - Since: 100.6.0 + public var initialTransformation: AGSTransformationMatrix = .identity + + /// Denotes whether tracking location and angles has started. + /// - Since: 100.6.0 + public private(set) var isTracking: Bool = false + + /// Denotes whether ARKit is being used to track location and angles. + /// - Since: 100.6.0 + public private(set) var isUsingARKit: Bool = true + + /// The data source used to get device location. Used either in conjuction with ARKit data or when ARKit is not present or not being used. + /// - Since: 100.6.0 + public var locationDataSource: AGSCLLocationDataSource? { + didSet { + locationDataSource?.locationChangeHandlerDelegate = self + } + } + + /// The viewpoint camera used to set the initial view of the sceneView instead of the device's GPS location via the location data source. You can use Key-Value Observing to track changes to the origin camera. + /// - Since: 100.6.0 + @objc public dynamic var originCamera: AGSCamera { + get { + return cameraController.originCamera + } + set { + cameraController.originCamera = newValue + } + } + + /// The view used to display ArcGIS 3D content. + /// - Since: 100.6.0 + public let sceneView = AGSSceneView(frame: .zero) + + /// The translation factor used to support a table top AR experience. + /// - Since: 100.6.0 + @objc public dynamic var translationFactor: Double { + get { + return cameraController.translationFactor + } + set { + cameraController.translationFactor = newValue + } + } + + /// The world tracking information used by `ARKit`. + /// - Since: 100.6.0 + public var arConfiguration: ARConfiguration = { + let config = ARWorldTrackingConfiguration() + config.worldAlignment = .gravityAndHeading + config.planeDetection = [.horizontal] + return config + }() { + didSet { + // If we're already tracking, reset tracking to use the new configuration. + if isTracking, isUsingARKit { + arSCNView.session.run(arConfiguration, options: .resetTracking) + } + } + } + + /// We implement `ARSCNViewDelegate` methods, but will use `arSCNViewDelegate` to forward them to clients. + /// - Since: 100.6.0 + public weak var arSCNViewDelegate: ARSCNViewDelegate? + + /// We implement `AGSLocationChangeHandlerDelegate` methods, but will use `locationChangeHandlerDelegate` to forward them to clients. + /// - Since: 100.6.0 + public weak var locationChangeHandlerDelegate: AGSLocationChangeHandlerDelegate? + + // MARK: Private properties + + /// The `AGSTransformationMatrixCameraController` used to control the Scene. + @objc private let cameraController = AGSTransformationMatrixCameraController() + + /// Whether `ARKit` is supported on this device. + private let deviceSupportsARKit: Bool = { + return ARWorldTrackingConfiguration.isSupported + }() + + /// Denotes whether we've received our initial location from the data source. + private var didSetInitialLocation: Bool = false + + /// The last portrait or landscape orientation value. + private var lastGoodDeviceOrientation = UIDeviceOrientation.portrait + + /// The tracking mode controlling how the locations generated from the location data source are used during AR tracking. + private var locationTrackingMode: ARLocationTrackingMode = .ignore + + // MARK: Initializers + + override public init(frame: CGRect) { + super.init(frame: frame) + sharedInitialization() + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + sharedInitialization() + } + + /// Initializer used to denote whether to display the live camera image. + /// + /// - Parameters: + /// - renderVideoFeed: Whether to display the live camera image. + /// - Since: 100.6.0 + public convenience init(renderVideoFeed: Bool) { + self.init(frame: .zero) + + if !isUsingARKit || !renderVideoFeed { + // User is not using ARKit, or they don't want to see video, + // set the arSCNView.alpha to 0.0 so it doesn't display. + arSCNView.alpha = 0.0 + } + + // Tell the sceneView we will be calling `renderFrame()` manually if we're using ARKit. + // This overrides the `sharedInitialization()` `isManualRendering` code + sceneView.isManualRendering = isUsingARKit + } + + deinit { + stopTracking() + } + + /// Initialization code shared between all initializers. + private func sharedInitialization() { + // Add the ARSCNView to our view. + if deviceSupportsARKit { + addSubviewWithConstraints(arSCNView) + arSCNView.delegate = self + } + + // Always use ARKit if device supports it. + isUsingARKit = deviceSupportsARKit + + // Add sceneView to view and setup constraints. + addSubviewWithConstraints(sceneView) + + // Make our sceneView's spaceEffect be transparent, no atmosphereEffect. + sceneView.spaceEffect = .transparent + sceneView.atmosphereEffect = .none + + // Set the camera controller on the sceneView + sceneView.cameraController = cameraController + + // Tell the sceneView we will be calling `renderFrame()` manually if we're using ARKit. + sceneView.isManualRendering = isUsingARKit + } + + /// Implementing this method will allow the computed `translationFactor` property to generate KVO events when the `cameraController.translationFactor` value changes. + /// + /// - Parameter key: The key we want to observe. + /// - Returns: A set of key paths for properties whose values affect the value of the specified key. + override public class func keyPathsForValuesAffectingValue(forKey key: String) -> Set { + var set = super.keyPathsForValuesAffectingValue(forKey: key) + if key == #keyPath(translationFactor) { + set.insert(#keyPath(cameraController.translationFactor)) + } else if key == #keyPath(originCamera) { + set.insert(#keyPath(cameraController.originCamera)) + } + + return set + } + + // MARK: Public + + /// Determines the map point for the given screen point. + /// + /// - Parameter screenPoint: The point in screen coordinates. + /// - Returns: The map point corresponding to screenPoint. + /// - Since: 100.6.0 + public func arScreenToLocation(screenPoint: CGPoint) -> AGSPoint? { + // Use the `internalHitTest` method to get the matrix of `screenPoint`. + guard let localOffsetMatrix = internalHitTest(screenPoint: screenPoint) else { return nil } + + let currOriginMatrix = originCamera.transformationMatrix + + // Scale translation by translationFactor. + let translatedMatrix = AGSTransformationMatrix(quaternionX: localOffsetMatrix.quaternionX, + quaternionY: localOffsetMatrix.quaternionY, + quaternionZ: localOffsetMatrix.quaternionZ, + quaternionW: localOffsetMatrix.quaternionW, + translationX: localOffsetMatrix.translationX * translationFactor, + translationY: localOffsetMatrix.translationY * translationFactor, + translationZ: localOffsetMatrix.translationZ * translationFactor) + let mapPointMatrix = currOriginMatrix.addTransformation(translatedMatrix) + + // Create a camera from transformationMatrix and return its location. + return AGSCamera(transformationMatrix: mapPointMatrix).location + } + + /// Resets the device tracking and related properties. + /// - Since: 100.6.0 + public func resetTracking() { + didSetInitialLocation = false + initialTransformation = .identity + if isUsingARKit { + arSCNView.session.run(arConfiguration, options: [.resetTracking, .removeExistingAnchors]) + } + + cameraController.transformationMatrix = .identity + } + + /// Sets the initial transformation used to offset the originCamera. The initial transformation is based on an AR point determined via existing plane hit detection from `screenPoint`. If an AR point cannot be determined, this method will return `false`. + /// + /// - Parameter screenPoint: The screen point to determine the `initialTransformation` from. + /// - Returns: Whether setting the `initialTransformation` succeeded or failed. + /// - Since: 100.6.0 + public func setInitialTransformation(using screenPoint: CGPoint) -> Bool { + // Use the `internalHitTest` method to get the matrix of `screenPoint`. + guard let matrix = internalHitTest(screenPoint: screenPoint) else { return false } + + // Set the `initialTransformation` as the AGSTransformationMatrix.identity - hit test matrix. + initialTransformation = AGSTransformationMatrix.identity.subtractTransformation(matrix) + + return true + } + + /// Starts device tracking. + /// + /// - Parameter completion: The completion handler called when start tracking completes. If tracking starts successfully, the `error` property will be nil; if tracking fails to start, the error will be non-nil and contain the reason for failure. + /// - Since: 100.6.0 + public func startTracking(_ locationTrackingMode: ARLocationTrackingMode, completion: ((_ error: Error?) -> Void)? = nil) { + // We have a location data source that needs to be started. + self.locationTrackingMode = locationTrackingMode + if locationTrackingMode != .ignore, + let locationDataSource = self.locationDataSource { + locationDataSource.start { [weak self] (error) in + if error == nil { + self?.finalizeStart() + } + completion?(error) + } + } else { + // We're either ignoring the data source or there is no data source so continue with defaults. + finalizeStart() + completion?(nil) + } + } + + /// Suspends device tracking. + /// - Since: 100.6.0 + public func stopTracking() { + arSCNView.session.pause() + locationDataSource?.stop() + isTracking = false + } + + // MARK: Private + + /// Operations that happen after device tracking has started. + fileprivate func finalizeStart() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + // Run the ARSession. + if self.isUsingARKit { + self.arSCNView.session.run(self.arConfiguration, options: .resetTracking) + } + + self.isTracking = true + } + } + + /// Adds subView to superView with appropriate constraints. + /// + /// - Parameter subview: The subView to add. + fileprivate func addSubviewWithConstraints(_ subview: UIView) { + // Add subview to view and setup constraints. + addSubview(subview) + subview.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + subview.leadingAnchor.constraint(equalTo: self.leadingAnchor), + subview.trailingAnchor.constraint(equalTo: self.trailingAnchor), + subview.topAnchor.constraint(equalTo: self.topAnchor), + subview.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + } + + /// Internal method to perform a hit test operation to get the transformation matrix representing the corresponding real-world point for `screenPoint`. + /// + /// - Parameter screenPoint: The screen point to determine the real world transformation matrix from. + /// - Returns: An `AGSTransformationMatrix` representing the real-world point corresponding to `screenPoint`. + fileprivate func internalHitTest(screenPoint: CGPoint) -> AGSTransformationMatrix? { + // Use the `hitTest` method on ARSCNView to get the location of `screenPoint`. + let results = arSCNView.hitTest(screenPoint, types: .existingPlaneUsingExtent) + + // Get the worldTransform from the first result; if there's no worldTransform, return nil. + guard let worldTransform = results.first?.worldTransform else { return nil } + + // Create our hit test matrix based on the worldTransform location. + // right now we ignore the orientation of the plane that was hit to find the point + // since we only use horizontal planes, when we will start using vertical planes + // we should stop suppressing the quaternion rotation to a null rotation (0,0,0,1) + let hitTestMatrix = AGSTransformationMatrix(quaternionX: 0.0, + quaternionY: 0.0, + quaternionZ: 0.0, + quaternionW: 1.0, + translationX: Double(worldTransform.columns.3.x), + translationY: Double(worldTransform.columns.3.y), + translationZ: Double(worldTransform.columns.3.z)) + + return hitTestMatrix + } +} + +// MARK: - ARSCNViewDelegate +extension ArcGISARView: ARSCNViewDelegate { + // This is not implemented as we are letting ARKit create and manage nodes. + // If you want to manage your own nodes, uncomment this and implement it in your code. +// public func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? { +// return arSCNViewDelegate?.renderer?(renderer, nodeFor: anchor) +// } + + public func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) { + arSCNViewDelegate?.renderer?(renderer, didAdd: node, for: anchor) + } + + public func renderer(_ renderer: SCNSceneRenderer, willUpdate node: SCNNode, for anchor: ARAnchor) { + arSCNViewDelegate?.renderer?(renderer, willUpdate: node, for: anchor) + } + + public func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) { + arSCNViewDelegate?.renderer?(renderer, didUpdate: node, for: anchor) + } + + public func renderer(_ renderer: SCNSceneRenderer, didRemove node: SCNNode, for anchor: ARAnchor) { + arSCNViewDelegate?.renderer?(renderer, didRemove: node, for: anchor) + } +} + +// MARK: - ARSessionObserver (via ARSCNViewDelegate) +extension ArcGISARView: ARSessionObserver { + public func session(_ session: ARSession, didFailWithError error: Error) { + arSCNViewDelegate?.session?(session, didFailWithError: error) + } + + public func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { + arSCNViewDelegate?.session?(session, cameraDidChangeTrackingState: camera) + } + + public func sessionWasInterrupted(_ session: ARSession) { + arSCNViewDelegate?.sessionWasInterrupted?(session) + } + + public func sessionInterruptionEnded(_ session: ARSession) { + arSCNViewDelegate?.sessionWasInterrupted?(session) + } + + @available(iOS 11.3, *) + public func sessionShouldAttemptRelocalization(_ session: ARSession) -> Bool { + return arSCNViewDelegate?.sessionShouldAttemptRelocalization?(session) ?? false + } + + public func session(_ session: ARSession, didOutputAudioSampleBuffer audioSampleBuffer: CMSampleBuffer) { + arSCNViewDelegate?.session?(session, didOutputAudioSampleBuffer: audioSampleBuffer) + } +} + +// MARK: - SCNSceneRendererDelegate (via ARSCNViewDelegate) +extension ArcGISARView: SCNSceneRendererDelegate { + public func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { + arSCNViewDelegate?.renderer?(renderer, updateAtTime: time) + } + + public func renderer(_ renderer: SCNSceneRenderer, didApplyAnimationsAtTime time: TimeInterval) { + arSCNViewDelegate?.renderer?(renderer, didApplyConstraintsAtTime: time) + } + + public func renderer(_ renderer: SCNSceneRenderer, didSimulatePhysicsAtTime time: TimeInterval) { + arSCNViewDelegate?.renderer?(renderer, didSimulatePhysicsAtTime: time) + } + + public func renderer(_ renderer: SCNSceneRenderer, didApplyConstraintsAtTime time: TimeInterval) { + arSCNViewDelegate?.renderer?(renderer, didApplyConstraintsAtTime: time) + } + + public func renderer(_ renderer: SCNSceneRenderer, willRenderScene scene: SCNScene, atTime time: TimeInterval) { + // If we aren't tracking yet, return. + guard isTracking else { return } + + // Get transform from SCNView.pointOfView. + guard let transform = arSCNView.pointOfView?.transform else { return } + let cameraTransform = simd_double4x4(transform) + + let cameraQuat = simd_quatd(cameraTransform) + let transformationMatrix = AGSTransformationMatrix(quaternionX: cameraQuat.vector.x, + quaternionY: cameraQuat.vector.y, + quaternionZ: cameraQuat.vector.z, + quaternionW: cameraQuat.vector.w, + translationX: cameraTransform.columns.3.x, + translationY: cameraTransform.columns.3.y, + translationZ: cameraTransform.columns.3.z) + + // Set the matrix on the camera controller. + cameraController.transformationMatrix = initialTransformation.addTransformation(transformationMatrix) + + // Set FOV on camera. + if let camera = arSCNView.session.currentFrame?.camera { + let intrinsics = camera.intrinsics + let imageResolution = camera.imageResolution + + // Get the device orientation, but don't allow non-landscape/portrait values. + let deviceOrientation = UIDevice.current.orientation + if deviceOrientation.isValidInterfaceOrientation { + lastGoodDeviceOrientation = deviceOrientation + } + sceneView.setFieldOfViewFromLensIntrinsicsWithXFocalLength(intrinsics[0][0], + yFocalLength: intrinsics[1][1], + xPrincipal: intrinsics[2][0], + yPrincipal: intrinsics[2][1], + xImageSize: Float(imageResolution.width), + yImageSize: Float(imageResolution.height), + deviceOrientation: lastGoodDeviceOrientation) + } + + // Render the Scene with the new transformation. + sceneView.renderFrame() + + // Call our arSCNViewDelegate method. + arSCNViewDelegate?.renderer?(renderer, willRenderScene: scene, atTime: time) + } + + public func renderer(_ renderer: SCNSceneRenderer, didRenderScene scene: SCNScene, atTime time: TimeInterval) { + arSCNViewDelegate?.renderer?(renderer, didRenderScene: scene, atTime: time) + } +} + +// MARK: - AGSLocationChangeHandlerDelegate +extension ArcGISARView: AGSLocationChangeHandlerDelegate { + public func locationDataSource(_ locationDataSource: AGSLocationDataSource, headingDidChange heading: Double) { + // Heading changed. + if !isUsingARKit { + // Not using ARKit, so update heading on the camera directly; otherwise, let ARKit handle heading changes. + let currentCamera = sceneView.currentViewpointCamera() + let camera = currentCamera.rotate(toHeading: heading, pitch: currentCamera.pitch, roll: currentCamera.roll) + sceneView.setViewpointCamera(camera) + } + + locationChangeHandlerDelegate?.locationDataSource?(locationDataSource, headingDidChange: heading) + } + + public func locationDataSource(_ locationDataSource: AGSLocationDataSource, locationDidChange location: AGSLocation) { + // Location changed. + guard locationTrackingMode != .ignore, var locationPoint = location.position else { return } + + // The AGSCLLocationDataSource does not include altitude information from the CLLocation when + // creating the `AGSLocation` geometry, so grab the altitude directly from the CLLocationManager. + if let clLocationDataSource = locationDataSource as? AGSCLLocationDataSource { + if let location = clLocationDataSource.locationManager.location, + location.verticalAccuracy >= 0 { + let altitude = location.altitude + locationPoint = AGSPoint(x: locationPoint.x, y: locationPoint.y, z: altitude, spatialReference: locationPoint.spatialReference) + } else { + // We don't have a valid altitude, so use the old altitude. + let oldLocationPoint = originCamera.location + locationPoint = AGSPoint(x: locationPoint.x, y: locationPoint.y, z: oldLocationPoint.z, spatialReference: locationPoint.spatialReference) + } + } + + // Always set originCamera; then reset ARKit + // Create a new camera based on our location and set it on the cameraController. + // Note for the .initial tracking mode (or if we've yet to set an initial locatin), + // we create a new camera with the location and defaults for heading, pitch, roll. + // For .continuous mode, we use the location and the old camera's heading, pitch, roll. + if locationTrackingMode == .initial || !didSetInitialLocation { + let newCamera = AGSCamera(location: locationPoint, heading: 0.0, pitch: 90.0, roll: 0.0) + originCamera = newCamera + didSetInitialLocation = true + } else if locationTrackingMode == .continuous { + originCamera = AGSCamera(location: locationPoint, heading: originCamera.heading, pitch: originCamera.pitch, roll: originCamera.roll) + } + + // If we're using ARKit, reset its tracking. + if isUsingARKit { + arSCNView.session.run(arConfiguration, options: .resetTracking) + } + + // Reset the camera controller's transformationMatrix to its initial state, the Idenity matrix. + cameraController.transformationMatrix = .identity + + if locationTrackingMode != .continuous { + // Stop the data source if the tracking mode is not continuous. + locationDataSource.stop() + } + + locationChangeHandlerDelegate?.locationDataSource?(locationDataSource, locationDidChange: location) + } + + public func locationDataSource(_ locationDataSource: AGSLocationDataSource, statusDidChange status: AGSLocationDataSourceStatus) { + // Status changed. + locationChangeHandlerDelegate?.locationDataSource?(locationDataSource, statusDidChange: status) + } +} diff --git a/Toolkit/ArcGISToolkit/CancelGroup.swift b/Toolkit/ArcGISToolkit/CancelGroup.swift index ed5324d4..46f138c7 100644 --- a/Toolkit/ArcGISToolkit/CancelGroup.swift +++ b/Toolkit/ArcGISToolkit/CancelGroup.swift @@ -18,22 +18,20 @@ import Foundation Wraps multiple AGSCancelables into a single cancelable object. */ @objc -public class CancelGroup: NSObject, AGSCancelable{ - +public class CancelGroup: NSObject, AGSCancelable { /// Cancels all the AGSCancelables in the group. - public func cancel(){ - children.forEach{ $0.cancel() } + public func cancel() { + children.forEach { $0.cancel() } _canceled = true } private var _canceled: Bool = false /// Whether or not the group is canceled. - public func isCanceled() -> Bool{ + public func isCanceled() -> Bool { return _canceled } /// The children associated with this group. public var children: [AGSCancelable] = [AGSCancelable]() - } diff --git a/Toolkit/ArcGISToolkit/Coalescer.swift b/Toolkit/ArcGISToolkit/Coalescer.swift index 26a42b52..3d27d7e3 100644 --- a/Toolkit/ArcGISToolkit/Coalescer.swift +++ b/Toolkit/ArcGISToolkit/Coalescer.swift @@ -12,7 +12,6 @@ // limitations under the License. internal class Coalescer { - // Class to coalesce actions into intervals. // This is helpful for the Scalebar because we get updates to the visibleArea up to 60hz and we // don't need to redraw the Scalebar that often @@ -21,7 +20,7 @@ internal class Coalescer { var interval: DispatchTimeInterval var action: (() -> Void) - init (dispatchQueue: DispatchQueue, interval: DispatchTimeInterval, action: @escaping (()->Void)){ + init (dispatchQueue: DispatchQueue, interval: DispatchTimeInterval, action: @escaping (() -> Void)) { self.dispatchQueue = dispatchQueue self.interval = interval self.action = action @@ -29,11 +28,10 @@ internal class Coalescer { private var count = 0 - func ping(){ - + func ping() { // synchronize to a serial queue, in this case main thread - if !Thread.isMainThread{ - DispatchQueue.main.async{ self.ping() } + if !Thread.isMainThread { + DispatchQueue.main.async { self.ping() } return } @@ -41,9 +39,8 @@ internal class Coalescer { count += 1 // the first time the count is incremented, it dispatches the action - if count == 1{ - dispatchQueue.asyncAfter(deadline: DispatchTime.now() + interval){ - + if count == 1 { + dispatchQueue.asyncAfter(deadline: DispatchTime.now() + interval) { // call the action self.action() @@ -51,19 +48,14 @@ internal class Coalescer { self.resetCount() } } - } - private func resetCount(){ - + private func resetCount() { // synchronize to a serial queue, in this case main thread - if !Thread.isMainThread{ - DispatchQueue.main.async{ self.count = 0 } - } - else{ + if !Thread.isMainThread { + DispatchQueue.main.async { self.count = 0 } + } else { self.count = 0 } } - } - diff --git a/Toolkit/ArcGISToolkit/Compass.swift b/Toolkit/ArcGISToolkit/Compass.swift index 3bb07305..62391040 100644 --- a/Toolkit/ArcGISToolkit/Compass.swift +++ b/Toolkit/ArcGISToolkit/Compass.swift @@ -15,7 +15,6 @@ import UIKit import ArcGIS public class Compass: UIImageView { - public var heading: Double = 0.0 { // Rotation - bound to MapView.MapRotation didSet { mapView.setViewpointRotation(heading, completion: nil) @@ -67,16 +66,14 @@ public class Compass: UIImageView { animateCompass() // Add Compass as an observer of the mapView's rotation. - rotationObservation = mapView.observe(\.rotation, options: .new) {[weak self] (mapView, change) in - - guard let rotation = change.newValue else{ + rotationObservation = mapView.observe(\.rotation, options: .new) {[weak self] (_, change) in + guard let rotation = change.newValue else { return } // make sure that UI changes are made on the main thread - DispatchQueue.main.async{ - - guard let self = self else{ + DispatchQueue.main.async { + guard let self = self else { return } @@ -87,14 +84,14 @@ public class Compass: UIImageView { self.animateCompass() } } - } - required public init?(coder aDecoder: NSCoder) { + public required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - @objc func compassTapped(){ + @objc + func compassTapped() { mapView.setViewpointRotation(0, completion: nil) } diff --git a/Toolkit/ArcGISToolkit/Extensions.swift b/Toolkit/ArcGISToolkit/Extensions.swift index e331e837..6db6113b 100644 --- a/Toolkit/ArcGISToolkit/Extensions.swift +++ b/Toolkit/ArcGISToolkit/Extensions.swift @@ -29,5 +29,3 @@ extension UIApplication { return controller } } - - diff --git a/Toolkit/ArcGISToolkit/Info.plist b/Toolkit/ArcGISToolkit/Info.plist index 9cdfeaeb..aa1b0165 100644 --- a/Toolkit/ArcGISToolkit/Info.plist +++ b/Toolkit/ArcGISToolkit/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 100.5 + 100.6 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSPrincipalClass diff --git a/Toolkit/ArcGISToolkit/JobManager.swift b/Toolkit/ArcGISToolkit/JobManager.swift index dc893294..c4384d12 100644 --- a/Toolkit/ArcGISToolkit/JobManager.swift +++ b/Toolkit/ArcGISToolkit/JobManager.swift @@ -21,7 +21,6 @@ public typealias JobCompletionHandler = (Any?, Error?) -> Void // // MARK: JobManager - private let _jobManagerSharedInstance = JobManager(jobManagerID: "shared") /** @@ -40,168 +39,138 @@ private let _jobManagerSharedInstance = JobManager(jobManagerID: "shared") method. */ public class JobManager: NSObject { - /// Default shared instance of the JobManager. public class var shared: JobManager { return _jobManagerSharedInstance } - public private(set) var jobManagerID: String + /// The JobManager ID, provided during initialization. + public let jobManagerID: String - // Flag to signify that we shouldn't write to defaults - // Maybe we are currently reading from the defaults so it's pointless to write to them. - // Or maybe we are waiting until a group of modifications are made before writing to the defaults. + /// Flag to signify that we shouldn't write to User Defaults. + /// + /// Used internally when reading stored `AGSJob`s from the User Defaults during init(). private var suppressSaveToUserDefaults = false private var kvoContext = 0 - deinit { - jobs.forEach { unObserveJobStatus(job: $0) } - } - - public private(set) var keyedJobs = [String: AGSJob](){ - didSet{ - self.updateJobsArray() - saveJobsToUserDefaults() + /// A dictionary of Unique IDs and `AGSJob`s that the `JobManager` is managing. + public private(set) var keyedJobs = [String: AGSJob]() { + willSet { + // Need `self` because of a Swift bug. + self.keyedJobs.values.forEach { unObserveJobStatus(job: $0) } + } + didSet { + keyedJobs.values.forEach { observeJobStatus(job: $0) } + + // If there was a change, then re-store the serialized AGSJobs in UserDefaults + if keyedJobs != oldValue { + saveJobsToUserDefaults() + } } } - public private(set) var jobs = [AGSJob]() - private func updateJobsArray(){ - - // when our jobs array changes we need to observe the jobs' status - // that we aren't currently observing. The best way to do that is to - // just unObserve all, then re-observe all job status events - - // so first un-observe all current jobs - jobs.forEach { unObserveJobStatus(job: $0) } - - // set new jobs array - jobs = keyedJobs.map{ $0.1 } - - // now observe all jobs - jobs.forEach { observeJobStatus(job: $0) } + + /// A convenience accessor to the `AGSJob`s that the `JobManager` is managing. + public var jobs: [AGSJob] { + return Array(keyedJobs.values) } - private func toJSON() -> JSONDictionary{ - var d = [String: Any]() - for (jobID, job) in self.keyedJobs{ - if let json = try? job.toJSON(){ - d[jobID] = json - } - } - return d + private var jobsDefaultsKey: String { + return "com.esri.arcgis.runtime.toolkit.jobManager.\(jobManagerID).jobs" } + private var jobStatusObservations = [String: NSKeyValueObservation]() + /// Create a JobManager with an ID. - public required init(jobManagerID: String){ + /// + /// - Parameter jobManagerID: An arbitrary identifier for this JobManager. + public required init(jobManagerID: String) { self.jobManagerID = jobManagerID super.init() - if let d = UserDefaults.standard.dictionary(forKey: self.jobsDefaultsKey){ - suppressSaveToUserDefaults = true - self.instantiateStateFromJSON(json: d) - suppressSaveToUserDefaults = false - } + loadJobsFromUserDefaults() } - private func instantiateStateFromJSON(json: JSONDictionary){ - for (jobID, value) in json{ - if let jobJSON = value as? JSONDictionary{ - if let job = (try? AGSJob.fromJSON(jobJSON)) as? AGSJob{ - self.keyedJobs[jobID] = job - } - } - } + deinit { + keyedJobs.values.forEach { unObserveJobStatus(job: $0) } } - private var jobsDefaultsKey: String { - return "com.esri.arcgis.runtime.toolkit.jobManager.\(jobManagerID).jobs" + private func toJSON() -> JSONDictionary { + return keyedJobs.compactMapValues { try? $0.toJSON() } } - // observing job status code - - private func observeJobStatus(job: AGSJob){ - job.addObserver(self, forKeyPath: #keyPath(AGSJob.status), options: [], context: &kvoContext) - } - private func unObserveJobStatus(job: AGSJob){ - job.removeObserver(self, forKeyPath: #keyPath(AGSJob.status)) + // Observing job status code + private func observeJobStatus(job: AGSJob) { + let observer = job.observe(\.status, options: [.new]) { [weak self] (_, _) in + self?.saveJobsToUserDefaults() + } + jobStatusObservations[job.serverJobID] = observer } - override public func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { - if context == &kvoContext{ - if keyPath == #keyPath(AGSJob.status){ - // when a job's status changes we need to save to user defaults again - // so that the correct job state is reflected in our saved state - saveJobsToUserDefaults() - } - } - else { - super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + private func unObserveJobStatus(job: AGSJob) { + if let observer = jobStatusObservations[job.serverJobID] { + observer.invalidate() + jobStatusObservations.removeValue(forKey: job.serverJobID) } } - - /** - Register a Job with the JobManager. - Returns a uniqueID for the Job. - */ - @discardableResult public func register(job: AGSJob) -> String{ + + /// Register an `AGSJob` with the `JobManager`. + /// + /// - Parameter job: The AGSJob to register. + /// - Returns: A unique ID for the AGSJob's registration which can be used to unregister the job. + @discardableResult + public func register(job: AGSJob) -> String { let jobUniqueID = NSUUID().uuidString keyedJobs[jobUniqueID] = job return jobUniqueID } - /** - Unregister a Job with the JobManager - Returns true if it found the Job and was able to unregister it. - */ - @discardableResult public func unregister(job: AGSJob) -> Bool{ - for (key, value) in keyedJobs{ - if value === job{ - keyedJobs.removeValue(forKey: key) - return true - } + /// Unregister an `AGSJob` from the `JobManager`. + /// + /// - Parameter job: The job to unregister. + /// - Returns: `true` if the job was found, `false` otherwise. + @discardableResult + public func unregister(job: AGSJob) -> Bool { + if let jobUniqueID = keyedJobs.first(where: { $0.value === job })?.key { + keyedJobs[jobUniqueID] = nil + return true } return false } - /** - Unregister a Job with the JobManager, using the Job's unique ID. - Returns true if it found the Job and was able to unregister it. - */ - @discardableResult public func unregister(jobUniqueID: String) -> Bool{ + /// Unregister an `AGSJob` from the `JobManager`. + /// + /// - Parameter jobUniqueID: The job's unique ID, returned from calling `register()`. + /// - Returns: `true` if the Job was found, `false` otherwise. + @discardableResult + public func unregister(jobUniqueID: String) -> Bool { let removed = keyedJobs.removeValue(forKey: jobUniqueID) != nil return removed } - /// Clears the finished Jobs from the Job manager. - public func clearFinishedJobs(){ - - suppressSaveToUserDefaults = true - for (jobUniqueID, job) in keyedJobs{ - if job.status == .failed || job.status == .succeeded{ - keyedJobs.removeValue(forKey: jobUniqueID) - } + /// Clears the finished `AGSJob`s from the `JobManager`. + public func clearFinishedJobs() { + keyedJobs = keyedJobs.filter { + let status = $0.value.status + return !(status == .failed || status == .succeeded) } - suppressSaveToUserDefaults = false - saveJobsToUserDefaults() - } - /** - Checks the status for all Jobs and returns when completed. - */ - @discardableResult public func checkStatusForAllJobs(completion: @escaping (Bool)->Void) -> AGSCancelable{ - - + /// Checks the status for all `AGSJob`s calling a completion block when completed. + /// + /// - Parameter completion: A completion block that is called when the status of all `AGSJob`s has been checked. Passed `true` if all statuses were retrieves successfully, or `false` otherwise. + /// - Returns: An `AGSCancelable` group that can be used to cancel the status checks. + @discardableResult + public func checkStatusForAllJobs(completion: @escaping (Bool) -> Void) -> AGSCancelable { let cancelGroup = CancelGroup() let group = DispatchGroup() var completedWithoutErrors = true - keyedJobs.forEach{ + keyedJobs.forEach { group.enter() - let cancellable = $0.1.checkStatus{ error in - if error != nil{ + let cancellable = $0.value.checkStatus { error in + if error != nil { completedWithoutErrors = false } group.leave() @@ -209,61 +178,77 @@ public class JobManager: NSObject { cancelGroup.children.append(cancellable) } - group.notify(queue: DispatchQueue.main){ + group.notify(queue: .main) { completion(completedWithoutErrors) } return cancelGroup } - /** - Checks the status for all Jobs and calls the completion handler when done. - this method can be called from: - `func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping` - */ + /// A helper function to call from a UIApplication's delegate when using iOS's Background Fetch capabilities. + /// + /// Checks the status for all `AGSJob`s and calls the completion handler when done. + /// + /// This method can be called from: + /// `func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void))` + /// + /// See [Apple's documentation](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623125-application) + /// for more details. + /// + /// - Parameters: + /// - application: See [Apple's documentation](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623125-application) + /// - completionHandler: See [Apple's documentation](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623125-application) public func application(application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - if self.jobs.count > 0{ - self.checkStatusForAllJobs{ completedWithoutErrors in - if completedWithoutErrors{ + if keyedJobs.isEmpty { + return completionHandler(.noData) + } else { + checkStatusForAllJobs { completedWithoutErrors in + if completedWithoutErrors { completionHandler(.newData) - } - else{ + } else { completionHandler(.failed) } } } - else{ - completionHandler(.noData) - } } - - /// Resume all paused and not-started jobs. - public func resumeAllPausedJobs(statusHandler: @escaping JobStatusHandler, completion: @escaping JobCompletionHandler){ - keyedJobs.filter{ $0.1.status == .paused || $0.1.status == .notStarted}.forEach{ - $0.1.start(statusHandler: statusHandler, completion:completion) + /// Resume all paused and not-started `AGSJob`s. + /// + /// An `AGSJob`'s status is `.paused` when it is created from JSON. So any `AGSJob`s that have been reloaded from User Defaults will be in the `.paused` state. + /// + /// See the [Tasks and Jobs](https://developers.arcgis.com/ios/latest/swift/guide/tasks-and-jobs.htm#ESRI_SECTION1_BA1D597878F049278CC787A1C04F9734) + /// guide topic for more details. + /// + /// - Parameters: + /// - statusHandler: A callback block that is called by each active `AGSJob` when the `AGSJob`'s status changes or its messages array is updated. + /// - completion: A callback block that is called by each `AGSJob` when it has completed. + public func resumeAllPausedJobs(statusHandler: @escaping JobStatusHandler, completion: @escaping JobCompletionHandler) { + keyedJobs.lazy.filter { $0.value.status == .paused || $0.value.status == .notStarted }.forEach { + $0.value.start(statusHandler: statusHandler, completion: completion) } } - /** - Saves all Jobs to User Defaults. - This happens automatically when the jobs are registered/unregistered. - It also happens when job status changes. - */ - private func saveJobsToUserDefaults(){ + /// Saves all managed `AGSJob`s to User Defaults. + /// + /// This happens automatically when the `AGSJob`s are registered/unregistered. + /// It also happens when an `AGSJob`'s status changes. + private func saveJobsToUserDefaults() { + guard !suppressSaveToUserDefaults else { return } - if suppressSaveToUserDefaults{ - return + UserDefaults.standard.set(self.toJSON(), forKey: jobsDefaultsKey) + } + + /// Load any `AGSJob`s that have been saved to User Defaults. + /// + /// This happens when the `JobManager` is initialized. All `AGSJob`s will be in the `.paused` state when first restored from JSON. + /// + /// See the [Tasks and Jobs](https://developers.arcgis.com/ios/latest/swift/guide/tasks-and-jobs.htm#ESRI_SECTION1_BA1D597878F049278CC787A1C04F9734) + /// guide topic for more details. + private func loadJobsFromUserDefaults() { + if let storedJobsJSON = UserDefaults.standard.dictionary(forKey: jobsDefaultsKey) { + suppressSaveToUserDefaults = true + keyedJobs = storedJobsJSON.compactMapValues { $0 is JSONDictionary ? (try? AGSJob.fromJSON($0)) as? AGSJob : nil } + suppressSaveToUserDefaults = false } - - let d = self.toJSON() - UserDefaults.standard.set(d, forKey: self.jobsDefaultsKey) } } - - - - - - - diff --git a/Toolkit/ArcGISToolkit/LegendViewController.swift b/Toolkit/ArcGISToolkit/LegendViewController.swift index 2af22155..d23f698a 100644 --- a/Toolkit/ArcGISToolkit/LegendViewController.swift +++ b/Toolkit/ArcGISToolkit/LegendViewController.swift @@ -15,24 +15,21 @@ import UIKit import ArcGIS public class LegendViewController: UIViewController, UITableViewDelegate, UITableViewDataSource { - - public var geoView: AGSGeoView?{ - didSet - { + public var geoView: AGSGeoView? { + didSet { if geoView != nil { if let mapView = geoView as? AGSMapView { - mapView.map?.load(completion: { [weak self] (error) in + mapView.map?.load { [weak self] (_) in if let basemap = mapView.map?.basemap { - basemap.load(completion: { (error) in + basemap.load { (_) in self?.updateLayerData() - }) + } } - }) - } - else if let sceneView = geoView as? AGSSceneView { - sceneView.scene?.load(completion: {[weak self] (error) in + } + } else if let sceneView = geoView as? AGSSceneView { + sceneView.scene?.load(completion: {[weak self] (_) in if let basemap = sceneView.scene?.basemap { - basemap.load(completion: { (error) in + basemap.load(completion: { (_) in self?.updateLayerData() }) } @@ -42,7 +39,7 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl //set layerViewStateChangedHandler if let geoView = geoView { geoView.layerViewStateChangedHandler = { [weak self] (layer: AGSLayer, layerViewState: AGSLayerViewState) in - DispatchQueue.main.async{ + DispatchQueue.main.async { self?.updateLegendArray() } } @@ -52,14 +49,12 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl } public var respectScaleRange: Bool = true { - didSet - { + didSet { updateLayerData() } } public var reverseLayerOrder: Bool = false { - didSet - { + didSet { updateLayerData() } } @@ -68,10 +63,10 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl @IBOutlet private var tableView: UITableView? // dictionary of legend infos; keys are AGSLayerContent objectIdentifier values - private var legendInfos = [UInt:[AGSLegendInfo]]() + private var legendInfos = [UInt: [AGSLegendInfo]]() // dictionary of symbol swatches (images); keys are the symbol used to create the swatch - private var symbolSwatches = [AGSSymbol:UIImage]() + private var symbolSwatches = [AGSSymbol: UIImage]() // the array of all layers in the map, including basemap layers private var layerArray = [AGSLayer]() @@ -93,14 +88,14 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl fatalError("use the method `makeLegendViewController` instead") } - required public init?(coder aDecoder: NSCoder) { + public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } // use this static method to instantiate the view controller from our storyboard - static public func makeLegendViewController(geoView: AGSGeoView? = nil) -> LegendViewController? { + public static func makeLegendViewController(geoView: AGSGeoView? = nil) -> LegendViewController? { // get the bundle and then the storyboard - let bundle = Bundle.init(for: LegendViewController.self) + let bundle = Bundle(for: LegendViewController.self) let storyboard = UIStoryboard(name: "Legend", bundle: bundle) // create the legend VC from the storyboard @@ -124,21 +119,19 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl var cell: UITableViewCell! // configure the cell... - let rowItem:AnyObject = legendArray[indexPath.row] + let rowItem: AnyObject = legendArray[indexPath.row] if let layer = rowItem as? AGSLayer { // item is a layer cell = tableView.dequeueReusableCell(withIdentifier: LegendViewController.layerTitleCellID)! let textLabel = cell.viewWithTag(LegendViewController.labelTag) as? UILabel textLabel?.text = layer.name - } - else if let layerContent = rowItem as? AGSLayerContent { + } else if let layerContent = rowItem as? AGSLayerContent { // item is not a layer, but still implements AGSLayerContent // so it's a sublayer cell = tableView.dequeueReusableCell(withIdentifier: LegendViewController.sublayerTitleCellID)! let textLabel = cell.viewWithTag(LegendViewController.labelTag) as? UILabel textLabel?.text = layerContent.name - } - else if let legendInfo = rowItem as? AGSLegendInfo { + } else if let legendInfo = rowItem as? AGSLegendInfo { // item is a legendInfo cell = tableView.dequeueReusableCell(withIdentifier: LegendViewController.legendInfoCellID)! let textLabel = cell.viewWithTag(LegendViewController.labelTag) as? UILabel @@ -152,18 +145,17 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl // we have a swatch, so set it into the imageView and stop the activity indicator imageview?.image = swatch activityIndicator.stopAnimating() - } - else { + } else { // tag the cell so we know what index path it's being used for cell.tag = indexPath.hashValue // we don't have a swatch for the given symbol, start the activity indicator // and create the swatch activityIndicator.startAnimating() - symbol.createSwatch(completion: { [weak self] (image, error) -> Void in + symbol.createSwatch(completion: { [weak self] (image, _) -> Void in // make sure this is the cell we still care about and that it // wasn't already recycled by the time we get the swatch - if cell.tag != indexPath.hashValue{ + if cell.tag != indexPath.hashValue { return } @@ -182,7 +174,6 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl // update the legend data for all layers and sublayers private func updateLayerData() { - // remove all saved data legendInfos.removeAll() symbolSwatches.removeAll() @@ -196,7 +187,7 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl private func populateLayerArray() { layerArray.removeAll() - var basemap:AGSBasemap? + var basemap: AGSBasemap? // Because the layers in the map's operationalLayers property // are drawn from the bottom up (the first layer in the array is @@ -208,8 +199,7 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl if let layers = mapView.map?.operationalLayers as AnyObject as? [AGSLayer] { reversedLayerArray.append(contentsOf: layers) } - } - else if let sceneView = geoView as? AGSSceneView { + } else if let sceneView = geoView as? AGSSceneView { basemap = sceneView.scene?.basemap if let layers = sceneView.scene?.operationalLayers as AnyObject as? [AGSLayer] { reversedLayerArray.append(contentsOf: layers) @@ -236,10 +226,9 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl // This is "!reverseLayerOrder" because the layers are by default reversed // and will only NOT be reversed here if reverseLayerOrder == true. - if !reverseLayerOrder && reversedLayerArray.count > 0 { + if !reverseLayerOrder && !reversedLayerArray.isEmpty { layerArray.append(contentsOf: reversedLayerArray.reversed()) - } - else { + } else { // we are reversing the order, so just use the original reversedLayerArray layerArray.append(contentsOf: reversedLayerArray) } @@ -257,11 +246,10 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl private func loadIndividualLayer(_ layerContent: AGSLayerContent) { if let layer = layerContent as? AGSLayer { // we have an AGSLayer, so make sure it's loaded - layer.load { [weak self] (error) in + layer.load { [weak self] (_) in self?.loadSublayersOrLegendInfos(layerContent) } - } - else { + } else { self.loadSublayersOrLegendInfos(layerContent) } } @@ -271,22 +259,21 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl // the AGSLayer is loaded for this layer/sublayer, so // set the contents changed handler. layerContent.subLayerContentsChangedHandler = { [weak self] () in - DispatchQueue.main.async{ + DispatchQueue.main.async { self?.updateLegendArray() } } // if we have sublayer contents, load those as well - if layerContent.subLayerContents.count > 0 { + if !layerContent.subLayerContents.isEmpty { layerContent.subLayerContents.forEach { self.loadIndividualLayer($0) } - } - else { + } else { // fetch the legend infos - layerContent.fetchLegendInfos(completion: { [weak self] (legendInfos, error) in + layerContent.fetchLegendInfos { [weak self] (legendInfos, _) in //handle legendInfos self?.legendInfos[LegendViewController.objectIdentifierFor(layerContent)] = legendInfos self?.updateLegendArray() - }) + } } } @@ -295,7 +282,6 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl // items once layers load. Updating everything here will make // implementing the table view data source methods much easier. private func updateLegendArray() { - legendArray.removeAll() // filter any layers which are not visible or not showInLegend @@ -320,8 +306,7 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl if featureCollectionLayer.layers.count > 1 { legendArray.append(layerContent) } - } - else { + } else { legendArray.append(layerContent) } updateLayerLegend(layerContent) @@ -334,17 +319,16 @@ public class LegendViewController: UIViewController, UITableViewDelegate, UITabl // Handle subLayerContents and legend infos; this method assumes that // the incoming layerContent argument is visible and showInLegend == true. private func updateLayerLegend(_ layerContent: AGSLayerContent) { - if layerContent.subLayerContents.count > 0 { + if !layerContent.subLayerContents.isEmpty { // filter any sublayers which are not visible or not showInLegend let sublayerContents = layerContent.subLayerContents.filter { $0.isVisible && $0.showInLegend } - sublayerContents.forEach({ (layerContent) in + sublayerContents.forEach { (layerContent) in legendArray.append(layerContent) updateLayerLegend(layerContent) - }) - } - else { - if let internalLegendInfos:[AGSLegendInfo] = legendInfos[LegendViewController.objectIdentifierFor(layerContent as AnyObject)] { - legendArray = legendArray + internalLegendInfos + } + } else { + if let internalLegendInfos: [AGSLegendInfo] = legendInfos[LegendViewController.objectIdentifierFor(layerContent as AnyObject)] { + legendArray += internalLegendInfos } } } diff --git a/Toolkit/ArcGISToolkit/MapViewController.swift b/Toolkit/ArcGISToolkit/MapViewController.swift index e4e744a3..0643a481 100644 --- a/Toolkit/ArcGISToolkit/MapViewController.swift +++ b/Toolkit/ArcGISToolkit/MapViewController.swift @@ -15,7 +15,6 @@ import UIKit import ArcGIS open class MapViewController: UIViewController { - public let mapView = AGSMapView(frame: CGRect.zero) override open func viewDidLoad() { @@ -25,6 +24,4 @@ open class MapViewController: UIViewController { mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(mapView) } - } - diff --git a/Toolkit/ArcGISToolkit/MeasureToolbar.swift b/Toolkit/ArcGISToolkit/MeasureToolbar.swift index 64039924..baae9de5 100644 --- a/Toolkit/ArcGISToolkit/MeasureToolbar.swift +++ b/Toolkit/ArcGISToolkit/MeasureToolbar.swift @@ -19,11 +19,10 @@ struct Measurement { let unit: AGSUnit } -class MeasureResultView: UIView{ - +class MeasureResultView: UIView { var measurement: Measurement? { - didSet{ - if let measurement = measurement{ + didSet { + if let measurement = measurement { valueLabel.text = valueString() unitButton.setTitle(stringForUnit(measurement.unit), for: .normal) unitButton.isHidden = false @@ -32,9 +31,9 @@ class MeasureResultView: UIView{ } } - var helpText: String?{ - didSet{ - if let helpText = helpText{ + var helpText: String? { + didSet { + if let helpText = helpText { valueLabel.text = helpText unitButton.isHidden = true unitButton.setTitle(nil, for: .normal) @@ -47,14 +46,13 @@ class MeasureResultView: UIView{ var stackView: UIStackView let numberFormatter = NumberFormatter() - var buttonTapHandler: (()->(Void))? + var buttonTapHandler: (() -> Void)? - override var intrinsicContentSize: CGSize{ + override var intrinsicContentSize: CGSize { return stackView.systemLayoutSizeFitting(CGSize(width: 0, height: 0), withHorizontalFittingPriority: .fittingSizeLevel, verticalFittingPriority: .fittingSizeLevel) } override init(frame: CGRect) { - numberFormatter.numberStyle = .decimal numberFormatter.minimumFractionDigits = 0 numberFormatter.maximumFractionDigits = 2 @@ -114,29 +112,28 @@ class MeasureResultView: UIView{ fatalError("init(coder:) has not been implemented") } - override public class var requiresConstraintBasedLayout: Bool { + override class var requiresConstraintBasedLayout: Bool { return true } - @objc func buttonTap(){ - guard unitButton.isHidden == false else{ + @objc + func buttonTap() { + guard unitButton.isHidden == false else { return } buttonTapHandler?() } - func valueString() -> String?{ - - guard let measurement = measurement else{ + func valueString() -> String? { + guard let measurement = measurement else { return "" } // if number greater than some value then don't show fraction - if measurement.value > 1_000{ + if measurement.value > 1_000 { numberFormatter.maximumFractionDigits = 0 - } - else{ + } else { numberFormatter.maximumFractionDigits = 2 } @@ -147,24 +144,21 @@ class MeasureResultView: UIView{ return measurementValueString } - func stringForUnit(_ unit: AGSUnit?) -> String?{ + func stringForUnit(_ unit: AGSUnit?) -> String? { guard let unit = unit else { return "" } return unit.pluralDisplayName } - } -private enum MeasureToolbarMode{ +private enum MeasureToolbarMode { case length case area case feature } public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { - - // Exposed so that the user can customize the sketch editor styles. // Consumers of the MeasureToolbar should not mutate the sketch editor state // other than it's style. @@ -180,7 +174,7 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { } public var mapView: AGSMapView? { - didSet{ + didSet { guard mapView != oldValue else { return } unbindFromMapView(mapView: oldValue) bindToMapView(mapView: mapView) @@ -221,7 +215,7 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { } } - private let resultView: MeasureResultView = MeasureResultView() + private let resultView = MeasureResultView() private var undoButton: UIBarButtonItem! private var redoButton: UIBarButtonItem! @@ -255,12 +249,12 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { sharedInitialization() } - required public init?(coder aDecoder: NSCoder) { + public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) sharedInitialization() } - convenience public init(mapView: AGSMapView){ + public convenience init(mapView: AGSMapView) { self.init(frame: .zero) self.mapView = mapView // because didSet doesn't happen in constructors @@ -270,8 +264,7 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { private var sketchModeButtons: [UIBarButtonItem] = [] private var selectModeButtons: [UIBarButtonItem] = [] - private func sharedInitialization(){ - + private func sharedInitialization() { let bundle = Bundle(for: type(of: self)) let measureLengthImage = UIImage(named: "MeasureLength", in: bundle, compatibleWith: nil) let measureAreaImage = UIImage(named: "MeasureArea", in: bundle, compatibleWith: nil) @@ -281,7 +274,7 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { undoButton = UIBarButtonItem(image: undoImage, style: .plain, target: nil, action: nil) redoButton = UIBarButtonItem(image: redoImage, style: .plain, target: nil, action: nil) - clearButton = UIBarButtonItem(barButtonSystemItem: .trash, target: nil, action:nil) + clearButton = UIBarButtonItem(barButtonSystemItem: .trash, target: nil, action: nil) segControl = UISegmentedControl(items: ["Length", "Area", "Select"]) segControl.setImage(measureLengthImage, forSegmentAt: 0) @@ -319,10 +312,10 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { NotificationCenter.default.addObserver(self, selector: #selector(sketchEditorGeometryDidChange(_:)), name: .AGSSketchEditorGeometryDidChange, object: nil) } - private func bindToMapView(mapView: AGSMapView?){ + private func bindToMapView(mapView: AGSMapView?) { mapView?.touchDelegate = self - if let mapView = mapView{ + if let mapView = mapView { // defaults for symbology selectionLineSymbol = lineSketchEditor.style.lineSymbol let fillColor = mapView.selectionProperties.color.withAlphaComponent(0.25) @@ -339,22 +332,21 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { } } - private func unbindFromMapView(mapView: AGSMapView?){ + private func unbindFromMapView(mapView: AGSMapView?) { mapView?.sketchEditor = nil mapView?.touchDelegate = nil - if let mapView = mapView, let selectionOverlay = selectionOverlay{ + if let mapView = mapView, let selectionOverlay = selectionOverlay { mapView.graphicsOverlays.remove(selectionOverlay) } } private var didSetConstraints: Bool = false - public override func updateConstraints() { - + override public func updateConstraints() { super.updateConstraints() - guard !didSetConstraints else{ + guard !didSetConstraints else { return } @@ -395,22 +387,19 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { return true } - @objc private func segmentControlValueChanged(){ - - if segControl.selectedSegmentIndex == 0{ + @objc + private func segmentControlValueChanged() { + if segControl.selectedSegmentIndex == 0 { startLineMode() - } - else if segControl.selectedSegmentIndex == 1{ + } else if segControl.selectedSegmentIndex == 1 { startAreaMode() - } - else if segControl.selectedSegmentIndex == 2{ + } else if segControl.selectedSegmentIndex == 2 { startFeatureMode() } } - private func startLineMode(){ - - guard mode != MeasureToolbarMode.length else{ + private func startLineMode() { + guard mode != MeasureToolbarMode.length else { return } @@ -419,14 +408,13 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { self.items = sketchModeButtons mapView?.sketchEditor = lineSketchEditor - if !lineSketchEditor.isStarted{ + if !lineSketchEditor.isStarted { lineSketchEditor.start(with: AGSSketchCreationMode.polyline) } } - private func startAreaMode(){ - - guard mode != MeasureToolbarMode.area else{ + private func startAreaMode() { + guard mode != MeasureToolbarMode.area else { return } @@ -435,14 +423,13 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { self.items = sketchModeButtons mapView?.sketchEditor = areaSketchEditor - if !areaSketchEditor.isStarted{ + if !areaSketchEditor.isStarted { areaSketchEditor.start(with: AGSSketchCreationMode.polygon) } } - private func startFeatureMode(){ - - guard mode != MeasureToolbarMode.feature else{ + private func startFeatureMode() { + guard mode != MeasureToolbarMode.feature else { return } @@ -452,31 +439,32 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { mapView?.sketchEditor = nil } - @objc private func undoButtonTap(){ + @objc + private func undoButtonTap() { mapView?.sketchEditor?.undoManager.undo() } - @objc private func redoButtonTap(){ + @objc + private func redoButtonTap() { mapView?.sketchEditor?.undoManager.redo() } - @objc private func clearButtonTap(){ + @objc + private func clearButtonTap() { mapView?.sketchEditor?.clearGeometry() } - private func unitsButtonTap(){ + private func unitsButtonTap() { let units: [AGSUnit] let selectedUnit: AGSUnit if mapView?.sketchEditor == lineSketchEditor || selectedGeometry?.geometryType == .polyline { - let linearUnitIDs: [AGSLinearUnitID] = [.centimeters, .feet, .inches, .kilometers, .meters, .miles, .millimeters, .nauticalMiles, .yards] units = linearUnitIDs.compactMap { AGSLinearUnit(unitID: $0) } selectedUnit = selectedLinearUnit } else if mapView?.sketchEditor == areaSketchEditor || selectedGeometry?.geometryType == .envelope || selectedGeometry?.geometryType == .polygon { - let areaUnitIDs: [AGSAreaUnitID] = [.acres, .hectares, .squareCentimeters, .squareDecimeters, .squareFeet, .squareKilometers, .squareMeters, .squareMillimeters, .squareMiles, .squareYards] units = areaUnitIDs.compactMap { AGSAreaUnit(unitID: $0) } selectedUnit = selectedAreaUnit @@ -499,7 +487,8 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { /// `Notification.Name.AGSSketchEditorGeometryDidChange` being posted. /// /// - Parameter notification: The posted notification. - @objc private func sketchEditorGeometryDidChange(_ notification: Notification) { + @objc + private func sketchEditorGeometryDidChange(_ notification: Notification) { guard let sketchEditor = notification.object as? AGSSketchEditor, sketchEditor == lineSketchEditor || sketchEditor == areaSketchEditor else { return @@ -521,28 +510,26 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { if let geometry = selectedGeometry { let measurement = Measurement(value: calculateMeasurement(of: geometry), unit: unit(for: geometry)) resultView.measurement = measurement - } else{ + } else { resultView.helpText = "Tap a feature" } } } - private func calculateSketchLength() -> Double{ - - guard mapView?.sketchEditor?.isSketchValid == true, let geom = mapView?.sketchEditor?.geometry else{ + private func calculateSketchLength() -> Double { + guard mapView?.sketchEditor?.isSketchValid == true, let geom = mapView?.sketchEditor?.geometry else { return 0 } return calculateLength(of: geom) } - private func calculateLength(of geom: AGSGeometry) -> Double{ - + private func calculateLength(of geom: AGSGeometry) -> Double { // if planar is very large then just return that, geodetic might take too long - if let linearUnit = geom.spatialReference?.unit as? AGSLinearUnit{ + if let linearUnit = geom.spatialReference?.unit as? AGSLinearUnit { var planar = AGSGeometryEngine.length(of: geom) planar = linearUnit.convert(toMeters: planar) - if planar > planarLengthMetersThreshold{ + if planar > planarLengthMetersThreshold { let planarDisplay = AGSLinearUnit.meters().convert(planar, to: selectedLinearUnit) //`print("returning planar length... \(planar) sq meters") return planarDisplay @@ -553,22 +540,20 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { return AGSGeometryEngine.geodeticLength(of: geom, lengthUnit: selectedLinearUnit, curveType: geodeticCurveType) } - private func calculateSketchArea() -> Double{ - - guard mapView?.sketchEditor?.isSketchValid == true, let geom = mapView?.sketchEditor?.geometry else{ + private func calculateSketchArea() -> Double { + guard mapView?.sketchEditor?.isSketchValid == true, let geom = mapView?.sketchEditor?.geometry else { return 0 } return calculateArea(of: geom) } - private func calculateArea(of geom: AGSGeometry) -> Double{ - + private func calculateArea(of geom: AGSGeometry) -> Double { // if planar is very large then just return that, geodetic might take too long - if let linearUnit = geom.spatialReference?.unit as? AGSLinearUnit{ + if let linearUnit = geom.spatialReference?.unit as? AGSLinearUnit { let planar = AGSGeometryEngine.area(of: geom) if let planarMiles = linearUnit.toAreaUnit()?.convert(planar, to: AGSAreaUnit.squareMiles()), - planarMiles > planarAreaSquareMilesThreshold{ + planarMiles > planarAreaSquareMilesThreshold { let planarDisplay = AGSAreaUnit.squareMiles().convert(planarMiles, to: selectedAreaUnit) //print("returning planar area... \(planarMiles) sq miles") return planarDisplay @@ -579,7 +564,7 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { return AGSGeometryEngine.geodeticArea(of: geom, areaUnit: selectedAreaUnit, curveType: geodeticCurveType) } - private func calculateMeasurement(of geom: AGSGeometry) -> Double{ + private func calculateMeasurement(of geom: AGSGeometry) -> Double { switch geom.geometryType { case .polyline: return calculateLength(of: geom) @@ -591,8 +576,7 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { } } - private func unit(for geom: AGSGeometry) -> AGSUnit{ - + private func unit(for geom: AGSGeometry) -> AGSUnit { switch geom.geometryType { case .polyline: return selectedLinearUnit @@ -603,8 +587,7 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { } } - private func selectionSymbol(for geom: AGSGeometry) -> AGSSymbol?{ - + private func selectionSymbol(for geom: AGSGeometry) -> AGSSymbol? { switch geom.geometryType { case .polyline: return selectionLineSymbol @@ -617,36 +600,32 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { private var lastIdentify: AGSCancelable? - public func geoView(_ geoView: AGSGeoView, didTapAtScreenPoint screenPoint: CGPoint, mapPoint: AGSPoint){ - + public func geoView(_ geoView: AGSGeoView, didTapAtScreenPoint screenPoint: CGPoint, mapPoint: AGSPoint) { lastIdentify?.cancel() - lastIdentify = geoView.identifyGraphicsOverlays(atScreenPoint: screenPoint, tolerance: MeasureToolbar.identifyTolerance, returnPopupsOnly: false){ [weak self] results, error in - - guard let self = self else{ + lastIdentify = geoView.identifyGraphicsOverlays(atScreenPoint: screenPoint, tolerance: MeasureToolbar.identifyTolerance, returnPopupsOnly: false) { [weak self] results, error in + guard let self = self else { return } - if let error = error{ - guard (error as NSError).domain != NSCocoaErrorDomain && (error as NSError).code != NSUserCancelledError else{ + if let error = error { + guard (error as NSError).domain != NSCocoaErrorDomain && (error as NSError).code != NSUserCancelledError else { return } } - if let geom = self.firstOverlayPolyResult(in: results){ + if let geom = self.firstOverlayPolyResult(in: results) { // display graphic result self.select(geom: geom) - } - else{ + } else { // otherwise identify layers to try to find a feature - self.lastIdentify = geoView.identifyLayers(atScreenPoint: screenPoint, tolerance: MeasureToolbar.identifyTolerance, returnPopupsOnly: false){ [weak self] results, error in - - guard let self = self else{ + self.lastIdentify = geoView.identifyLayers(atScreenPoint: screenPoint, tolerance: MeasureToolbar.identifyTolerance, returnPopupsOnly: false) { [weak self] results, error in + guard let self = self else { return } - if let error = error{ - guard (error as NSError).domain != NSCocoaErrorDomain && (error as NSError).code != NSUserCancelledError else{ + if let error = error { + guard (error as NSError).domain != NSCocoaErrorDomain && (error as NSError).code != NSUserCancelledError else { return } } @@ -655,21 +634,19 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { self.select(geom: geom) } } - } } - private func clearGeometrySelection(){ + private func clearGeometrySelection() { selectionOverlay?.clearSelection() selectionOverlay?.graphics.removeAllObjects() selectedGeometry = nil } - private func select(geom: AGSGeometry?){ - + private func select(geom: AGSGeometry?) { clearGeometrySelection() - guard let geom = geom else{ + guard let geom = geom else { return } @@ -680,15 +657,14 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { selectedGeometry = geom } - private func firstOverlayPolyResult(in identifyResults: [AGSIdentifyGraphicsOverlayResult]?) -> AGSGeometry?{ - - guard let results = identifyResults else{ + private func firstOverlayPolyResult(in identifyResults: [AGSIdentifyGraphicsOverlayResult]?) -> AGSGeometry? { + guard let results = identifyResults else { return nil } - for result in results{ - for ge in result.graphics{ - if ge.geometry?.geometryType == .polyline || ge.geometry?.geometryType == .polygon || ge.geometry?.geometryType == .envelope{ + for result in results { + for ge in result.graphics { + if ge.geometry?.geometryType == .polyline || ge.geometry?.geometryType == .polygon || ge.geometry?.geometryType == .envelope { return ge.geometry! } } @@ -696,19 +672,18 @@ public class MeasureToolbar: UIToolbar, AGSGeoViewTouchDelegate { return nil } - private func firstLayerPolyResult(in identifyResults: [AGSIdentifyLayerResult]?) -> AGSGeometry?{ - - guard let results = identifyResults else{ + private func firstLayerPolyResult(in identifyResults: [AGSIdentifyLayerResult]?) -> AGSGeometry? { + guard let results = identifyResults else { return nil } - for result in results{ - for ge in result.geoElements{ - if ge.geometry?.geometryType == .polyline || ge.geometry?.geometryType == .polygon || ge.geometry?.geometryType == .envelope{ + for result in results { + for ge in result.geoElements { + if ge.geometry?.geometryType == .polyline || ge.geometry?.geometryType == .polygon || ge.geometry?.geometryType == .envelope { return ge.geometry! } } - if let subGeom = firstLayerPolyResult(in: result.sublayerResults){ + if let subGeom = firstLayerPolyResult(in: result.sublayerResults) { return subGeom } } diff --git a/Toolkit/ArcGISToolkit/PopupController.swift b/Toolkit/ArcGISToolkit/PopupController.swift index 95764223..e197e0e7 100644 --- a/Toolkit/ArcGISToolkit/PopupController.swift +++ b/Toolkit/ArcGISToolkit/PopupController.swift @@ -18,7 +18,6 @@ import ArcGIS /// Through its use of the `AGSPopupsViewController`, it provides a complete /// feature editing and collecting experience. public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoViewTouchDelegate { - private var lastPopupQueries = [AGSCancelable]() private var popupsViewController: AGSPopupsViewController? private let sketchEditor = AGSSketchEditor() @@ -42,8 +41,7 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV /// - takeOverTouchDelegate: Whether or not the `PopupController` will take over the `AGSGeoView's` `touchDelegate`. /// If `false` then you must forward calls from the `AGSGeoViewTouchDelegate` to the `PopupController`. Defaults to `true`. /// - showAddFeatureButton: If `true` then a `UIBarButtonItem` will be added to the `navigationItem` as a right-hand button. - public init(geoViewController: UIViewController, geoView: AGSGeoView, takeOverTouchDelegate: Bool = true, showAddFeatureButton: Bool = true){ - + public init(geoViewController: UIViewController, geoView: AGSGeoView, takeOverTouchDelegate: Bool = true, showAddFeatureButton: Bool = true) { self.geoViewController = geoViewController self.geoView = geoView self.addNewFeatureButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil) @@ -53,33 +51,32 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV self.addNewFeatureButtonItem.target = self self.addNewFeatureButtonItem.action = #selector(addNewFeatureTap) - if showAddFeatureButton{ - if let items = geoViewController.navigationItem.rightBarButtonItems{ + if showAddFeatureButton { + if let items = geoViewController.navigationItem.rightBarButtonItems { geoViewController.navigationItem.rightBarButtonItems = [self.addNewFeatureButtonItem] + items - } - else{ + } else { geoViewController.navigationItem.rightBarButtonItem = self.addNewFeatureButtonItem } } - if takeOverTouchDelegate{ + if takeOverTouchDelegate { self.geoView.touchDelegate = self } sketchEditor.isVisible = true - if let mapView = geoView as? AGSMapView{ + if let mapView = geoView as? AGSMapView { mapView.sketchEditor = sketchEditor } } private var addingNewFeature: Bool = false - @objc private func addNewFeatureTap(){ - + @objc + private func addNewFeatureTap() { // if old pvc is being shown still for some reason, dismiss it self.cleanupLastPopupsViewController() - guard let map = (geoView as? AGSMapView)?.map else{ + guard let map = (geoView as? AGSMapView)?.map else { return } @@ -91,54 +88,48 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV geoViewController?.present(navigationController, animated: true) } - - private func cleanupLastPopupsViewController(){ + private func cleanupLastPopupsViewController() { unselectLastSelectedFeature() // if old pvc is being shown still for some reason, dismiss it if popupsViewController?.view?.window != nil { - if popupsViewController == geoViewController?.navigationController?.topViewController{ + if popupsViewController == geoViewController?.navigationController?.topViewController { geoViewController?.navigationController?.popToViewController(geoViewController!, animated: true) - } - else if popupsViewController == geoViewController?.presentedViewController{ + } else if popupsViewController == geoViewController?.presentedViewController { popupsViewController?.dismiss(animated: true) } } // cleanup last time - lastPopupQueries.forEach{ $0.cancel() } - popupsViewController = nil; + lastPopupQueries.forEach { $0.cancel() } + popupsViewController = nil lastPopupQueries.removeAll() } public func geoView(_ geoView: AGSGeoView, didTapAtScreenPoint screenPoint: CGPoint, mapPoint: AGSPoint) { - self.cleanupLastPopupsViewController() - guard let mapView = geoView as? AGSMapView , mapView.map != nil else{ + guard let mapView = geoView as? AGSMapView, mapView.map != nil else { return } let c = mapView.identifyLayers(atScreenPoint: screenPoint, tolerance: 10, returnPopupsOnly: true, maximumResultsPerLayer: 12) { [weak self] (identifyResults, error) -> Void in - if let identifyResults = identifyResults { - let popups = identifyResults.flatMap({ $0.allPopups }) + let popups = identifyResults.flatMap { $0.allPopups } self?.showPopups(popups) - } - else if let error = error { + } else if let error = error { print("error identifying popups \(error)") } } lastPopupQueries.append(c) } - private func showPopups(_ popups: [AGSPopup]){ - - guard !popups.isEmpty else{ + private func showPopups(_ popups: [AGSPopup]) { + guard !popups.isEmpty else { return } - if let popupsViewController = self.popupsViewController{ + if let popupsViewController = self.popupsViewController { // If we already have a popupsViewController, then show additional popupsViewController.showAdditionalPopups(popups) return @@ -154,7 +145,7 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV popupsViewController.customDoneButton = nil popupsViewController.delegate = self - if containerStyle == .navigationController{ + if containerStyle == .navigationController { // set a back button for the pvc in the nav controller, showing modally, this is handled for us // need to do this so we can clean up (unselect feature, etc) when `back` is tapped let doneViewingBbi = UIBarButtonItem(title: "Back", style: .plain, target: self, action: #selector(doneViewingInNavController)) @@ -162,24 +153,22 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV popupsViewController.navigationItem.leftBarButtonItem = doneViewingBbi geoViewController?.navigationController?.pushViewController(popupsViewController, animated: true) - } - else{ + } else { geoViewController?.present(popupsViewController, animated: true) } - } - @objc private func doneViewingInNavController(){ + @objc + private func doneViewingInNavController() { guard let popupsViewController = popupsViewController else { return } popupsViewControllerDidFinishViewingPopups(popupsViewController) } - private func unselectLastSelectedFeature(){ - + private func unselectLastSelectedFeature() { guard let feature = lastSelectedFeature, - let layer = lastSelectedFeatureLayer else{ + let layer = lastSelectedFeatureLayer else { return } @@ -192,65 +181,56 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV private var editingGeometry: Bool = false private func navigateToMapActionForGeometryEditing() { - editingGeometry = true - if let geoViewController = geoViewController, let nc = geoViewController.navigationController{ + if let geoViewController = geoViewController, let nc = geoViewController.navigationController { // if there is a navigationController available add button to go back to popups when done editing geometry geoViewControllerOriginalRightBarButtonItems = geoViewController.navigationItem.rightBarButtonItems let backToPvcButton = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(navigateBackToPopupsFromGeometryEditing)) geoViewController.navigationItem.rightBarButtonItem = backToPvcButton - if useNavigationControllerIfAvailable{ + if useNavigationControllerIfAvailable { nc.popToViewController(geoViewController, animated: true) - } - else{ + } else { popupsViewController?.dismiss(animated: true) } - } - else{ + } else { // in this case developer needs to have a button that calls `navigateBackToPopupsFromGeometryEditing` popupsViewController?.dismiss(animated: true) } - } - @objc private func navigateBackToPopupsFromGeometryEditing(){ - - guard let popupsViewController = popupsViewController else{ + @objc + private func navigateBackToPopupsFromGeometryEditing() { + guard let popupsViewController = popupsViewController else { return } editingGeometry = false - if let geoViewController = geoViewController, let nc = geoViewController.navigationController{ + if let geoViewController = geoViewController, let nc = geoViewController.navigationController { // if there is a navigationController available reset to original buttons geoViewController.navigationItem.rightBarButtonItems = geoViewControllerOriginalRightBarButtonItems geoViewControllerOriginalRightBarButtonItems = nil - if useNavigationControllerIfAvailable{ + if useNavigationControllerIfAvailable { nc.pushViewController(popupsViewController, animated: true) - } - else{ + } else { geoViewController.present(popupsViewController, animated: true) } - } - else{ + } else { geoViewController?.present(popupsViewController, animated: true) } } public func popupsViewController(_ popupsViewController: AGSPopupsViewController, sketchEditorFor popup: AGSPopup) -> AGSSketchEditor? { - // give the popupsViewController the sketchEditor - if let g = popup.geoElement.geometry{ + if let g = popup.geoElement.geometry { self.sketchEditor.start(with: g) - } - else if let f = popup.geoElement as? AGSFeature, let ft = f.featureTable as? AGSArcGISFeatureTable{ + } else if let f = popup.geoElement as? AGSFeature, let ft = f.featureTable as? AGSArcGISFeatureTable { self.sketchEditor.start(with: ft.geometryType) - } - else{ + } else { self.sketchEditor.start(with: AGSSketchCreationMode.polygon) } @@ -263,10 +243,9 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV } public func popupsViewController(_ popupsViewController: AGSPopupsViewController, didChangeToCurrentPopup popup: AGSPopup) { - guard let f = popup.geoElement as? AGSArcGISFeature, let ft = f.featureTable as? AGSServiceFeatureTable, - let fl = ft.featureLayer else{ + let fl = ft.featureLayer else { return } @@ -278,30 +257,25 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV } public func popupsViewController(_ popupsViewController: AGSPopupsViewController, didFinishEditingFor popup: AGSPopup) { - // geometry editing has ended self.sketchEditor.stop() // apply edits for service feature table - if let f = popup.geoElement as? AGSArcGISFeature, let ft = f.featureTable as? AGSServiceFeatureTable{ + if let f = popup.geoElement as? AGSArcGISFeature, let ft = f.featureTable as? AGSServiceFeatureTable { ft.applyEdits { (results, error) in - - if let error = error{ + if let error = error { // In this case it is a service level error print("error applying edits: \(error)") } - if let results = results{ - - let editErrors = results.flatMap({ self.checkFeatureEditResult($0) }) - if editErrors.isEmpty{ + if let results = results { + let editErrors = results.flatMap { self.checkFeatureEditResult($0) } + if editErrors.isEmpty { print("applied all edits successfully") - } - else{ + } else { // These would be feature level edit errors print("apply edits failed: \(editErrors)") } - } } } @@ -311,12 +285,12 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV } /// This pulls out any nested errors from a feature edit result - private func checkFeatureEditResult(_ featureEditResult: AGSFeatureEditResult) -> [Error]{ + private func checkFeatureEditResult(_ featureEditResult: AGSFeatureEditResult) -> [Error] { var errors = [Error]() - if let error = featureEditResult.error{ + if let error = featureEditResult.error { errors.append(error) } - errors.append(contentsOf: featureEditResult.attachmentResults.compactMap({ $0.error })) + errors.append(contentsOf: featureEditResult.attachmentResults.compactMap { $0.error }) return errors } @@ -324,7 +298,7 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV // geometry editing has ended self.sketchEditor.stop() - if addingNewFeature{ + if addingNewFeature { // if was adding new feature, then hide the popup, don't show viewing mode self.cleanupLastPopupsViewController() } @@ -336,19 +310,16 @@ public class PopupController: NSObject, AGSPopupsViewControllerDelegate, AGSGeoV public func popupsViewControllerDidFinishViewingPopups(_ popupsViewController: AGSPopupsViewController) { self.cleanupLastPopupsViewController() } - } extension PopupController: TemplatePickerViewControllerDelegate { - public func templatePickerViewControllerDidCancel(_ templatePickerViewController: TemplatePickerViewController) { templatePickerViewController.dismiss(animated: true) } - public func templatePickerViewController(_ templatePickerViewController: TemplatePickerViewController, didSelect featureTemplateInfo: FeatureTemplateInfo){ - templatePickerViewController.dismiss(animated: true){ - - guard let feature = featureTemplateInfo.featureTable.createFeature(with: featureTemplateInfo.featureTemplate) else{ + public func templatePickerViewController(_ templatePickerViewController: TemplatePickerViewController, didSelect featureTemplateInfo: FeatureTemplateInfo) { + templatePickerViewController.dismiss(animated: true) { + guard let feature = featureTemplateInfo.featureTable.createFeature(with: featureTemplateInfo.featureTemplate) else { return } diff --git a/Toolkit/ArcGISToolkit/Scalebar.swift b/Toolkit/ArcGISToolkit/Scalebar.swift index 2e775ec5..092669ee 100644 --- a/Toolkit/ArcGISToolkit/Scalebar.swift +++ b/Toolkit/ArcGISToolkit/Scalebar.swift @@ -14,15 +14,15 @@ import UIKit import ArcGIS -public enum ScalebarUnits{ +public enum ScalebarUnits { case imperial case metric - internal func baseUnits()->AGSLinearUnit{ + internal func baseUnits() -> AGSLinearUnit { return self == .imperial ? AGSLinearUnit.feet() : AGSLinearUnit.meters() } - private static func multiplierAndMagnitudeForDistance(distance: Double) -> (multiplier: Double, magnitude: Double){ + private static func multiplierAndMagnitudeForDistance(distance: Double) -> (multiplier: Double, magnitude: Double) { // get multiplier let magnitude = pow(10, floor(log10(distance))) @@ -31,8 +31,7 @@ public enum ScalebarUnits{ return (multiplier, magnitude) } - internal func closestDistanceWithoutGoingOver(to distance: Double, units: AGSLinearUnit) -> Double{ - + internal func closestDistanceWithoutGoingOver(to distance: Double, units: AGSLinearUnit) -> Double { let mm = ScalebarUnits.multiplierAndMagnitudeForDistance(distance: distance) let roundNumber = mm.multiplier * mm.magnitude @@ -51,7 +50,9 @@ public enum ScalebarUnits{ // this table must begin with 1 and end with 10 private static let roundNumberMultipliers: [Double] = [1, 1.2, 1.25, 1.5, 1.75, 2, 2.4, 2.5, 3, 3.75, 4, 5, 6, 7.5, 8, 9, 10] - private static func segmentOptionsForMultiplier(multiplier: Double) -> [Int]{ + + // swiftlint:disable cyclomatic_complexity + private static func segmentOptionsForMultiplier(multiplier: Double) -> [Int] { switch multiplier { case 1: return [1, 2, 4, 5] @@ -91,9 +92,9 @@ public enum ScalebarUnits{ return [1] } } - - internal static func numSegmentsForDistance(distance: Double, maxNumSegments: Int) -> Int{ - + // swiftlint:enable cyclomatic_complexity + + internal static func numSegmentsForDistance(distance: Double, maxNumSegments: Int) -> Int { // this function returns the best number of segments so that we get relatively round // numbers when the distance is divided up. @@ -103,36 +104,33 @@ public enum ScalebarUnits{ return num } - internal func linearUnitsForDistance(distance: Double) -> AGSLinearUnit{ - + internal func linearUnitsForDistance(distance: Double) -> AGSLinearUnit { switch self { case .imperial: - if distance >= 2640{ + if distance >= 2640 { return AGSLinearUnit.miles() } return AGSLinearUnit.feet() case .metric: - if distance >= 1000{ + if distance >= 1000 { return AGSLinearUnit.kilometers() } return AGSLinearUnit.meters() } - } - } -public enum ScalebarStyle{ +public enum ScalebarStyle { case line case bar case graduatedLine case alternatingBar case dualUnitLine - fileprivate func rendererForScalebar(scalebar: Scalebar) -> ScalebarRenderer{ + fileprivate func rendererForScalebar(scalebar: Scalebar) -> ScalebarRenderer { switch self { case .line: return ScalebarLineStyleRenderer(scalebar: scalebar) @@ -148,85 +146,76 @@ public enum ScalebarStyle{ } } -public enum ScalebarAlignment{ +public enum ScalebarAlignment { case left case right case center } - public class Scalebar: UIView { - // // public properties - public var units: ScalebarUnits = .imperial{ - didSet{ + public var units: ScalebarUnits = .imperial { + didSet { updateScaleDisplay(forceRedraw: true) } } - public var style: ScalebarStyle = .line{ - didSet{ + public var style: ScalebarStyle = .line { + didSet { renderer = style.rendererForScalebar(scalebar: self) updateScaleDisplay(forceRedraw: true) } } - @IBInspectable - public var fillColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5){ - didSet{ + @IBInspectable public var fillColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) { + didSet { setNeedsDisplay() } } - @IBInspectable - public var alternateFillColor: UIColor? = UIColor.black{ - didSet{ + @IBInspectable public var alternateFillColor: UIColor? = UIColor.black { + didSet { setNeedsDisplay() } } - @IBInspectable - public var lineColor: UIColor = UIColor.white{ - didSet{ + @IBInspectable public var lineColor: UIColor = UIColor.white { + didSet { setNeedsDisplay() } } - @IBInspectable - public var shadowColor: UIColor? = UIColor.black.withAlphaComponent(0.65){ - didSet{ + @IBInspectable public var shadowColor: UIColor? = UIColor.black.withAlphaComponent(0.65) { + didSet { setNeedsDisplay() } } - @IBInspectable - public var textColor: UIColor? = UIColor.black{ - didSet{ + @IBInspectable public var textColor: UIColor? = UIColor.black { + didSet { setNeedsDisplay() } } - @IBInspectable - public var textShadowColor: UIColor? = UIColor.white{ - didSet{ + @IBInspectable public var textShadowColor: UIColor? = UIColor.white { + didSet { setNeedsDisplay() } } // Set this to a value greater than 0 if you don't specify constraints for width and want to rely // on intrinsic content size for the width when using autolayout. Only applicable for autolayout. - @IBInspectable - public var maximumIntrinsicWidth: CGFloat = 0 { - didSet{ + @IBInspectable public var maximumIntrinsicWidth: CGFloat = 0 { + didSet { // this will invalidate the intrinsicContentSize and also redraw updateScaleDisplay(forceRedraw: true) } } - public var alignment: ScalebarAlignment = .left{ - didSet{ + public var alignment: ScalebarAlignment = .left { + didSet { updateScaleDisplay(forceRedraw: true) } } @@ -235,15 +224,15 @@ public class Scalebar: UIView { public var useGeodeticCalculations = true public var mapView: AGSMapView? { - didSet{ + didSet { unbindFromMapView(mapView: oldValue) bindToMapView(mapView: mapView) updateScaleDisplay(forceRedraw: true) } } - public var font: UIFont = UIFont.systemFont(ofSize: 9.0, weight: UIFont.Weight.semibold){ - didSet{ + public var font = UIFont.systemFont(ofSize: 9.0, weight: UIFont.Weight.semibold) { + didSet { recalculateFontProperties() updateScaleDisplay(forceRedraw: true) } @@ -259,9 +248,9 @@ public class Scalebar: UIView { internal static let labelYPad: CGFloat = 2.0 internal static let labelXPad: CGFloat = 4.0 - internal static let tickHeight: CGFloat = 6.0 - internal static let tick2Height: CGFloat = 4.5 - internal static let notchHeight: CGFloat = 6.0 + internal static let tickHeight: CGFloat = 6.0 + internal static let tick2Height: CGFloat = 4.5 + internal static let notchHeight: CGFloat = 6.0 internal static var numberFormatter: NumberFormatter = { let numberFormatter = NumberFormatter() numberFormatter.numberStyle = .decimal @@ -271,16 +260,14 @@ public class Scalebar: UIView { return numberFormatter }() - internal static let showFrameDebugColors = false - internal static let lineCap: CGLineCap = CGLineCap.round + internal static let lineCap = CGLineCap.round internal var fontHeight: CGFloat = 0 internal var zeroStringWidth: CGFloat = 0 internal var maxRightUnitsPad: CGFloat = 0 - private func recalculateFontProperties(){ - + private func recalculateFontProperties() { let attributes: [NSAttributedString.Key: Any] = [.font: font] let zeroText = "0" @@ -298,7 +285,7 @@ public class Scalebar: UIView { // accurate for the center of the map on smaller scales (when zoomed way out). // A minScale of 0 means it will always be visible private let minScale: Double = 0 - private var updateCoalescer: Coalescer? = nil + private var updateCoalescer: Coalescer? private var renderer: ScalebarRenderer? @@ -311,12 +298,12 @@ public class Scalebar: UIView { sharedInitialization() } - required public init?(coder aDecoder: NSCoder) { + public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) sharedInitialization() } - required public init(mapView: AGSMapView){ + public required init(mapView: AGSMapView) { super.init(frame: CGRect.zero) sharedInitialization() self.mapView = mapView @@ -324,8 +311,7 @@ public class Scalebar: UIView { bindToMapView(mapView: mapView) } - private func sharedInitialization(){ - + private func sharedInitialization() { self.updateCoalescer = Coalescer(dispatchQueue: DispatchQueue.main, interval: DispatchTimeInterval.milliseconds(500), action: updateScaleDisplayIfNecessary) self.isUserInteractionEnabled = false @@ -340,18 +326,18 @@ public class Scalebar: UIView { private var mapObservation: NSKeyValueObservation? private var visibleAreaObservation: NSKeyValueObservation? - private func bindToMapView(mapView: AGSMapView?){ - mapObservation = mapView?.observe(\.map, options: .new){[weak self] mapView, change in + private func bindToMapView(mapView: AGSMapView?) { + mapObservation = mapView?.observe(\.map, options: .new) {[weak self] _, _ in self?.updateScaleDisplay(forceRedraw: false) } - visibleAreaObservation = mapView?.observe(\.visibleArea, options: .new){ [weak self] mapView, change in + visibleAreaObservation = mapView?.observe(\.visibleArea, options: .new) { [weak self] _, _ in // since we get updates so often, we don't need to redraw that often // so use the coalescer to filter the events on a time interval self?.updateCoalescer?.ping() } } - private func unbindFromMapView(mapView: AGSMapView?){ + private func unbindFromMapView(mapView: AGSMapView?) { // invalidate observations and set to nil mapObservation?.invalidate() mapObservation = nil @@ -359,37 +345,37 @@ public class Scalebar: UIView { visibleAreaObservation = nil } - private func updateScaleDisplayIfNecessary(){ + private func updateScaleDisplayIfNecessary() { updateScaleDisplay(forceRedraw: false) } - private func updateScaleDisplay(forceRedraw: Bool){ - - guard var renderer = renderer else{ + // swiftlint:disable cyclomatic_complexity + private func updateScaleDisplay(forceRedraw: Bool) { + guard var renderer = renderer else { // this should never happen, should always have a renderer setNeedsDisplay() return } - guard let mapView = mapView else{ + guard let mapView = mapView else { renderer.currentScaleDisplay = nil setNeedsDisplay() return } - guard mapView.map != nil else{ + guard mapView.map != nil else { renderer.currentScaleDisplay = nil setNeedsDisplay() return } - guard let sr = mapView.spatialReference else{ + guard let sr = mapView.spatialReference else { renderer.currentScaleDisplay = nil setNeedsDisplay() return } - guard let visibleArea = mapView.visibleArea else{ + guard let visibleArea = mapView.visibleArea else { renderer.currentScaleDisplay = nil setNeedsDisplay() return @@ -397,7 +383,7 @@ public class Scalebar: UIView { //print("current scale: \(mapView.mapScale)") - guard minScale <= 0 || mapView.mapScale < minScale else{ + guard minScale <= 0 || mapView.mapScale < minScale else { //print("current scale: \(mapView.mapScale), minScale \(minScale)") renderer.currentScaleDisplay = nil setNeedsDisplay() @@ -416,19 +402,18 @@ public class Scalebar: UIView { let lineDisplayLength: CGFloat // bail early if we can because the last time we drew was good - if let csd = renderer.currentScaleDisplay, forceRedraw == false{ + if let csd = renderer.currentScaleDisplay, forceRedraw == false { var needsRedraw = false - if csd.mapScale != mapScale{ needsRedraw = true } + if csd.mapScale != mapScale { needsRedraw = true } let dependsOnMapCenter = sr.unit is AGSAngularUnit || useGeodeticCalculations - if dependsOnMapCenter && !mapCenter.isEqual(to: csd.mapCenter){ needsRedraw = true } - if !needsRedraw{ + if dependsOnMapCenter && !mapCenter.isEqual(to: csd.mapCenter) { needsRedraw = true } + if !needsRedraw { // no need to redraw - nothing significant changed return } } if useGeodeticCalculations || sr.unit is AGSAngularUnit { - let maxLengthPlanar = unitsPerPoint * Double(maxLength) let p1 = AGSPoint(x: mapCenter.x - (maxLengthPlanar * 0.5), y: mapCenter.y, spatialReference: sr) let p2 = AGSPoint(x: mapCenter.x + (maxLengthPlanar * 0.5), y: mapCenter.y, spatialReference: sr) @@ -440,10 +425,8 @@ public class Scalebar: UIView { lineDisplayLength = CGFloat( (roundNumberDistance * planarToGeodeticFactor) / unitsPerPoint ) displayUnit = units.linearUnitsForDistance(distance: roundNumberDistance) lineMapLength = baseUnits.convert(roundNumberDistance, to: displayUnit) - } - else { - - guard let srUnit = sr.unit as? AGSLinearUnit else{ + } else { + guard let srUnit = sr.unit as? AGSLinearUnit else { renderer.currentScaleDisplay = nil setNeedsDisplay() return @@ -458,7 +441,7 @@ public class Scalebar: UIView { lineMapLength = baseUnits.convert(closestLen, to: displayUnit) } - guard lineDisplayLength.isFinite, !lineDisplayLength.isNaN else{ + guard lineDisplayLength.isFinite, !lineDisplayLength.isNaN else { renderer.currentScaleDisplay = nil setNeedsDisplay() return @@ -475,25 +458,21 @@ public class Scalebar: UIView { // tell view we need to redraw setNeedsDisplay() } - - public override var intrinsicContentSize: CGSize{ - get{ - if let renderer = renderer { - if maximumIntrinsicWidth > 0{ - return CGSize(width: renderer.currentMaxDisplayWidth, height: renderer.displayHeight) - } - else{ - return CGSize(width: UIView.noIntrinsicMetric, height: renderer.displayHeight) - } - } - else{ - return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) + // swiftlint:enable cyclomatic_complexity + + override public var intrinsicContentSize: CGSize { + if let renderer = renderer { + if maximumIntrinsicWidth > 0 { + return CGSize(width: renderer.currentMaxDisplayWidth, height: renderer.displayHeight) + } else { + return CGSize(width: UIView.noIntrinsicMetric, height: renderer.displayHeight) } + } else { + return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) } } - private func offsetRectForDisplaySize(displaySize: CGSize) -> CGRect{ - + private func offsetRectForDisplaySize(displaySize: CGSize) -> CGRect { // center on y axis let offsetY = (bounds.height - displaySize.height) / 2 @@ -512,10 +491,9 @@ public class Scalebar: UIView { } override public func draw(_ rect: CGRect) { - super.draw(rect) - guard let renderer = self.renderer, renderer.currentScaleDisplay != nil else{ + guard let renderer = self.renderer, renderer.currentScaleDisplay != nil else { return } @@ -523,11 +501,11 @@ public class Scalebar: UIView { let odr = offsetRectForDisplaySize(displaySize: displaySize) - guard !odr.isEmpty else{ + guard !odr.isEmpty else { return } - if Scalebar.showFrameDebugColors, let context = UIGraphicsGetCurrentContext(){ + if Scalebar.showFrameDebugColors, let context = UIGraphicsGetCurrentContext() { context.saveGState() context.setFillColor(UIColor.yellow.cgColor) @@ -543,19 +521,17 @@ public class Scalebar: UIView { renderer.draw(rect: odr) } - private func calculateDisplaySize() -> CGSize{ + private func calculateDisplaySize() -> CGSize { if let renderer = renderer { let displaySize = CGSize(width: renderer.currentMaxDisplayWidth, height: renderer.displayHeight) return displaySize - } - else{ + } else { return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) } } } - -internal struct ScaleDisplay{ +internal struct ScaleDisplay { var mapScale: Double = 0 var unitsPerPoint: Double = 0 var lineMapLength: Double = 0 @@ -565,7 +541,7 @@ internal struct ScaleDisplay{ var mapLengthString: String } -internal struct SegmentInfo{ +internal struct SegmentInfo { var index: Int var segmentScreenLength: CGFloat var xOffset: CGFloat @@ -574,11 +550,10 @@ internal struct SegmentInfo{ var textWidth: CGFloat } -internal protocol ScalebarRenderer{ - - var scalebar: Scalebar? {get} +internal protocol ScalebarRenderer { + var scalebar: Scalebar? { get } var currentScaleDisplay: ScaleDisplay? { get set } - var displayHeight: CGFloat {get} + var displayHeight: CGFloat { get } var currentMaxDisplayWidth: CGFloat { get } init(scalebar: Scalebar) @@ -587,27 +562,21 @@ internal protocol ScalebarRenderer{ func draw(rect: CGRect) } -internal extension ScalebarRenderer{ - - var shadowOffset: CGPoint{ +internal extension ScalebarRenderer { + var shadowOffset: CGPoint { return CGPoint(x: 0.5, y: 0.5) } var lineWidth: CGFloat { - get { - return 2 - } + return 2 } var halfLineWidth: CGFloat { - get{ - return 1 - } + return 1 } - - func calculateSegmentInfos() -> [SegmentInfo]?{ - - guard let scaleDisplay = currentScaleDisplay, let scalebar = scalebar else{ + + func calculateSegmentInfos() -> [SegmentInfo]? { + guard let scaleDisplay = currentScaleDisplay, let scalebar = scalebar else { return nil } @@ -619,7 +588,7 @@ internal extension ScalebarRenderer{ let minSegmentTestString = (scaleDisplay.mapLengthString.count > 3) ? scaleDisplay.mapLengthString : "9.9" // use 1.5 because the last segment, the text is right justified insted of center, which makes it harder to squeeze text in let minSegmentWidth = (minSegmentTestString.size(withAttributes: [.font: scalebar.font]).width * 1.5) + (Scalebar.labelXPad * 2) - var maxNumSegments: Int = Int(lineDisplayLength / minSegmentWidth) + var maxNumSegments = Int(lineDisplayLength / minSegmentWidth) maxNumSegments = min(maxNumSegments, 4) // cap it at 4 let numSegments: Int = ScalebarUnits.numSegmentsForDistance(distance: scaleDisplay.lineMapLength, maxNumSegments: maxNumSegments) @@ -630,7 +599,6 @@ internal extension ScalebarRenderer{ var segmentInfos = [SegmentInfo]() for index in 0.. CGFloat{ + func availableLineDisplayLength(totalDisplayWidth: CGFloat) -> CGFloat { return totalDisplayWidth - lineWidth } - func draw(rect: CGRect){ - - guard let scaleDisplay = currentScaleDisplay else{ + func draw(rect: CGRect) { + guard let scaleDisplay = currentScaleDisplay else { return } - guard let scalebar = self.scalebar else{ + guard let scalebar = self.scalebar else { return } - guard let context = UIGraphicsGetCurrentContext() else{ + guard let context = UIGraphicsGetCurrentContext() else { return } @@ -803,20 +758,19 @@ internal class ScalebarLineStyleRenderer: ScalebarRenderer{ let lineBottom = lineTop + Scalebar.tickHeight path.move(to: CGPoint(x: lineX, y: lineTop)) - path.addLine(to: CGPoint(x: lineX, y:lineBottom)) + path.addLine(to: CGPoint(x: lineX, y: lineBottom)) path.addLine(to: CGPoint(x: lineX + lineScreenLength, y: lineBottom)) path.addLine(to: CGPoint(x: lineX + lineScreenLength, y: lineTop)) - // // draw paths context.setLineCap(Scalebar.lineCap) context.setLineJoin(CGLineJoin.bevel) - if let shadowColor = scalebar.shadowColor{ + if let shadowColor = scalebar.shadowColor { var t = CGAffineTransform(translationX: shadowOffset.x, y: shadowOffset.y) - if let shadowPath = path.copy(using: &t){ + if let shadowPath = path.copy(using: &t) { context.setLineWidth(lineWidth) context.setStrokeColor(shadowColor.cgColor) context.addPath(shadowPath) @@ -845,8 +799,7 @@ internal class ScalebarLineStyleRenderer: ScalebarRenderer{ } } -internal class ScalebarGraduatedLineStyleRenderer: ScalebarRenderer{ - +internal class ScalebarGraduatedLineStyleRenderer: ScalebarRenderer { weak var scalebar: Scalebar? required init(scalebar: Scalebar) { @@ -856,41 +809,36 @@ internal class ScalebarGraduatedLineStyleRenderer: ScalebarRenderer{ var currentScaleDisplay: ScaleDisplay? var displayHeight: CGFloat { - get{ - guard let scalebar = scalebar else { return 0 } - return halfLineWidth + Scalebar.tickHeight + Scalebar.labelYPad + scalebar.fontHeight + shadowOffset.y - } + guard let scalebar = scalebar else { return 0 } + return halfLineWidth + Scalebar.tickHeight + Scalebar.labelYPad + scalebar.fontHeight + shadowOffset.y } var currentMaxDisplayWidth: CGFloat { - get{ - guard let scalebar = scalebar, let scaleDisplay = currentScaleDisplay else { - return 0 - } - return halfLineWidth + scaleDisplay.lineDisplayLength + halfLineWidth + scalebar.maxRightUnitsPad + shadowOffset.x + guard let scalebar = scalebar, let scaleDisplay = currentScaleDisplay else { + return 0 } + return halfLineWidth + scaleDisplay.lineDisplayLength + halfLineWidth + scalebar.maxRightUnitsPad + shadowOffset.x } - func availableLineDisplayLength(totalDisplayWidth: CGFloat) -> CGFloat{ + func availableLineDisplayLength(totalDisplayWidth: CGFloat) -> CGFloat { guard let scalebar = scalebar else { return 0 } return totalDisplayWidth - halfLineWidth - scalebar.maxRightUnitsPad } - func draw(rect: CGRect){ - - guard let scaleDisplay = currentScaleDisplay else{ + func draw(rect: CGRect) { + guard let scaleDisplay = currentScaleDisplay else { return } - guard let scalebar = self.scalebar else{ + guard let scalebar = self.scalebar else { return } - guard let segmentInfos = calculateSegmentInfos() else{ + guard let segmentInfos = calculateSegmentInfos() else { return } - guard let context = UIGraphicsGetCurrentContext() else{ + guard let context = UIGraphicsGetCurrentContext() else { return } @@ -916,14 +864,13 @@ internal class ScalebarGraduatedLineStyleRenderer: ScalebarRenderer{ let lineX = x + halfLineWidth path.move(to: CGPoint(x: lineX, y: lineTop)) - path.addLine(to: CGPoint(x: lineX, y:lineBottom)) + path.addLine(to: CGPoint(x: lineX, y: lineBottom)) path.addLine(to: CGPoint(x: lineX + lineScreenLength, y: lineBottom)) path.addLine(to: CGPoint(x: lineX + lineScreenLength, y: lineTop)) - // draw segment ticks - for si in segmentInfos{ - if si.index == segmentInfos.last?.index{ + for si in segmentInfos { + if si.index == segmentInfos.last?.index { // skip last segment continue } @@ -938,9 +885,9 @@ internal class ScalebarGraduatedLineStyleRenderer: ScalebarRenderer{ context.setLineCap(Scalebar.lineCap) context.setLineJoin(CGLineJoin.bevel) - if let shadowColor = scalebar.shadowColor{ + if let shadowColor = scalebar.shadowColor { var t = CGAffineTransform(translationX: shadowOffset.x, y: shadowOffset.y) - if let shadowPath = path.copy(using: &t){ + if let shadowPath = path.copy(using: &t) { context.setLineWidth(lineWidth) context.setStrokeColor(shadowColor.cgColor) context.addPath(shadowPath) @@ -960,14 +907,12 @@ internal class ScalebarGraduatedLineStyleRenderer: ScalebarRenderer{ let textY = lineBottom + Scalebar.labelYPad drawSegmentsText(segmentInfos: segmentInfos, scaleDisplay: scaleDisplay, startingX: lineX, textY: textY) - // reset the state context.restoreGState() } } -internal class ScalebarBarStyleRenderer: ScalebarRenderer{ - +internal class ScalebarBarStyleRenderer: ScalebarRenderer { weak var scalebar: Scalebar? required init(scalebar: Scalebar) { @@ -977,36 +922,31 @@ internal class ScalebarBarStyleRenderer: ScalebarRenderer{ var currentScaleDisplay: ScaleDisplay? var displayHeight: CGFloat { - get{ - guard let scalebar = scalebar else { return 0 } - return Scalebar.notchHeight + Scalebar.labelYPad + scalebar.fontHeight + shadowOffset.y - } + guard let scalebar = scalebar else { return 0 } + return Scalebar.notchHeight + Scalebar.labelYPad + scalebar.fontHeight + shadowOffset.y } var currentMaxDisplayWidth: CGFloat { - get{ - guard let scaleDisplay = currentScaleDisplay else{ - return 0 - } - return halfLineWidth + scaleDisplay.lineDisplayLength + halfLineWidth + shadowOffset.x + guard let scaleDisplay = currentScaleDisplay else { + return 0 } + return halfLineWidth + scaleDisplay.lineDisplayLength + halfLineWidth + shadowOffset.x } - func availableLineDisplayLength(totalDisplayWidth: CGFloat) -> CGFloat{ + func availableLineDisplayLength(totalDisplayWidth: CGFloat) -> CGFloat { return totalDisplayWidth - lineWidth } - func draw(rect: CGRect){ - - guard let scaleDisplay = currentScaleDisplay else{ + func draw(rect: CGRect) { + guard let scaleDisplay = currentScaleDisplay else { return } - guard let scalebar = self.scalebar else{ + guard let scalebar = self.scalebar else { return } - guard let context = UIGraphicsGetCurrentContext() else{ + guard let context = UIGraphicsGetCurrentContext() else { return } @@ -1020,7 +960,6 @@ internal class ScalebarBarStyleRenderer: ScalebarRenderer{ let path = CGMutablePath() - // set path for bar style /* =================== @@ -1043,9 +982,9 @@ internal class ScalebarBarStyleRenderer: ScalebarRenderer{ context.setLineCap(Scalebar.lineCap) context.setLineJoin(CGLineJoin.bevel) - if let shadowColor = scalebar.shadowColor{ + if let shadowColor = scalebar.shadowColor { var t = CGAffineTransform(translationX: shadowOffset.x, y: shadowOffset.y) - if let shadowPath = path.copy(using: &t){ + if let shadowPath = path.copy(using: &t) { context.setLineWidth(lineWidth) context.setStrokeColor(shadowColor.cgColor) context.addPath(shadowPath) @@ -1053,7 +992,7 @@ internal class ScalebarBarStyleRenderer: ScalebarRenderer{ } } - if let fillColor = scalebar.fillColor{ + if let fillColor = scalebar.fillColor { context.setFillColor(fillColor.cgColor) context.addPath(path) context.drawPath(using: .fill) @@ -1080,8 +1019,7 @@ internal class ScalebarBarStyleRenderer: ScalebarRenderer{ } } -internal class ScalebarAlternatingBarStyleRenderer: ScalebarRenderer{ - +internal class ScalebarAlternatingBarStyleRenderer: ScalebarRenderer { weak var scalebar: Scalebar? required init(scalebar: Scalebar) { @@ -1091,44 +1029,40 @@ internal class ScalebarAlternatingBarStyleRenderer: ScalebarRenderer{ var currentScaleDisplay: ScaleDisplay? var displayHeight: CGFloat { - get{ - guard let scalebar = scalebar else { return 0 } - return halfLineWidth + Scalebar.notchHeight + Scalebar.labelYPad + scalebar.fontHeight + shadowOffset.y - } + guard let scalebar = scalebar else { return 0 } + return halfLineWidth + Scalebar.notchHeight + Scalebar.labelYPad + scalebar.fontHeight + shadowOffset.y } var currentMaxDisplayWidth: CGFloat { - get{ - guard let scalebar = scalebar, let scaleDisplay = currentScaleDisplay else { - return 0 - } - return halfLineWidth + scaleDisplay.lineDisplayLength + halfLineWidth + scalebar.maxRightUnitsPad + shadowOffset.x + guard let scalebar = scalebar, let scaleDisplay = currentScaleDisplay else { + return 0 } + return halfLineWidth + scaleDisplay.lineDisplayLength + halfLineWidth + scalebar.maxRightUnitsPad + shadowOffset.x } // can change this if you want to see quarter graduation private let showQuarters = false - func availableLineDisplayLength(totalDisplayWidth: CGFloat) -> CGFloat{ + func availableLineDisplayLength(totalDisplayWidth: CGFloat) -> CGFloat { guard let scalebar = scalebar else { return 0 } return totalDisplayWidth - halfLineWidth - scalebar.maxRightUnitsPad } - func draw(rect: CGRect){ - - guard let scaleDisplay = currentScaleDisplay else{ + // swiftlint:disable cyclomatic_complexity + func draw(rect: CGRect) { + guard let scaleDisplay = currentScaleDisplay else { return } - guard let scalebar = self.scalebar else{ + guard let scalebar = self.scalebar else { return } - guard let segmentInfos = calculateSegmentInfos() else{ + guard let segmentInfos = calculateSegmentInfos() else { return } - guard let context = UIGraphicsGetCurrentContext() else{ + guard let context = UIGraphicsGetCurrentContext() else { return } @@ -1142,14 +1076,12 @@ internal class ScalebarAlternatingBarStyleRenderer: ScalebarRenderer{ let pathStroke = CGMutablePath() - // set path for bar style /* =========~~~~~~~~~~ 0 100 200km */ - let lineTop = y + halfLineWidth let lineBottom = lineTop + Scalebar.notchHeight let lineX = x + halfLineWidth @@ -1164,8 +1096,8 @@ internal class ScalebarAlternatingBarStyleRenderer: ScalebarRenderer{ pathStroke.closeSubpath() // add all segment ticks - for si in segmentInfos{ - if si.index == segmentInfos.last?.index{ + for si in segmentInfos { + if si.index == segmentInfos.last?.index { // skip last segment continue } @@ -1181,8 +1113,7 @@ internal class ScalebarAlternatingBarStyleRenderer: ScalebarRenderer{ var lastPathX = lineX - for si in segmentInfos{ - + for si in segmentInfos { let fillPath = (si.index % 2) == 0 ? fillPath2 : fillPath1 let pathX = lineX + si.xOffset @@ -1196,7 +1127,6 @@ internal class ScalebarAlternatingBarStyleRenderer: ScalebarRenderer{ lastPathX = pathX } - // // draw paths @@ -1204,9 +1134,9 @@ internal class ScalebarAlternatingBarStyleRenderer: ScalebarRenderer{ context.setLineJoin(CGLineJoin.bevel) // stroke shadow - if let shadowColor = scalebar.shadowColor{ + if let shadowColor = scalebar.shadowColor { var t = CGAffineTransform(translationX: shadowOffset.x, y: shadowOffset.y) - if let shadowPath = pathStroke.copy(using: &t){ + if let shadowPath = pathStroke.copy(using: &t) { context.setLineWidth(lineWidth) context.setStrokeColor(shadowColor.cgColor) context.addPath(shadowPath) @@ -1215,14 +1145,14 @@ internal class ScalebarAlternatingBarStyleRenderer: ScalebarRenderer{ } // fill in odd segments - if let fillColor = scalebar.fillColor{ + if let fillColor = scalebar.fillColor { context.setFillColor(fillColor.cgColor) context.addPath(fillPath1) context.drawPath(using: .fill) } // fill in even segments - if let alternateFillColor = scalebar.alternateFillColor{ + if let alternateFillColor = scalebar.alternateFillColor { context.setFillColor(alternateFillColor.cgColor) context.addPath(fillPath2) context.drawPath(using: .fill) @@ -1243,12 +1173,10 @@ internal class ScalebarAlternatingBarStyleRenderer: ScalebarRenderer{ // reset the state context.restoreGState() } - + // swiftlint:enable cyclomatic_complexity } - -internal class ScalebarDualUnitLineStyleRenderer: ScalebarRenderer{ - +internal class ScalebarDualUnitLineStyleRenderer: ScalebarRenderer { weak var scalebar: Scalebar? required init(scalebar: Scalebar) { @@ -1258,37 +1186,32 @@ internal class ScalebarDualUnitLineStyleRenderer: ScalebarRenderer{ var currentScaleDisplay: ScaleDisplay? var displayHeight: CGFloat { - get{ - guard let scalebar = scalebar else { return 0 } - return scalebar.fontHeight + Scalebar.labelYPad + Scalebar.tick2Height + Scalebar.tick2Height + Scalebar.labelYPad + scalebar.fontHeight + shadowOffset.y - } + guard let scalebar = scalebar else { return 0 } + return scalebar.fontHeight + Scalebar.labelYPad + Scalebar.tick2Height + Scalebar.tick2Height + Scalebar.labelYPad + scalebar.fontHeight + shadowOffset.y } var currentMaxDisplayWidth: CGFloat { - get{ - guard let scalebar = scalebar, let scaleDisplay = currentScaleDisplay else { - return 0 - } - return halfLineWidth + scaleDisplay.lineDisplayLength + halfLineWidth + scalebar.maxRightUnitsPad + shadowOffset.x + guard let scalebar = scalebar, let scaleDisplay = currentScaleDisplay else { + return 0 } + return halfLineWidth + scaleDisplay.lineDisplayLength + halfLineWidth + scalebar.maxRightUnitsPad + shadowOffset.x } - func availableLineDisplayLength(totalDisplayWidth: CGFloat) -> CGFloat{ + func availableLineDisplayLength(totalDisplayWidth: CGFloat) -> CGFloat { guard let scalebar = scalebar else { return 0 } return totalDisplayWidth - halfLineWidth - scalebar.maxRightUnitsPad } - func draw(rect: CGRect){ - - guard let scaleDisplay = currentScaleDisplay else{ + func draw(rect: CGRect) { + guard let scaleDisplay = currentScaleDisplay else { return } - guard let scalebar = self.scalebar else{ + guard let scalebar = self.scalebar else { return } - guard let context = UIGraphicsGetCurrentContext() else{ + guard let context = UIGraphicsGetCurrentContext() else { return } @@ -1319,7 +1242,7 @@ internal class ScalebarDualUnitLineStyleRenderer: ScalebarRenderer{ // top unit line path.move(to: CGPoint(x: lineX, y: lineTop)) path.addLine(to: CGPoint(x: lineX, y: lineBottom)) - path.move(to: CGPoint(x: lineX, y:lineY)) + path.move(to: CGPoint(x: lineX, y: lineY)) path.addLine(to: CGPoint(x: lineX + lineScreenLength, y: lineY)) path.addLine(to: CGPoint(x: lineX + lineScreenLength, y: lineTop)) @@ -1343,9 +1266,9 @@ internal class ScalebarDualUnitLineStyleRenderer: ScalebarRenderer{ context.setLineCap(Scalebar.lineCap) context.setLineJoin(CGLineJoin.bevel) - if let shadowColor = scalebar.shadowColor{ + if let shadowColor = scalebar.shadowColor { var t = CGAffineTransform(translationX: shadowOffset.x, y: shadowOffset.y) - if let shadowPath = path.copy(using: &t){ + if let shadowPath = path.copy(using: &t) { context.setLineWidth(lineWidth) context.setStrokeColor(shadowColor.cgColor) context.addPath(shadowPath) @@ -1376,8 +1299,7 @@ internal class ScalebarDualUnitLineStyleRenderer: ScalebarRenderer{ self.drawText(text: topText, frame: topTextFrame, alignment: .right) // draw bottom text - if let numberString = Scalebar.numberFormatter.string(from: NSNumber(value: otherLineMapLength)){ - + if let numberString = Scalebar.numberFormatter.string(from: NSNumber(value: otherLineMapLength)) { let bottomUnitsText = " \(otherDisplayUnits.abbreviation)" let bottomUnitsTextWidth = bottomUnitsText.size(withAttributes: [.font: scalebar.font]).width @@ -1397,9 +1319,3 @@ internal class ScalebarDualUnitLineStyleRenderer: ScalebarRenderer{ context.restoreGState() } } - - - - - - diff --git a/Toolkit/ArcGISToolkit/TableViewController.swift b/Toolkit/ArcGISToolkit/TableViewController.swift index ff5c7031..c3f3f99a 100644 --- a/Toolkit/ArcGISToolkit/TableViewController.swift +++ b/Toolkit/ArcGISToolkit/TableViewController.swift @@ -14,7 +14,6 @@ import UIKit open class TableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { - public var cellReuseIdentifier = "cell" public var tableView = UITableView(frame: .zero) @@ -29,7 +28,7 @@ open class TableViewController: UIViewController, UITableViewDataSource, UITable tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) + ]) tableView.delegate = self tableView.dataSource = self @@ -44,13 +43,12 @@ open class TableViewController: UIViewController, UITableViewDataSource, UITable return UITableViewCell() } - public func goBack(_ completion: (()->Void)? ){ - if let nc = navigationController{ + public func goBack(_ completion: (() -> Void)? ) { + if let nc = navigationController { nc.popViewController(animated: true) completion?() - } - else{ - self.dismiss(animated: true){ + } else { + self.dismiss(animated: true) { completion?() } } diff --git a/Toolkit/ArcGISToolkit/TemplatePickerViewController.swift b/Toolkit/ArcGISToolkit/TemplatePickerViewController.swift index 13ff129e..2b463def 100644 --- a/Toolkit/ArcGISToolkit/TemplatePickerViewController.swift +++ b/Toolkit/ArcGISToolkit/TemplatePickerViewController.swift @@ -14,7 +14,7 @@ import ArcGIS /// An object that encapsulates information related to a feature template -public class FeatureTemplateInfo{ +public class FeatureTemplateInfo { /// The feature layer that the template is from public let featureLayer: AGSFeatureLayer /// The feature table that the template is from @@ -24,7 +24,7 @@ public class FeatureTemplateInfo{ /// The swatch for the feature template public var swatch: UIImage? - fileprivate init(featureLayer: AGSFeatureLayer, featureTable: AGSArcGISFeatureTable, featureTemplate: AGSFeatureTemplate, swatch: UIImage? = nil){ + fileprivate init(featureLayer: AGSFeatureLayer, featureTable: AGSArcGISFeatureTable, featureTemplate: AGSFeatureTemplate, swatch: UIImage? = nil) { self.featureLayer = featureLayer self.featureTable = featureTable self.featureTemplate = featureTemplate @@ -51,7 +51,6 @@ public protocol TemplatePickerViewControllerDelegate: AnyObject { /// and allowing them to choose one. /// This view controller is meant to be embedded in a navigation controller. public class TemplatePickerViewController: TableViewController { - /// The map which this view controller will display the feature templates from public let map: AGSMap? @@ -59,21 +58,21 @@ public class TemplatePickerViewController: TableViewController { private var currentDatasource = [String: [FeatureTemplateInfo]]() private var isFiltering: Bool = false private var unfilteredInfos = [FeatureTemplateInfo]() - private var currentInfos = [FeatureTemplateInfo](){ - didSet{ - tables = Set(self.currentInfos.map { $0.featureTable }).sorted(by: {$0.tableName < $1.tableName}) - currentDatasource = Dictionary(grouping: currentInfos, by: { $0.featureTable.tableName }) + private var currentInfos = [FeatureTemplateInfo]() { + didSet { + tables = Set(self.currentInfos.map { $0.featureTable }).sorted { $0.tableName < $1.tableName } + currentDatasource = Dictionary(grouping: currentInfos) { $0.featureTable.tableName } self.tableView.reloadData() } } /// Initializes a `TemplatePickerViewController` with a map. - public init(map: AGSMap){ + public init(map: AGSMap) { self.map = map super.init(nibName: nil, bundle: nil) } - required public init?(coder aDecoder: NSCoder) { + public required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -92,7 +91,7 @@ public class TemplatePickerViewController: TableViewController { navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(TemplatePickerViewController.cancelAction)) // get the templates from the map and load them as the datasource - if let map = map{ + if let map = map { getTemplateInfos(map: map, completion: loadInfosAndCreateSwatches) } } @@ -112,58 +111,53 @@ public class TemplatePickerViewController: TableViewController { } /// Gets the templates out of a map. - private func getTemplateInfos(map: AGSMap, completion: @escaping (([FeatureTemplateInfo])->Void) ){ - - map.load{ [weak self] error in - + private func getTemplateInfos(map: AGSMap, completion: @escaping (([FeatureTemplateInfo]) -> Void) ) { + map.load { [weak self] error in guard let self = self else { return } - guard error == nil else{ return } + guard error == nil else { return } - let allLayers : [AGSLayer] = (map.operationalLayers as Array + map.basemap.baseLayers as Array + map.basemap.referenceLayers as Array) as! [AGSLayer] + let allLayers: [AGSLayer] = (map.operationalLayers as Array + map.basemap.baseLayers as Array + map.basemap.referenceLayers as Array) as! [AGSLayer] let featureLayers = allLayers - .compactMap({ $0 as? AGSFeatureLayer }) - .filter({ $0.featureTable is AGSArcGISFeatureTable }) + .compactMap { $0 as? AGSFeatureLayer } + .filter { $0.featureTable is AGSArcGISFeatureTable } - AGSLoadObjects(featureLayers){ [weak self] _ in + AGSLoadObjects(featureLayers) { [weak self] _ in guard let self = self else { return } - let templates = featureLayers.flatMap({ return self.getTemplateInfos(featureLayer: $0) }) + let templates = featureLayers.flatMap { return self.getTemplateInfos(featureLayer: $0) } completion(templates) } } - } /// Gets the templates out of a feature layer and associated table. /// This should only be called once the feature layer is loaded. - private func getTemplateInfos(featureLayer: AGSFeatureLayer) -> [FeatureTemplateInfo]{ - - guard let table = featureLayer.featureTable as? AGSArcGISFeatureTable else{ + private func getTemplateInfos(featureLayer: AGSFeatureLayer) -> [FeatureTemplateInfo] { + guard let table = featureLayer.featureTable as? AGSArcGISFeatureTable else { return [] } - guard let popupDef = featureLayer.popupDefinition, popupDef.allowEdit || table.canAddFeature else{ + guard let popupDef = featureLayer.popupDefinition, popupDef.allowEdit || table.canAddFeature else { return [] } - let tableTemplates = table.featureTemplates.map({ - FeatureTemplateInfo(featureLayer:featureLayer, featureTable:table, featureTemplate:$0) - }) + let tableTemplates = table.featureTemplates.map { + FeatureTemplateInfo(featureLayer: featureLayer, featureTable: table, featureTemplate: $0) + } let typeTemplates = table.featureTypes .lazy - .flatMap({ $0.templates }) - .map({ FeatureTemplateInfo(featureLayer:featureLayer, featureTable:table, featureTemplate:$0) }) + .flatMap { $0.templates } + .map { FeatureTemplateInfo(featureLayer: featureLayer, featureTable: table, featureTemplate: $0) } return tableTemplates + typeTemplates } /// Loads the template infos as the current datasource /// and creates swatches for them - private func loadInfosAndCreateSwatches(infos: [FeatureTemplateInfo]){ - + private func loadInfosAndCreateSwatches(infos: [FeatureTemplateInfo]) { // if filtering, need to disable it - if isFiltering{ + if isFiltering { navigationItem.searchController?.isActive = false } @@ -174,14 +168,13 @@ public class TemplatePickerViewController: TableViewController { currentInfos = unfilteredInfos // generate swatches for the layer infos - for index in infos.indices{ + for index in infos.indices { let info = infos[index] - if let feature = info.featureTable.createFeature(with: info.featureTemplate){ + if let feature = info.featureTable.createFeature(with: info.featureTemplate) { let sym = info.featureLayer.renderer?.symbol(for: feature) - sym?.createSwatch{ [weak self] image, error in - + sym?.createSwatch { [weak self] image, error in guard let self = self else { return } - guard error == nil else{ return } + guard error == nil else { return } // update info with swatch infos[index].swatch = image @@ -196,7 +189,7 @@ public class TemplatePickerViewController: TableViewController { // MARK: TableView delegate/datasource methods - public func numberOfSectionsInTableView(_ tableView: UITableView) -> Int{ + public func numberOfSectionsInTableView(_ tableView: UITableView) -> Int { return tables.count } @@ -206,7 +199,6 @@ public class TemplatePickerViewController: TableViewController { } public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - // when the user taps on a feature type // first get the selected object @@ -221,7 +213,7 @@ public class TemplatePickerViewController: TableViewController { // Only do this if not being presented from a nav controller // as in that case, it causes problems when the delegate that pushed this VC // tries to pop it off the stack. - if presentingViewController != nil{ + if presentingViewController != nil { navigationItem.searchController?.isActive = false } @@ -244,14 +236,15 @@ public class TemplatePickerViewController: TableViewController { // MARK: go back, cancel methods - @objc private func cancelAction(){ + @objc + private func cancelAction() { // If the search controller is still active, the delegate will not be // able to dismiss this if they showed this modally. // (or wrapped it in a navigation controller and showed that modally) // Only do this if not being presented from a nav controller // as in that case, it causes problems when the delegate that pushed this VC // tries to pop it off the stack. - if presentingViewController != nil{ + if presentingViewController != nil { navigationItem.searchController?.isActive = false } delegate?.templatePickerViewControllerDidCancel(self) @@ -259,14 +252,13 @@ public class TemplatePickerViewController: TableViewController { // MARK: IndexPath -> Info - private func info(for indexPath: IndexPath) -> FeatureTemplateInfo{ + private func info(for indexPath: IndexPath) -> FeatureTemplateInfo { let tableName = tables[indexPath.section].tableName let infos = self.currentDatasource[tableName]! return infos[indexPath.row] } - private func indexPath(for info: FeatureTemplateInfo) -> IndexPath{ - + private func indexPath(for info: FeatureTemplateInfo) -> IndexPath { let tableIndex = tables.index { $0.tableName == info.featureTable.tableName }! let infos = self.currentDatasource[info.featureTable.tableName]! let infoIndex = infos.index { $0 === info }! @@ -282,19 +274,18 @@ extension TemplatePickerViewController: UISearchResultsUpdating { isFiltering = true DispatchQueue.global(qos: DispatchQoS.QoSClass.userInitiated).async { [weak self] in guard let self = self else { return } - let filtered = self.unfilteredInfos.filter{ + let filtered = self.unfilteredInfos.filter { $0.featureTemplate.name.range(of: text, options: .caseInsensitive) != nil } DispatchQueue.main.async { [weak self] in guard let self = self else { return } // Make sure we are still filtering - if self.isFiltering{ + if self.isFiltering { self.currentInfos = filtered } } } - } - else { + } else { isFiltering = false self.currentInfos = self.unfilteredInfos } diff --git a/Toolkit/ArcGISToolkit/TimeSlider.swift b/Toolkit/ArcGISToolkit/TimeSlider.swift index 81252c9e..a51b20cc 100644 --- a/Toolkit/ArcGISToolkit/TimeSlider.swift +++ b/Toolkit/ArcGISToolkit/TimeSlider.swift @@ -19,7 +19,6 @@ import ArcGIS // MARK: - Time Slider Control public class TimeSlider: UIControl { - // MARK: - Enumerations /** @@ -106,7 +105,7 @@ public class TimeSlider: UIControl { isRangeEnabled = (startTime != endTime) } } - // This means there is only one thumb needs to be displayed and current extent start and end times are same. + // This means there is only one thumb needs to be displayed and current extent start and end times are same. else if let startTime = currentExtent?.startTime, currentExtent?.endTime == nil { // // Only one thumb should be displayed @@ -119,7 +118,7 @@ public class TimeSlider: UIControl { // Start and end time must be same. currentExtentEndTime = currentExtentStartTime } - // This means there is only one thumb needs to be displayed and current extent start and end times are same. + // This means there is only one thumb needs to be displayed and current extent start and end times are same. else if let endTime = currentExtent?.endTime, currentExtent?.startTime == nil { // // Only one thumb should be displayed @@ -132,8 +131,8 @@ public class TimeSlider: UIControl { // Start and end time must be same. currentExtentEndTime = currentExtentStartTime } - // Set start and end time to nil if current extent is nil - // or it's start and end times are nil + // Set start and end time to nil if current extent is nil + // or it's start and end times are nil else if currentExtent == nil || (currentExtent?.startTime == nil && currentExtent?.endTime == nil) { currentExtentStartTime = nil currentExtentEndTime = nil @@ -202,8 +201,7 @@ public class TimeSlider: UIControl { didSet { if isRangeEnabled { upperThumbLayer.isPinned = isEndTimePinned - } - else { + } else { isStartTimePinned = isEndTimePinned lowerThumbLayer.isPinned = isEndTimePinned upperThumbLayer.isPinned = isEndTimePinned @@ -222,14 +220,12 @@ public class TimeSlider: UIControl { // Set current extent if it's nil. if currentExtent == nil, let fullExtent = fullExtent { currentExtent = fullExtent - } - else if fullExtent == nil { + } else if fullExtent == nil { timeSteps?.removeAll() tickMarks.removeAll() removeTickMarkLabels() currentExtent = fullExtent - } - else { + } else { // // It is possible that the current extent times are outside of the range of // new full extent times. Adjust and sanp them to the tick marks. @@ -526,8 +522,7 @@ public class TimeSlider: UIControl { // Start the timer with specified playback interval timer = Timer.scheduledTimer(timeInterval: playbackInterval, target: self, selector: #selector(timerAction), userInfo: nil, repeats: true) - } - else { + } else { // // Set the button state playPauseButton.isSelected = false @@ -553,8 +548,7 @@ public class TimeSlider: UIControl { didSet { if observeGeoView { addObservers() - } - else { + } else { removeObservers() } } @@ -584,9 +578,9 @@ public class TimeSlider: UIControl { fullExtentEndTimeLabel.isHidden = !isSliderVisible currentExtentStartTimeLabel.isHidden = !isSliderVisible currentExtentEndTimeLabel.isHidden = !isSliderVisible - tickMarkLabels.forEach({ (tickMarkLabel) in + tickMarkLabels.forEach { (tickMarkLabel) in tickMarkLabel.isHidden = !isSliderVisible - }) + } invalidateIntrinsicContentSize() setNeedsLayout() } @@ -669,10 +663,10 @@ public class TimeSlider: UIControl { private let tickMarkLayer = TimeSliderTickMarkLayer() private let lowerThumbLayer = TimeSliderThumbLayer() private let upperThumbLayer = TimeSliderThumbLayer() - private let fullExtentStartTimeLabel: CATextLayer = CATextLayer() - private let fullExtentEndTimeLabel: CATextLayer = CATextLayer() - private let currentExtentStartTimeLabel: CATextLayer = CATextLayer() - private let currentExtentEndTimeLabel: CATextLayer = CATextLayer() + private let fullExtentStartTimeLabel = CATextLayer() + private let fullExtentEndTimeLabel = CATextLayer() + private let currentExtentStartTimeLabel = CATextLayer() + private let currentExtentEndTimeLabel = CATextLayer() private let minimumFrameWidth: CGFloat = 250.0 private let maximumThumbSize: CGFloat = 50.0 @@ -688,7 +682,7 @@ public class TimeSlider: UIControl { private let forwardButton = UIButton(type: .custom) private let backButton = UIButton(type: .custom) - fileprivate var pinnedThumbFillColor: UIColor = UIColor.black + fileprivate var pinnedThumbFillColor = UIColor.black // If set to True, it will show two thumbs, otherwise only one. Default is True. fileprivate var isRangeEnabled: Bool = true { @@ -707,10 +701,10 @@ public class TimeSlider: UIControl { private var mapLayersObservation: NSKeyValueObservation? private var sceneLayersObservation: NSKeyValueObservation? private var timeExtentObservation: NSKeyValueObservation? - + // MARK: - Override Functions - required public init?(coder: NSCoder) { + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -759,7 +753,7 @@ public class TimeSlider: UIControl { return lowerThumbLayer.isHighlighted || upperThumbLayer.isHighlighted } - open override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + override public func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { // // Get the touch location let location = touch.location(in: self) @@ -770,8 +764,7 @@ public class TimeSlider: UIControl { // Set values based on selected thumb if lowerThumbLayer.isHighlighted { updateCurrentExtentStartTime(Date(timeIntervalSince1970: selectedValue)) - } - else if upperThumbLayer.isHighlighted { + } else if upperThumbLayer.isHighlighted { updateCurrentExtentEndTime(Date(timeIntervalSince1970: selectedValue)) } @@ -805,7 +798,7 @@ public class TimeSlider: UIControl { } // Refresh the slider when requried - public override func layoutSubviews() { + override public func layoutSubviews() { // // Calculate time steps if timeSteps == nil || timeSteps?.isEmpty == true { @@ -821,7 +814,7 @@ public class TimeSlider: UIControl { } // Set intrinsic content size - public override var intrinsicContentSize: CGSize { + override public var intrinsicContentSize: CGSize { let intrinsicHeight: CGFloat if isSliderVisible { if playbackButtonsVisible { @@ -847,7 +840,8 @@ public class TimeSlider: UIControl { to initialize slider's fullExtent, currentExtent and timeStepInterval properties. Setting observeGeoView to true will observe changes in operational layers and time extent of geoView. */ - public func initializeTimeProperties(geoView: AGSGeoView, observeGeoView: Bool, completion: @escaping (Error?)->Void) { + // swiftlint:disable cyclomatic_complexity + public func initializeTimeProperties(geoView: AGSGeoView, observeGeoView: Bool, completion: @escaping (Error?) -> Void) { // // Set operational layers guard let operationalLayers = geoView.operationalLayers, !operationalLayers.isEmpty else { @@ -862,8 +856,7 @@ public class TimeSlider: UIControl { // Set map/scene if let mapView = geoView as? AGSMapView { map = mapView.map - } - else if let sceneView = geoView as? AGSSceneView { + } else if let sceneView = geoView as? AGSSceneView { scene = sceneView.scene } @@ -900,7 +893,7 @@ public class TimeSlider: UIControl { // Once, all layers are loaded, // loop through all of them. - operationalLayers.forEach({ (layer) in + operationalLayers.forEach { (layer) in // // The layer must be time aware, supports time filtering and time filtering is enabled. guard let timeAwareLayer = layer as? AGSTimeAware, timeAwareLayer.supportsTimeFiltering, timeAwareLayer.isTimeFilteringEnabled else { @@ -916,7 +909,7 @@ public class TimeSlider: UIControl { // This is an async operation to find out time step interval and // whether range time filtering is supported by the layer. dispatchGroup.enter() - self.findTimeStepIntervalAndIsRangeTimeFilteringSupported(for: timeAwareLayer, completion: { (timeInterval, supportsRangeFiltering) in + self.findTimeStepIntervalAndIsRangeTimeFilteringSupported(for: timeAwareLayer) { (timeInterval, supportsRangeFiltering) in // // Set the range filtering value supportsRangeTimeFiltering = supportsRangeFiltering @@ -927,8 +920,7 @@ public class TimeSlider: UIControl { if timeInterval > layersTimeInterval { timeAwareLayersStepInterval = timeInterval } - } - else { + } else { timeAwareLayersStepInterval = timeInterval } } @@ -937,10 +929,10 @@ public class TimeSlider: UIControl { // Leave the group so we can set time // properties and notify dispatchGroup.leave() - }) - }) + } + } - dispatchGroup.notify(queue: DispatchQueue.main, execute: { + dispatchGroup.notify(queue: DispatchQueue.main) { // // If full extent or time step interval is not available then // we cannot initialize the slider. Finish with error. @@ -960,8 +952,7 @@ public class TimeSlider: UIControl { // calculate using default timeStepCount if let timeStepInterval = timeAwareLayersStepInterval { self.timeStepInterval = timeStepInterval - } - else { + } else { self.timeStepInterval = self.calculateTimeStepInterval(for: layersFullExtent, timeStepCount: 0) } @@ -969,22 +960,22 @@ public class TimeSlider: UIControl { // current extent to either the full extent's start (if range filtering is not supported), or to the entire full extent. if let geoViewTimeExtent = self.geoView?.timeExtent, !self.reInitializeTimeProperties { self.currentExtent = geoViewTimeExtent - } - else { + } else { if let fullExtentStartTime = self.fullExtent?.startTime, let fullExtentEndTime = self.fullExtent?.endTime { self.currentExtent = supportsRangeTimeFiltering ? AGSTimeExtent(startTime: fullExtentStartTime, endTime: fullExtentEndTime) : AGSTimeExtent(timeInstant: fullExtentStartTime) } } completion(nil) - }) + } } } - + // swiftlint:enable cyclomatic_complexity + /** This will initialize slider's fullExtent, currentExtent and timeStepInterval properties if the layer is visible and participate in time based filtering. */ - public func initializeTimeProperties(timeAwareLayer: AGSTimeAware, completion: @escaping (Error?)->Void) { + public func initializeTimeProperties(timeAwareLayer: AGSTimeAware, completion: @escaping (Error?) -> Void) { // // The layer must be loadable. guard let layer = timeAwareLayer as? AGSLoadable else { @@ -992,7 +983,7 @@ public class TimeSlider: UIControl { return } - layer.load(completion: { [weak self] (error) in + layer.load { [weak self] (error) in // // If layer fails to load then // return with an error. @@ -1016,7 +1007,7 @@ public class TimeSlider: UIControl { // // This is an async operation to find out time step interval and // whether range time filtering is supported by the layer. - self.findTimeStepIntervalAndIsRangeTimeFilteringSupported(for: timeAwareLayer, completion: { [weak self] (timeInterval, supportsRangeTimeFiltering) in + self.findTimeStepIntervalAndIsRangeTimeFilteringSupported(for: timeAwareLayer) { [weak self] (timeInterval, supportsRangeTimeFiltering) in // // Make sure self is around guard let self = self else { @@ -1041,8 +1032,7 @@ public class TimeSlider: UIControl { // calculate using default timeStepCount if let timeInterval = timeInterval { self.timeStepInterval = timeInterval - } - else { + } else { self.timeStepInterval = self.calculateTimeStepInterval(for: fullTimeExtent, timeStepCount: 0) } @@ -1053,15 +1043,15 @@ public class TimeSlider: UIControl { // Slider is loaded successfully. completion(nil) - }) - }) + } + } } /** This will initialize slider's fullExtent, currentExtent and timeStepInterval properties based on provided step count and full extent. The current extent will be set to a time instant. */ - public func initializeTimeSteps(timeStepCount: Int, fullExtent: AGSTimeExtent, completion: @escaping (Error?)->Void) { + public func initializeTimeSteps(timeStepCount: Int, fullExtent: AGSTimeExtent, completion: @escaping (Error?) -> Void) { // // There should be at least two time steps // for time slider to work correctly. @@ -1071,7 +1061,7 @@ public class TimeSlider: UIControl { } // Full extent's start and end time must be available for time slider to work correctly. - guard let fullExtentStartTime = fullExtent.startTime, let _ = fullExtent.endTime else { + guard let fullExtentStartTime = fullExtent.startTime, fullExtent.endTime != nil else { completion(NSError(domain: AGSErrorDomain, code: AGSErrorCode.commonNoData.rawValue, userInfo: [NSLocalizedDescriptionKey: "fullExtent is not available to calculate time steps."])) return } @@ -1092,7 +1082,8 @@ public class TimeSlider: UIControl { /** Moves the slider thumbs forward with provided time steps. */ - @discardableResult public func stepForward(timeSteps: Int) -> Bool { + @discardableResult + public func stepForward(timeSteps: Int) -> Bool { // // Time steps must be greater than 0 if timeSteps > 0 { @@ -1104,7 +1095,8 @@ public class TimeSlider: UIControl { /** Moves the slider thumbs back with provided time steps. */ - @discardableResult public func stepBack(timeSteps: Int) -> Bool { + @discardableResult + public func stepBack(timeSteps: Int) -> Bool { // // Time steps must be greater than 0 if timeSteps > 0 { @@ -1115,21 +1107,25 @@ public class TimeSlider: UIControl { // MARK: - Actions - @objc private func forwardAction(_ sender: UIButton) { + @objc + private func forwardAction(_ sender: UIButton) { isPlaying = false stepForward(timeSteps: 1) } - @objc private func backAction(_ sender: UIButton) { + @objc + private func backAction(_ sender: UIButton) { isPlaying = false stepBack(timeSteps: 1) } - @objc private func playPauseAction(_ sender: UIButton) { - isPlaying = !isPlaying + @objc + private func playPauseAction(_ sender: UIButton) { + isPlaying.toggle() } - @discardableResult private func moveTimeStep(timeSteps: Int) -> Bool { + @discardableResult + private func moveTimeStep(timeSteps: Int) -> Bool { // // Time steps must be between 1 and count of calculated time steps if let ts = self.timeSteps, timeSteps < ts.count, let startTime = currentExtentStartTime, let endTime = currentExtentEndTime { @@ -1142,7 +1138,7 @@ public class TimeSlider: UIControl { // Set the start time step index if it's not set if startTimeStepIndex <= 0 { - if let (index, date) = closestTimeStep(for: startTime) { + if let (index, date) = closestTimeStep(for: startTime) { currentExtentStartTime = date startTimeStepIndex = index } @@ -1158,16 +1154,15 @@ public class TimeSlider: UIControl { // Get the minimum and maximum allowable time step indexes. This is not necessarily the end of the time slider since // the start and end times may be pinned. - let minTimeStepIndex = !isStartTimePinned ? 0 : startTimeStepIndex; - let maxTimeStepIndex = !isEndTimePinned ? ts.count - 1 : endTimeStepIndex; + let minTimeStepIndex = !isStartTimePinned ? 0 : startTimeStepIndex + let maxTimeStepIndex = !isEndTimePinned ? ts.count - 1 : endTimeStepIndex // Get the number of steps by which to move the current time. If the number specified in the method call would move the current time extent // beyond the valid range, clamp the number of steps to the maximum number that the extent can move in the specified direction. var validTimeStepDelta = 0 if timeSteps > 0 { validTimeStepDelta = startTimeStepIndex + timeSteps <= maxTimeStepIndex ? timeSteps : maxTimeStepIndex - startTimeStepIndex - } - else { + } else { validTimeStepDelta = endTimeStepIndex + timeSteps >= minTimeStepIndex ? timeSteps : minTimeStepIndex - endTimeStepIndex } @@ -1184,16 +1179,15 @@ public class TimeSlider: UIControl { endTimeStepIndex + validTimeStepDelta : endTimeStepIndex // Evaluate how many time steps the start and end were moved by and whether they were able to be moved by the requested number of steps - let startDelta = newStartTimeStepIndex - startTimeStepIndex; - let endDelta = newEndTimeStepIndex - endTimeStepIndex; - let canMoveStartAndEndByTimeSteps = startDelta == timeSteps && endDelta == timeSteps; - let canMoveStartOrEndByTimeSteps = startDelta == timeSteps || endDelta == timeSteps; + let startDelta = newStartTimeStepIndex - startTimeStepIndex + let endDelta = newEndTimeStepIndex - endTimeStepIndex + let canMoveStartAndEndByTimeSteps = startDelta == timeSteps && endDelta == timeSteps + let canMoveStartOrEndByTimeSteps = startDelta == timeSteps || endDelta == timeSteps - let isRequestedMoveValid = canMoveStartAndEndByTimeSteps || canMoveStartOrEndByTimeSteps; + let isRequestedMoveValid = canMoveStartAndEndByTimeSteps || canMoveStartOrEndByTimeSteps // Apply the new extent if the new time indexes represent a valid change if isRequestedMoveValid && newStartTimeStepIndex < ts.count && newEndTimeStepIndex < ts.count { - // Set new times and time step indexes currentExtentStartTime = ts[newStartTimeStepIndex] startTimeStepIndex = newStartTimeStepIndex @@ -1217,13 +1211,13 @@ public class TimeSlider: UIControl { return false } - @objc private func timerAction() { + @objc + private func timerAction() { if let geoView = self.geoView { if geoView.drawStatus == .completed { handlePlaying() } - } - else { + } else { handlePlaying() } } @@ -1403,21 +1397,19 @@ public class TimeSlider: UIControl { lowerThumbLayer.isHidden = !isSliderVisible lowerThumbLayer.frame = lowerThumbFrame lowerThumbLayer.setNeedsDisplay() - } - else { + } else { lowerThumbLayer.isHidden = true } // Set upper thumb layer frame - if let endTime = currentExtentEndTime, isRangeEnabled, fullExtent != nil { + if let endTime = currentExtentEndTime, isRangeEnabled, fullExtent != nil { let upperThumbCenter = CGFloat(position(for: endTime.timeIntervalSince1970)) let upperThumbOrigin = CGPoint(x: trackLayerSidePadding + upperThumbCenter - thumbSize.width / 2.0, y: trackLayerFrame.midY - thumbSize.height / 2.0) let upperThumbFrame = CGRect(origin: upperThumbOrigin, size: thumbSize) upperThumbLayer.isHidden = !isSliderVisible upperThumbLayer.frame = upperThumbFrame upperThumbLayer.setNeedsDisplay() - } - else { + } else { upperThumbLayer.isHidden = true } } @@ -1480,8 +1472,7 @@ public class TimeSlider: UIControl { let tickLayerStartTimeLabelY = tickMarkLayer.frame.maxY + labelPadding let startTimeLabelY = max(thumbStartTimeLabelY, tickLayerStartTimeLabelY) fullExtentStartTimeLabel.frame = CGRect(x: startTimeLabelX, y: startTimeLabelY, width: startTimeLabelSize.width, height: startTimeLabelSize.height) - } - else { + } else { fullExtentStartTimeLabel.string = "" fullExtentStartTimeLabel.isHidden = true } @@ -1502,8 +1493,7 @@ public class TimeSlider: UIControl { let tickLayerEndTimeLabelY = tickMarkLayer.frame.maxY + labelPadding let endTimeLabelY = max(thumbEndTimeLabelY, tickLayerEndTimeLabelY) fullExtentEndTimeLabel.frame = CGRect(x: endTimeLabelX, y: endTimeLabelY, width: endTimeLabelSize.width, height: endTimeLabelSize.height) - } - else { + } else { fullExtentEndTimeLabel.string = "" fullExtentEndTimeLabel.isHidden = true } @@ -1512,6 +1502,7 @@ public class TimeSlider: UIControl { CATransaction.commit() } + // swiftlint:disable cyclomatic_complexity private func updateCurrentExtentLabelFrames() { // // If label mode is not thumbs then @@ -1530,7 +1521,7 @@ public class TimeSlider: UIControl { // // Update current extent start time label - if let startTime = currentExtentStartTime, fullExtent != nil { + if let startTime = currentExtentStartTime, fullExtent != nil { let startTimeString = string(for: startTime, style: currentExtentLabelDateStyle) currentExtentStartTimeLabel.string = startTimeString let startTimeLabelSize: CGSize = startTimeString.size(withAttributes: [kCTFontAttributeName as NSAttributedString.Key: currentExtentLabelFont]) @@ -1541,14 +1532,12 @@ public class TimeSlider: UIControl { if let fullExtentStartTime = fullExtent?.startTime, fullExtentStartTime == startTime { currentExtentStartTimeLabel.isHidden = true } - } - else if startTimeLabelX + startTimeLabelSize.width > bounds.maxX - labelSidePadding { + } else if startTimeLabelX + startTimeLabelSize.width > bounds.maxX - labelSidePadding { startTimeLabelX = bounds.maxX - startTimeLabelSize.width - labelSidePadding if let fullExtentEndTime = fullExtent?.endTime, fullExtentEndTime == startTime { currentExtentStartTimeLabel.isHidden = true } - } - else if !currentExtentEndTimeLabel.isHidden && currentExtentEndTimeLabel.frame.origin.x >= 0.0 && startTimeLabelX + startTimeLabelSize.width > currentExtentEndTimeLabel.frame.origin.x { + } else if !currentExtentEndTimeLabel.isHidden && currentExtentEndTimeLabel.frame.origin.x >= 0.0 && startTimeLabelX + startTimeLabelSize.width > currentExtentEndTimeLabel.frame.origin.x { startTimeLabelX = currentExtentEndTimeLabel.frame.origin.x - startTimeLabelSize.width - paddingBetweenLabels } @@ -1556,14 +1545,13 @@ public class TimeSlider: UIControl { let tickLayerStartTimeLabelY = tickMarkLayer.frame.minY - currentExtentStartTimeLabel.frame.height - labelPadding let startTimeLabelY = min(thumbStartTimeLabelY, tickLayerStartTimeLabelY) currentExtentStartTimeLabel.frame = CGRect(x: startTimeLabelX, y: startTimeLabelY, width: startTimeLabelSize.width, height: startTimeLabelSize.height) - } - else { + } else { currentExtentStartTimeLabel.string = "" currentExtentStartTimeLabel.isHidden = true } // Update current extent end time label - if let endTime = currentExtentEndTime, isRangeEnabled, fullExtent != nil { + if let endTime = currentExtentEndTime, isRangeEnabled, fullExtent != nil { let endTimeString = string(for: endTime, style: currentExtentLabelDateStyle) currentExtentEndTimeLabel.string = endTimeString let endTimeLabelSize: CGSize = endTimeString.size(withAttributes: [kCTFontAttributeName as NSAttributedString.Key: currentExtentLabelFont]) @@ -1574,14 +1562,12 @@ public class TimeSlider: UIControl { if let fullExtentStartTime = fullExtent?.startTime, fullExtentStartTime == endTime { currentExtentEndTimeLabel.isHidden = true } - } - else if endTimeLabelX + endTimeLabelSize.width > bounds.maxX - labelSidePadding { + } else if endTimeLabelX + endTimeLabelSize.width > bounds.maxX - labelSidePadding { endTimeLabelX = bounds.maxX - endTimeLabelSize.width - labelSidePadding if let fullExtentEndTime = fullExtent?.endTime, fullExtentEndTime == endTime { currentExtentEndTimeLabel.isHidden = true } - } - else if !currentExtentStartTimeLabel.isHidden && endTimeLabelX < currentExtentStartTimeLabel.frame.origin.x + currentExtentStartTimeLabel.frame.width { + } else if !currentExtentStartTimeLabel.isHidden && endTimeLabelX < currentExtentStartTimeLabel.frame.origin.x + currentExtentStartTimeLabel.frame.width { endTimeLabelX = currentExtentStartTimeLabel.frame.origin.x + currentExtentStartTimeLabel.frame.width + paddingBetweenLabels } @@ -1589,8 +1575,7 @@ public class TimeSlider: UIControl { let tickLayerEndTimeLabelY = tickMarkLayer.frame.minY - currentExtentEndTimeLabel.frame.height - labelPadding let endTimeLabelY = min(thumbEndTimeLabelY, tickLayerEndTimeLabelY) currentExtentEndTimeLabel.frame = CGRect(x: endTimeLabelX, y: endTimeLabelY, width: endTimeLabelSize.width, height: endTimeLabelSize.height) - } - else { + } else { currentExtentEndTimeLabel.string = "" currentExtentEndTimeLabel.isHidden = true } @@ -1598,7 +1583,9 @@ public class TimeSlider: UIControl { // Commit the transaction CATransaction.commit() } - + // swiftlint:enable cyclomatic_complexity + + // swiftlint:disable cyclomatic_complexity private func positionTickMarks() { // // Bail out if time steps are not available @@ -1632,7 +1619,7 @@ public class TimeSlider: UIControl { if maxMajorTickInterval >= majorTickInterval { // // Calculate the number of ticks between each major tick and the index of the first major tick - for i in majorTickInterval.. (index: Int, date: Date)? { + private func closestTimeStep(for date: Date) -> (index: Int, date: Date)? { // // Return nil if not able to find the closest time step for the provided date. - guard let closest = timeSteps?.enumerated().min( by: { abs($0.1.timeIntervalSince1970 - date.timeIntervalSince1970) < abs($1.1.timeIntervalSince1970 - date.timeIntervalSince1970)} ) else { + guard let closest = timeSteps?.enumerated().min( by: { abs($0.1.timeIntervalSince1970 - date.timeIntervalSince1970) < abs($1.1.timeIntervalSince1970 - date.timeIntervalSince1970) }) else { return nil } @@ -1951,9 +1935,9 @@ public class TimeSlider: UIControl { private func removeTickMarkLabels() { // // Remove layers from the view - tickMarkLabels.forEach({ (tickMarkLabel) in + tickMarkLabels.forEach { (tickMarkLabel) in tickMarkLabel.removeFromSuperlayer() - }) + } // Clear the array tickMarkLabels.removeAll() @@ -1963,7 +1947,7 @@ public class TimeSlider: UIControl { private func notifyChangeOfCurrentExtent() { // // Notify only if current date are different than previous dates. - if (previousCurrentExtentStartTime != currentExtentStartTime || previousCurrentExtentEndTime != currentExtentEndTime) { + if previousCurrentExtentStartTime != currentExtentStartTime || previousCurrentExtentEndTime != currentExtentEndTime { // // Update previous values previousCurrentExtentStartTime = currentExtentStartTime @@ -1995,13 +1979,12 @@ public class TimeSlider: UIControl { let startTime = Date(timeIntervalSince1970: boundCurrentExtentStartTime(value: startTime.timeIntervalSince1970)) // If time steps are available then snap it to the closest time step and set the index. - if let ts = timeSteps, ts.count > 0 { + if let ts = timeSteps, !ts.isEmpty { if let (index, date) = closestTimeStep(for: startTime) { currentExtentStartTime = date startTimeStepIndex = index } - } - else { + } else { currentExtentStartTime = startTime startTimeStepIndex = -1 } @@ -2013,20 +1996,20 @@ public class TimeSlider: UIControl { let endTime = Date(timeIntervalSince1970: boundCurrentExtentEndTime(value: endTime.timeIntervalSince1970)) // If time steps are available then snap it to the closest time step and set the index. - if let ts = timeSteps, ts.count > 0 { + if let ts = timeSteps, !ts.isEmpty { if let (index, date) = closestTimeStep(for: endTime) { currentExtentEndTime = date endTimeStepIndex = index } - } - else { + } else { currentExtentEndTime = endTime endTimeStepIndex = -1 } } // This function returns time step interval and whether given layer supports range time filtering or not. - private func findTimeStepIntervalAndIsRangeTimeFilteringSupported(for timeAwareLayer: AGSTimeAware, completion: @escaping ((timeStepInterval: AGSTimeValue?, supportsRangeTimeFiltering: Bool))->Void) { + // swiftlint:disable cyclomatic_complexity + private func findTimeStepIntervalAndIsRangeTimeFilteringSupported(for timeAwareLayer: AGSTimeAware, completion: @escaping ((timeStepInterval: AGSTimeValue?, supportsRangeTimeFiltering: Bool)) -> Void) { // // The default is false var supportsRangeTimeFiltering = false @@ -2034,13 +2017,11 @@ public class TimeSlider: UIControl { // Get the time interval of the layer var timeStepInterval = timeAwareLayer.timeInterval - // If the layer is map image layer then we need to find out details from the // sublayers. Let's load all sublayers and check whether sub layers supports // range time filtering and largets time step interval. if let mapImageLayer = timeAwareLayer as? AGSArcGISMapImageLayer { - AGSLoadObjects(mapImageLayer.mapImageSublayers as! [AGSLoadable], { [weak self] (loaded) in - + AGSLoadObjects(mapImageLayer.mapImageSublayers as! [AGSLoadable]) { [weak self] (loaded) in // Make sure self is around guard let self = self else { return @@ -2053,7 +2034,7 @@ public class TimeSlider: UIControl { // // If either start or end time field name is not available then // set supportsRangeTimeFiltering to false - if timeInfo.startTimeField.count <= 0 || timeInfo.endTimeField.count <= 0 { + if timeInfo.startTimeField.isEmpty || timeInfo.endTimeField.isEmpty { supportsRangeTimeFiltering = false } @@ -2064,8 +2045,7 @@ public class TimeSlider: UIControl { if interval1 > interval2 { timeInterval = interval1 } - } - else { + } else { timeInterval = interval1 } } @@ -2079,22 +2059,22 @@ public class TimeSlider: UIControl { } } completion((timeStepInterval, supportsRangeTimeFiltering)) - }) - } - else { + } + } else { // // If layer is not map image layer then find layer supports // range time filtering or not and set time step interval // from timeInfo if not available on the layer. if let timeAwareLayer = timeAwareLayer as? AGSLoadable, let timeInfo = timeInfo(for: timeAwareLayer) { - if timeInfo.startTimeField.count <= 0 || timeInfo.endTimeField.count <= 0 { + if timeInfo.startTimeField.isEmpty || timeInfo.endTimeField.isEmpty { supportsRangeTimeFiltering = false } } completion((timeStepInterval, supportsRangeTimeFiltering)) } } - + // swiftlint:enable cyclomatic_complexity + // Returns layer's time info if available. The parameter cannot be of type AGSLayer because // ArcGISSublayer does not inherit from AGSLayer. It is expected that this function is // called on already loaded object @@ -2107,11 +2087,9 @@ public class TimeSlider: UIControl { if layer.loadStatus == .loaded { if let sublayer = layer as? AGSArcGISSublayer { return sublayer.mapServiceSublayerInfo?.timeInfo - } - else if let featureLayer = layer as? AGSFeatureLayer, let featureTable = featureLayer.featureTable as? AGSArcGISFeatureTable { + } else if let featureLayer = layer as? AGSFeatureLayer, let featureTable = featureLayer.featureTable as? AGSArcGISFeatureTable { return featureTable.layerInfo?.timeInfo - } - else if let rasterLayer = layer as? AGSRasterLayer, let imageServiceRaster = rasterLayer.raster as? AGSImageServiceRaster { + } else if let rasterLayer = layer as? AGSRasterLayer, let imageServiceRaster = rasterLayer.raster as? AGSImageServiceRaster { return imageServiceRaster.serviceInfo?.timeInfo } } @@ -2129,7 +2107,7 @@ public class TimeSlider: UIControl { // Re initialize time slider reInitializeTimeProperties = true - initializeTimeProperties(geoView: geoView, observeGeoView: observeGeoView, completion: { [weak self] (error) in + initializeTimeProperties(geoView: geoView, observeGeoView: observeGeoView) { [weak self] (error) in // // Bail out if there is an error guard error == nil else { @@ -2138,7 +2116,7 @@ public class TimeSlider: UIControl { // Set the flag self?.reInitializeTimeProperties = false - }) + } } // This function checks whether the observed value of operationalLayers @@ -2166,14 +2144,14 @@ public class TimeSlider: UIControl { } // This function returns a string for the given date and date style + // swiftlint:disable cyclomatic_complexity private func string(for date: Date, style: DateStyle) -> String { // // Create the date formatter to get the string for a date - let dateFormatter: DateFormatter = DateFormatter() + let dateFormatter = DateFormatter() dateFormatter.timeZone = timeZone switch style { - case .dayShortMonthYear: dateFormatter.setLocalizedDateFormatFromTemplate("d MMM y") case .longDate: @@ -2202,7 +2180,8 @@ public class TimeSlider: UIControl { return dateFormatter.string(from: date) } - + // swiftlint:enable cyclomatic_complexity + // Calculates time step interval based on provided time extent and time step count private func calculateTimeStepInterval(for timeExtent: AGSTimeExtent, timeStepCount: Int) -> AGSTimeValue? { if let startTime = timeExtent.startTime, let endTime = timeExtent.endTime { @@ -2217,8 +2196,7 @@ public class TimeSlider: UIControl { if let (duration, component) = timeIntervalDate.offset(from: startTime) { return AGSTimeValue.fromCalenderComponents(duration: Double(duration), component: component) } - } - else { + } else { if let startTime = timeExtent.startTime, let endTime = timeExtent.endTime { // // Since the time step count is 0 we'll use default duration 1 @@ -2230,7 +2208,6 @@ public class TimeSlider: UIControl { } return nil } - } // MARK: - Time Slider Thumb Layer @@ -2371,12 +2348,11 @@ private class TimeSliderTickMarkLayer: CALayer { // Render tick marks tickMarksOriginX.forEach { (tickX) in ctx.beginPath() - ctx.move(to: CGPoint(x: CGFloat(tickX), y:bounds.midY - (slider.trackHeight / 2.0))) + ctx.move(to: CGPoint(x: CGFloat(tickX), y: bounds.midY - (slider.trackHeight / 2.0))) ctx.addLine(to: CGPoint(x: CGFloat(tickX), y: bounds.midY + bounds.height / 2.0)) ctx.strokePath() } - } - else { + } else { // Loop through all tick marks // and render them. for i in 0.. (duration: Int, component: Calendar.Component)? { - if years(from: date) > 0 { return (years(from: date), .year) } - if months(from: date) > 0 { return (months(from: date), .month) } + if years(from: date) > 0 { return (years(from: date), .year) } + if months(from: date) > 0 { return (months(from: date), .month) } if seconds(from: date) > 0 { return (seconds(from: date), .second) } if nanoseconds(from: date) > 0 { return (nanoseconds(from: date), .nanosecond) } return nil @@ -2550,23 +2524,16 @@ fileprivate extension Date { // MARK: - Color Extension extension UIColor { - class var oceanBlue: UIColor { - get { - return UIColor(red: 0.0, green: 0.475, blue: 0.757, alpha: 1) - } + return UIColor(red: 0.0, green: 0.475, blue: 0.757, alpha: 1) } class var customBlue: UIColor { - get { - return UIColor(red: 0.0, green: 0.45, blue: 0.94, alpha: 1.0) - } + return UIColor(red: 0.0, green: 0.45, blue: 0.94, alpha: 1.0) } class var lightSkyBlue: UIColor { - get { - return UIColor(red: 0.529, green: 0.807, blue: 0.980, alpha: 1.0) - } + return UIColor(red: 0.529, green: 0.807, blue: 0.980, alpha: 1.0) } } @@ -2619,6 +2586,7 @@ extension AGSTimeValue: Comparable { } // Converts time value to the calender component values. + // swiftlint:disable cyclomatic_complexity public func toCalenderComponents() -> (duration: Double, component: Calendar.Component)? { switch unit { case .unknown: @@ -2645,7 +2613,8 @@ extension AGSTimeValue: Comparable { return (duration, Calendar.Component.year) } } - + // swiftlint:enable cyclomatic_complexity + // Returns time value generated from calender component and duration class func fromCalenderComponents(duration: Double, component: Calendar.Component) -> AGSTimeValue? { switch component { @@ -2697,21 +2666,17 @@ extension AGSTimeValue: Comparable { // MARK: - GeoView Extension -extension AGSGeoView { - - fileprivate var operationalLayers: [AGSLayer]? { - get { - if let mapView = self as? AGSMapView { - if let layers = mapView.map?.operationalLayers as AnyObject as? [AGSLayer] { - return layers - } +fileprivate extension AGSGeoView { + var operationalLayers: [AGSLayer]? { + if let mapView = self as? AGSMapView { + if let layers = mapView.map?.operationalLayers as AnyObject as? [AGSLayer] { + return layers } - else if let sceneView = self as? AGSSceneView { - if let layers = sceneView.scene?.operationalLayers as AnyObject as? [AGSLayer] { - return layers - } + } else if let sceneView = self as? AGSSceneView { + if let layers = sceneView.scene?.operationalLayers as AnyObject as? [AGSLayer] { + return layers } - return nil } + return nil } } diff --git a/Toolkit/ArcGISToolkit/UnitsViewController.swift b/Toolkit/ArcGISToolkit/UnitsViewController.swift index 18ec687f..8187699c 100644 --- a/Toolkit/ArcGISToolkit/UnitsViewController.swift +++ b/Toolkit/ArcGISToolkit/UnitsViewController.swift @@ -16,7 +16,7 @@ import class ArcGIS.AGSUnit /// The protocol you implement to respond as the user interacts with the units /// view controller. -public protocol UnitsViewControllerDelegate: class { +public protocol UnitsViewControllerDelegate: AnyObject { /// Tells the delegate that the user has cancelled selecting a unit. /// /// - Parameter unitsViewController: The current units view controller. @@ -59,14 +59,15 @@ public class UnitsViewController: TableViewController { } /// Called in response to the Cancel button being tapped. - @objc private func cancel() { + @objc + private func cancel() { // If the search controller is still active, the delegate will not be // able to dismiss this if they showed this modally. // (or wrapped it in a navigation controller and showed that modally) // Only do this if not being presented from a nav controller // as in that case, it causes problems when the delegate that pushed this VC // tries to pop it off the stack. - if presentingViewController != nil{ + if presentingViewController != nil { navigationItem.searchController?.isActive = false } delegate?.unitsViewControllerDidCancel(self) @@ -88,12 +89,12 @@ public class UnitsViewController: TableViewController { tableView.reloadRows(at: indexPaths, with: .automatic) } - override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) sharedInitialization() } - required public init?(coder aDecoder: NSCoder) { + public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) sharedInitialization() } @@ -141,7 +142,7 @@ public class UnitsViewController: TableViewController { // Only do this if not being presented from a nav controller // as in that case, it causes problems when the delegate that pushed this VC // tries to pop it off the stack. - if presentingViewController != nil{ + if presentingViewController != nil { navigationItem.searchController?.isActive = false } delegate?.unitsViewControllerDidSelectUnit(self)