diff --git a/.swiftlint.yml b/.swiftlint.yml index 6947d0156..3774264ea 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -28,7 +28,6 @@ disabled_rules: # Some rules are only opt-in opt_in_rules: - - anyobject_protocol - array_init - attributes - closure_end_indentation diff --git a/AuthenticationExample/AuthenticationExample.xcodeproj/project.pbxproj b/AuthenticationExample/AuthenticationExample.xcodeproj/project.pbxproj index 2235b68cc..d77abd9c8 100644 --- a/AuthenticationExample/AuthenticationExample.xcodeproj/project.pbxproj +++ b/AuthenticationExample/AuthenticationExample.xcodeproj/project.pbxproj @@ -325,7 +325,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 200.1.0; + MARKETING_VERSION = 200.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.esri.Authentication; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -358,7 +358,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 200.1.0; + MARKETING_VERSION = 200.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.esri.Authentication; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/AuthenticationExample/AuthenticationExample/SignInView.swift b/AuthenticationExample/AuthenticationExample/SignInView.swift index b2abd75e1..a35366526 100644 --- a/AuthenticationExample/AuthenticationExample/SignInView.swift +++ b/AuthenticationExample/AuthenticationExample/SignInView.swift @@ -132,7 +132,7 @@ private extension Error { /// authentication challenge. var isChallengeCancellationError: Bool { switch self { - case is CancellationError: + case is ArcGISChallengeCancellationError: return true case let error as NSError: return error.domain == NSURLErrorDomain && error.code == -999 diff --git a/Documentation/Authenticator/README.md b/Documentation/Authenticator/README.md deleted file mode 100644 index 2368cbf81..000000000 --- a/Documentation/Authenticator/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Authenticator - -The `Authenticator` is a configurable object that handles authentication challenges. It will display a user interface when network and ArcGIS authentication challenges occur. - -![image](https://user-images.githubusercontent.com/3998072/203615041-c887d5e3-bb64-469a-a76b-126059329e92.png) - -## Features - -The `Authenticator` has a view modifier that will display a prompt when the `Authenticator` is asked to handle an authentication challenge. This will handle many different types of authentication, for example: - - ArcGIS authentication (token and OAuth) - - Integrated Windows Authentication (IWA) - - Client Certificate (PKI) - -The `Authenticator` can be configured to support securely persisting credentials to the keychain. - -## Key properties - -`Authenticator` has the following view modifier: - -```swift - /// Presents user experiences for collecting network authentication credentials from the user. - /// - Parameter authenticator: The authenticator for which credentials will be prompted. - @ViewBuilder func authenticator(_ authenticator: Authenticator) -> some View -``` - -To securely store credentials in the keychain, use the following extension method of `AuthenticationManager`: - -```swift - /// Sets up new credential stores that will be persisted to the keychain. - /// - Remark: The credentials will be stored in the default access group of the keychain. - /// You can find more information about what the default group would be here: - /// https://developer.apple.com/documentation/security/keychain_services/keychain_items/sharing_access_to_keychain_items_among_a_collection_of_apps - /// - Parameters: - /// - access: When the credentials stored in the keychain can be accessed. - /// - synchronizesWithiCloud: A Boolean value indicating whether the credentials are synchronized with iCloud. - public func setupPersistentCredentialStorage( - access: ArcGIS.KeychainAccess, - synchronizesWithiCloud: Bool = false - ) async throws -``` - -During sign-out, use the following extension methods of `AuthenticationManager`: - -```swift - /// Revokes tokens of OAuth user credentials. - func revokeOAuthTokens() async - - /// Clears all ArcGIS and network credentials from the respective stores. - /// Note: This sets up new `URLSessions` so that removed network credentials are respected - /// right away. - func clearCredentialStores() async -``` - -## Behavior: - -The Authenticator view modifier will display an alert prompting the user for credentials. If credentials were persisted to the keychain, the Authenticator will use those instead of requiring the user to reenter credentials. - -## Usage - -### Basic usage for displaying the `Authenticator`. - -This would typically go in your application's `App` struct. - -```swift -init() { - // Create an authenticator. - authenticator = Authenticator( - // If you want to use OAuth, uncomment this code: - //oAuthConfigurations: [.arcgisDotCom] - ) - // Sets authenticator as ArcGIS and Network challenge handlers to handle authentication - // challenges. - ArcGISEnvironment.authenticationManager.handleChallenges(using: authenticator) -} - -var body: some SwiftUI.Scene { - WindowGroup { - HomeView() - .authenticator(authenticator) - .task { - // Here we setup credential stores to be persistent, which means that it will - // synchronize with the keychain for storing credentials. - // It also means that a user can sign in without having to be prompted for - // credentials. Once credentials are cleared from the stores ("sign-out"), - // then the user will need to be prompted once again. - try? await ArcGISEnvironment.authenticationManager.setupPersistentCredentialStorage(access: .whenUnlockedThisDeviceOnly) - } - } -} -``` - -To see the `Authenticator` in action, check out the [Authentication Examples](../../AuthenticationExample) and refer to [AuthenticationApp.swift](../../AuthenticationExample/AuthenticationExample/AuthenticationApp.swift) in the project. diff --git a/Documentation/BasemapGallery/README.md b/Documentation/BasemapGallery/README.md deleted file mode 100644 index f0f067709..000000000 --- a/Documentation/BasemapGallery/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# BasemapGallery - -The `BasemapGallery` displays a collection of basemaps from ArcGIS Online, a user-defined portal, or an array of `BasemapGalleryItem`s. When a new basemap is selected from the `BasemapGallery` and the optional `BasemapGalleryViewModel.geoModel` property is set, the basemap of the `geoModel` is replaced with the basemap in the gallery. - -|iPhone|iPad| -|:--:|:--:| -|![image](https://user-images.githubusercontent.com/3998072/205385086-cb9bc0a0-3c46-484d-aefa-8878c7112a3e.png)|![image](https://user-images.githubusercontent.com/3998072/205384854-79f25efe-25f4-4330-a487-b64b528a9daf.png)| - -> **NOTE**: BasemapGallery uses metered ArcGIS basemaps by default, so you will need to configure an API key. See [Security and authentication documentation](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/#api-keys) for more information. - -## Features - -BasemapGallery: - -- Can be configured to use a list, grid, or automatic layout. When using an automatic layout, list or grid presentation is chosen based on the horizontal size class of the display. -- Displays basemaps from a portal or a custom collection. If neither a custom portal or array of basemaps is provided, the list of basemaps will be loaded from ArcGIS Online. -- Displays a representation of the map or scene's current basemap if that basemap exists in the gallery. -- Displays a name and thumbnail for each basemap. -- Can be configured to automatically change the basemap of a geo model based on user selection. - -## Key properties - -`BasemapGallery` has the following initializers: - -```swift - /// Creates a `BasemapGallery` with the given geo model and array of basemap gallery items. - /// - Remark: If `items` is empty, ArcGIS Online's developer basemaps will - /// be loaded and added to `items`. - /// - Parameters: - /// - items: A list of pre-defined base maps to display. - /// - geoModel: A geo model. - public init(items: [BasemapGalleryItem] = [], geoModel: GeoModel? = nil) -``` - -```swift - /// Creates a `BasemapGallery` with the given geo model and portal. - /// The portal will be used to retrieve basemaps. - /// - Parameters: - /// - portal: The portal to use to load basemaps. - /// - geoModel: A geo model. - public init(portal: Portal, geoModel: GeoModel? = nil) -``` - -`BasemapGallery` has the following instance method: - -- `style(_ newStyle: Style)` - The `Style` of the `BasemapGallery`, used to specify how the list of basemaps is displayed (list, grid, or automatic depending on display width). - -`BasemapGallery` has the following helper class and initializer: - -```swift -/// The `BasemapGalleryItem` encompasses an element in a `BasemapGallery`. -public class BasemapGalleryItem : ObservableObject - -/// Creates a `BasemapGalleryItem`. -/// - Parameters: -/// - basemap: The `Basemap` represented by the item. -/// - name: The item name. If `nil`, `Basemap.name` is used, if available. -/// - description: The item description. If `nil`, `Basemap.Item.description` -/// is used, if available. -/// - thumbnail: The thumbnail used to represent the item. If `nil`, -/// `Basemap.Item.thumbnail` is used, if available. -public init( - basemap: Basemap, - name: String? = nil, - description: String? = nil, - thumbnail: UIImage? = nil -) - -``` - -## Behavior: - -Selecting a basemap with a spatial reference that does not match that of the geo model will display an error. It will also display an error if a provided base map cannot be loaded. If a `GeoModel` is provided to the `BasemapGallery`, selecting an item in the gallery will set that basemap on the geo model. - -## Usage - -### Basic usage for displaying a `BasemapGallery`. - -```swift -@State private var map = Map(basemapStyle: .arcGISImagery) - -var body: some View { - MapView(map: map) - .overlay(alignment: .topTrailing) { - BasemapGallery(geoModel: map) - .style(.automatic()) - .padding() - } -} -``` - -To see it in action, try out the [Examples](../../Examples) and refer to [BasemapGalleryExampleView.swift](../../Examples/Examples/BasemapGalleryExampleView.swift) in the project. diff --git a/Documentation/Bookmarks/README.md b/Documentation/Bookmarks/README.md deleted file mode 100644 index 7cb2915cd..000000000 --- a/Documentation/Bookmarks/README.md +++ /dev/null @@ -1,114 +0,0 @@ -# Bookmarks - -The `Bookmarks` component will display a list of bookmarks and allow the user to select a bookmark and perform some action. You can create the component with either an array of `Bookmarks`, or with a `Map` or `Scene` containing the bookmarks to display. - -`Bookmarks` can be configured to handle automated bookmark selection (zooming the map/scene to the bookmark’s viewpoint) by passing in a `Viewpoint` binding or the client can handle bookmark selection changes manually using the `.onSelectionChanged(perform:)` modifier. - -|iPhone|iPad| -|:--:|:--:| -|![image](https://user-images.githubusercontent.com/3998072/202765630-894bee44-a0c2-4435-86f4-c80c4cc4a0b9.png)|![image](https://user-images.githubusercontent.com/3998072/202765729-91c52555-4677-4c2b-b62b-215e6c3790a6.png)| - -## Features - -Bookmarks: - -- Can be configured to display bookmarks from a map or scene, or from an array of user-defined bookmarks. -- Can be configured to automatically zoom the map or scene to a bookmark selection. -- Can be configured to perform a user-defined action when a bookmark is selected. -- Will automatically hide the `Bookmark` view when a bookmark is selected. - -## Key properties - -`Bookmarks` has the following initializers: - -```swift - /// Creates a `Bookmarks` component. - /// - Parameters: - /// - isPresented: Determines if the bookmarks list is presented. - /// - bookmarks: An array of bookmarks. Use this when displaying bookmarks defined at runtime. - /// - viewpoint: A viewpoint binding that will be updated when a bookmark is selected. - /// Alternately, you can use the `onSelectionChanged(perform:)` modifier to handle - /// bookmark selection. - public init(isPresented: Binding, bookmarks: [Bookmark], viewpoint: Binding? = nil) -``` - -```swift - /// Creates a `Bookmarks` component. - /// - Parameters: - /// - isPresented: Determines if the bookmarks list is presented. - /// - geoModel: A `GeoModel` authored with pre-existing bookmarks. - /// - viewpoint: A viewpoint binding that will be updated when a bookmark is selected. - /// Alternately, you can use the `onSelectionChanged(perform:)` modifier to handle - /// bookmark selection. - public init(isPresented: Binding, mapOrScene: GeoModel, viewpoint: Binding? = nil) -``` - -`Bookmarks` has the following instance method: - -```swift - /// Sets an action to perform when the bookmark selection changes. - /// - Parameter action: The action to perform when the bookmark selection has changed. - public func onSelectionChanged(perform action: @escaping (Bookmark) -> Void) -> Bookmarks -``` - -## Behavior: - -If a `Viewpoint` binding is provided to the `Bookmarks` view, selecting a bookmark will set that viewpoint binding to the viewpoint of the bookmark. Selecting a bookmark will dismiss the `Bookmarks` view. If a `GeoModel` is provided, that geo model's bookmarks will be displayed to the user. - -## Usage - -### Basic usage for displaying a `Bookmarks` view. -The view is displayed in a `popover` in response to a toolbar button tap. - -```swift -/// The `Map` with predefined bookmarks. -@State private var map = Map(url: URL(string: "https://www.arcgis.com/home/item.html?id=16f1b8ba37b44dc3884afc8d5f454dd2")!)! - -/// The last selected bookmark. -@State var selectedBookmark: Bookmark? - -/// Indicates if the `Bookmarks` component is shown or not. -/// - Remark: This allows a developer to control when the `Bookmarks` component is -/// shown/hidden, whether that be in a group of options or a standalone button. -@State var showingBookmarks = false - -/// Allows for communication between the `Bookmarks` component and a `MapView` or -/// `SceneView`. -@State var viewpoint: Viewpoint? - -var body: some View { - MapViewReader { mapViewProxy in - MapView(map: map, viewpoint: viewpoint) - .onViewpointChanged(kind: .centerAndScale) { - viewpoint = $0 - } - .task(id: selectedBookmark) { - if let selectedBookmark, let viewpoint = selectedBookmark.viewpoint { - await mapViewProxy.setViewpoint(viewpoint) - } - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - showingBookmarks.toggle() - } label: { - Label( - "Show Bookmarks", - systemImage: "bookmark" - ) - } - .popover(isPresented: $showingBookmarks) { - // Display the `Bookmarks` component with the list of bookmarks in a map. - Bookmarks( - isPresented: $showingBookmarks, - geoModel: map - ) - .onSelectionChanged { selectedBookmark = $0 } - } - } - } - } -} -``` - -To see it in action, try out the [Examples](../../Examples) and refer to [BookmarksExampleView.swift](../../Examples/Examples/BookmarksExampleView.swift) in the project. diff --git a/Documentation/Compass/README.md b/Documentation/Compass/README.md deleted file mode 100644 index 27dd0bb76..000000000 --- a/Documentation/Compass/README.md +++ /dev/null @@ -1,96 +0,0 @@ -# Compass - -The Compass (alias North arrow) displays the current viewpoint rotation of a MapView or SceneView. The Compass supports resetting the rotation to zero/north. - -The ArcGIS Maps SDK for Swift currently supports rotating MapViews and SceneViews interactively with a 2-finger gesture and while the map/scene will snap to north when rotating using gestures, the Compass provides an easier way. The Compass Toolkit component will appear when the map is rotated and, when tapped, re-orientates the map back to north and hides the compass icon (note that the MapView auto-snaps back to north when it's within a threshold of north, and in that case the compass also auto hides). - -![image](https://user-images.githubusercontent.com/3998072/202810369-a0b82778-77d4-404e-bebf-1a84841fbb1b.png) - -## Features - -Compass: - -- Automatically hides when the rotation is zero. -- Can be configured to be always visible. -- Will reset the map/scene rotation to North when tapped on. - -## Key properties - -`Compass` has the following initializers: - -```swift - /// Creates a compass with a rotation (0° indicates a direction toward true North, 90° indicates - /// a direction toward true West, etc.). - /// - Parameters: - /// - rotation: The rotation whose value determines the heading of the compass. - /// - mapViewProxy: The proxy to provide access to map view operations. - public init(rotation: Double?, mapViewProxy: MapViewProxy) - - /// Creates a compass with a rotation (0° indicates a direction toward true North, 90° indicates - /// a direction toward true West, etc.). - /// - Parameters: - /// - rotation: The rotation whose value determines the heading of the compass. - /// - action: The action to perform when the compass is tapped. - public init(rotation: Double?, action: @escaping () -> Void) -``` - -`Compass` has the following modifiers: - -- `func compassSize(size: CGFloat)` - The size of the `Compass`, specifying both the width and height of the compass. -- `func autoHideDisabled(_:)` - Specifies whether the ``Compass`` should automatically hide when the heading is 0. - -## Behavior: - -Whenever the map is not orientated North (non-zero bearing) the compass appears. When reset to north, it disappears. The `autoHideDisabled` view modifier allows you to disable the auto-hide feature so that it is always displayed. - -When the compass is tapped, the map orients back to north (zero bearing). - -## Usage - -### Basic usage for displaying a `Compass`. - -```swift -@State private var map = Map(basemapStyle: .arcGISImagery) - -@State private var viewpoint: Viewpoint? - -var body: some View { - MapViewReader { proxy in - MapView(map: map, viewpoint: viewpoint) - .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } - .overlay(alignment: .topTrailing) { - Compass(rotation: viewpoint?.rotation, mapViewProxy: proxy) - .padding() - } - } -} -``` - -To add a `Compass` to a SceneView, use the initializer which takes an `action` argument to perform a custom action when the compass is tapped on. - -```swift -@State private var scene = Scene(basemapStyle: .arcGISImagery) - -@State private var viewpoint: Viewpoint? - -var body: some View { - SceneViewReader { proxy in - SceneView(scene: scene) - .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } - .overlay(alignment: .topTrailing) { - Compass(rotation: viewpoint?.rotation) { - if let viewpoint { - Task { - await proxy.setViewpoint(viewpoint.withRotation(.zero)) - } - } - } - .padding() - } - } -} -``` - - - -To see it in action, try out the [Examples](../../Examples/Examples) and refer to [CompassExampleView.swift](../../Examples/Examples/CompassExampleView.swift) in the project. diff --git a/Documentation/FloatingPanel/README.md b/Documentation/FloatingPanel/README.md deleted file mode 100644 index 3ac0d570c..000000000 --- a/Documentation/FloatingPanel/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# FloatingPanel - -A floating panel is a view that overlays a view and supplies view-related content. For a map view, for instance, it could display a legend, bookmarks, search results, etc.. Apple Maps, Google Maps, Windows 10, and Collector have floating panel implementations, sometimes referred to as a "bottom sheet". - -Floating panels are non-modal and primarily simple containers that clients will fill with their own content. They can be transient where they only display information for a short period of time, like identify results. Or they can be persistent, where the information is always displayed, such as a dedicated search panel. - -The floating panel allows for interaction with background content, unlike native sheets or popovers. - -The following images are of a simple list of numbers in a floating panel. - -|iPhone|iPad| -|:--:|:--:| -|![image](https://user-images.githubusercontent.com/3998072/202795901-b86d6d26-3572-4c88-8f6e-84473ce57002.png)|![image](https://user-images.githubusercontent.com/3998072/202796009-92e3b5c3-d88b-4124-8d9f-bad6df445f02.png)| - -## Features: - -FloatingPanel: - -- Can display any custom content. -- Can be resized by dragging the panel's handle. -- Has three predefined height settings, called "detents", that the panel will snap to when the user drags and releases the handle. -- Can be configured with a custom detent, specifying either a fraction of the maximum height or a fixed value. -- Is displayed using the `.floatingPanel` view modifier. - -## Key properties - -`FloatingPanel` exposes the following view modifier: - -```swift - /// - Parameters: - /// - backgroundColor: The background color of the floating panel. - /// - selectedDetent: A binding to the currently selected detent. - /// - horizontalAlignment: The horizontal alignment of the floating panel. - /// - isPresented: A binding to a Boolean value that determines whether the view is presented. - /// - maxWidth: The maximum width of the floating panel. - /// - content: A closure that returns the content of the floating panel. - /// - Returns: A dynamic view with a presentation style similar to that of a sheet in compact - /// environments and a popover otherwise. - func floatingPanel( - backgroundColor: Color = Color(uiColor: .systemBackground), - selectedDetent: Binding = .constant(.half), - horizontalAlignment: HorizontalAlignment = .trailing, - isPresented: Binding = .constant(true), - maxWidth: CGFloat = 400, - _ content: @escaping () -> Content - ) -> some View where Content: View -``` - -### Behavior: - -Content in a floating panel can be resized using a “handle” on the bottom (for regular-width environments) or on the top (compact-width environments). - -The height of the floating panel is determined by a selected “detent”. There are pre-defined detents for full screen height, half screen height, and a “summary” height, along with the ability to set custom detent heights. Dragging and releasing the handle will snap the floating panel height to the nearest detent. - -The floating panel is displayed via a view modifier that allows you to set the content of the floating panel along with a number of other properties, including: - -- `backgroundColor`: The background color of the floating panel. -- `selectedDetent`: A binding to the currently selected detent. -- `horizontalAlignment`: The horizontal alignment of the floating panel. -- `maxWidth`: The maximum width of the floating panel. - -### Usage - -```swift -MapView( - map: map -) -.floatingPanel() { - List(1..<21) { Text("\($0)") } - .listStyle(.plain) -} -``` - -To see it in action, try out the [Examples](../../Examples) and refer to [FloatingPanelExampleView.swift](../../Examples/Examples/FloatingPanelExampleView.swift) in the project. diff --git a/Documentation/FloorFilter/README.md b/Documentation/FloorFilter/README.md deleted file mode 100644 index 8f12c8983..000000000 --- a/Documentation/FloorFilter/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# FloorFilter - -The `FloorFilter` component simplifies visualization of GIS data for a specific floor of a building in your application. It allows you to filter the floor plan data displayed in your geo view to view a site, a building in the site, or a floor in the building. - -The ArcGIS Maps SDK currently supports filtering a 2D floor aware map based on the sites, buildings, or levels in the map. - -|iPhone|iPad| -|:--:|:--:| -|![image](https://user-images.githubusercontent.com/3998072/202811733-dcd640e9-3b27-43a8-8bec-fd9aeb6798c7.png)|![image](https://user-images.githubusercontent.com/3998072/202811772-bf6009e7-82ec-459f-86ae-6651f519b2ef.png)| - -## Features - -- Automatically hides the floor browsing view when the associated map or scene is not floor-aware. -- Selects the facility in view automatically (can be configured through the `AutomaticSelectionMode`). -- Shows the selected facility's levels in proper vertical order. -- Filters the map/scene content to show the selected level. -- Allows browsing the full floor-aware hierarchy of sites, facilities, and levels. -- Shows the ground floor of all facilities when there is no active selection. -- Updates the visibility of floor levels across all facilities (e.g. if you are looking at floor 3 in building A, floor 3 will be shown in neighboring buildings). -- Adjusts layout and presentation to work well regardless of positioning - left/right and top/bottom. -- Keeps the selected facility visible in the list while the selection is changing in response to map navigation. - -## Key properties - -`FloorFilter` has the following initializer: - -```swift - /// Creates a `FloorFilter`. - /// - Parameters: - /// - floorManager: The floor manager used by the `FloorFilter`. - /// - alignment: Determines the display configuration of Floor Filter elements. - /// - automaticSelectionMode: The selection behavior of the floor filter. - /// - viewpoint: Viewpoint updated when the selected site or facility changes. - /// - isNavigating: A Boolean value indicating whether the map is currently being navigated. - /// - selection: The selected site, facility, or level. - public init( - floorManager: FloorManager, - alignment: Alignment, - automaticSelectionMode: FloorFilterAutomaticSelectionMode = .always, - viewpoint: Binding = .constant(nil), - isNavigating: Binding, - selection: Binding? = nil - ) -``` - -`FloorFilter` has the associated enum: - -```swift -/// Defines automatic selection behavior. -public enum FloorFilterAutomaticSelectionMode { - /// Always update selection based on the current viewpoint; clear the selection when the user - /// navigates away. - case always - /// Only update the selection when there is a new site or facility in the current viewpoint; don't clear - /// selection when the user navigates away. - case alwaysNotClearing - /// Never update selection based on the map or scene view's current viewpoint. - case never -} - -/// A selected site, facility, or level. -public enum FloorFilterSelection: Hashable { - /// A selected site. - case site(FloorSite) - /// A selected facility. - case facility(FloorFacility) - /// A selected level. - case level(FloorLevel) -} -``` - -`FloorFilter` has the following modifier: - -- `func levelSelectorWidth(_ width: CGFloat)` - The width of the level selector. - -## Behavior: - -|Site Button| -|:--:| -|![image](https://user-images.githubusercontent.com/3998072/203417956-5161103d-5d29-42fa-8564-de254159efe2.png)| - -When the Site button is tapped, a prompt opens so the user can select a site and then a facility. After selecting a site and facility, a list of levels is displayed. The list of sites and facilities can be dynamically filtered using the search bar. - -## Usage - -### Basic usage for displaying a `FloorFilter`. - -```swift -/// Make a map from a portal item. -static func makeMap() -> Map { - Map(item: PortalItem( - portal: .arcGISOnline(connection: .anonymous), - id: Item.ID("b4b599a43a474d33946cf0df526426f5")! - )) -} - -/// Determines the appropriate time to initialize the `FloorFilter`. -@State private var isMapLoaded = false - -/// A Boolean value indicating whether the map is currently being navigated. -@State private var isNavigating = false - -/// The initial viewpoint of the map. -@State private var viewpoint: Viewpoint? = Viewpoint( - center: Point( - x: -117.19496, - y: 34.05713, - spatialReference: .wgs84 - ), - scale: 100_000 -) - -@StateObject private var map = makeMap() - -var body: some View { - MapView( - map: map, - viewpoint: viewpoint - ) - .onNavigatingChanged { - isNavigating = $0 - } - .onViewpointChanged(kind: .centerAndScale) { - viewpoint = $0 - } - /// Preserve the current viewpoint when a keyboard is presented in landscape. - .ignoresSafeArea(.keyboard, edges: .bottom) - .overlay(alignment: .bottomLeading) { - if isMapLoaded, - let floorManager = map.floorManager { - FloorFilter( - floorManager: floorManager, - alignment: .bottomLeading, - viewpoint: $viewpoint, - isNavigating: $isNavigating - ) - .frame( - maxWidth: 400, - maxHeight: 400 - ) - .padding(36) - } - } - .task { - do { - try await map.load() - isMapLoaded = true - } catch { } - } -} -``` - -To see it in action, try out the [Examples](../../Examples) and refer to [FloorFilterExampleView.swift](../../Examples/Examples/FloorFilterExampleView.swift) in the project. diff --git a/Documentation/OverviewMap/OverviewMap_MapView.png b/Documentation/OverviewMap/OverviewMap_MapView.png deleted file mode 100644 index 496e5d7f2..000000000 Binary files a/Documentation/OverviewMap/OverviewMap_MapView.png and /dev/null differ diff --git a/Documentation/OverviewMap/OverviewMap_SceneView.png b/Documentation/OverviewMap/OverviewMap_SceneView.png deleted file mode 100644 index fb3b44e41..000000000 Binary files a/Documentation/OverviewMap/OverviewMap_SceneView.png and /dev/null differ diff --git a/Documentation/OverviewMap/README.md b/Documentation/OverviewMap/README.md deleted file mode 100644 index d2707fda8..000000000 --- a/Documentation/OverviewMap/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# OverviewMap - -`OverviewMap` is a small, secondary `MapView` (sometimes called an inset map), that can be overlaid on an existing `GeoView` (`MapView` or `SceneView`). `OverviewMap` shows a representation of the current `visibleArea` (for a `MapView`) or `viewpoint` (for a `SceneView`). - -|MapView|SceneView -|:--:|:--:| -|![OverviewMap - MapView](./OverviewMap_MapView.png)|![OverviewMap - SceneView](./OverviewMap_SceneView.png)| - - -> **NOTE**: OverviewMap uses metered ArcGIS basemaps by default, so you will need to configure an API key. See [Security and authentication documentation](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/#api-keys) for more information. - -## Features - -OverviewMap: - -- Displays a representation of the current `VisibleArea`/`Viewpoint` for a connected `GeoView`. -- Supports a configurable scaling factor for setting the overview map's zoom level relative to the connected view. -- Supports a configurable symbol for visualizing the current `VisibleArea`/`Viewpoint` representation (a `FillSymbol` for a connected `MapView`; a `MarkerSymbol` for a connected `SceneView`). -- Supports using a custom map in the overview map display. - -## Key properties - -`OverviewMap` has the following instance methods: - -- `scaleFactor(_ scaleFactor: Double)` - The scale of the `OverviewMap` relative to the scale of the connected `GeoView`. The `OverviewMap` will display at the a scale equal to: `viewpoint.targetScale` x `scaleFactor`. The default is `25`. -- `symbol(_ symbol: Symbol)` - The symbol used to visualize the current `VisibleArea`/`Viewpoint`. This is a red rectangle by default for a `MapView`; for a `SceneView`, this is a red cross. - -## Behavior: - -For an `OverviewMap` on a `MapView`, the `MapView`'s `visibleArea` property will be represented in the `OverviewMap` as a polygon, which will rotate as the `MapView` rotates. - -For an `OverviewMap` on a `SceneView`, the center point of the `SceneView`'s `currentViewpoint` property will be represented in the `OverviewMap` by a point. - -## Usage - -### Basic usage for overlaying a `MapView` - -Note that for a `MapView`, you need to provide the `OverviewMap` both a viewpoint and a polygon representing the visible area. - -```swift -@StateObject -var map = Map(basemapStyle: .arcGISImagery) - -@State -private var viewpoint: Viewpoint? - -@State -private var visibleArea: ArcGIS.Polygon? - -var body: some View { - MapView(map: map) - .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } - .onVisibleAreaChanged { visibleArea = $0 } - .overlay( - OverviewMap.forMapView( - with: viewpoint, - visibleArea: visibleArea - ) - .frame(width: 200, height: 132) - .padding(), - alignment: .topTrailing - ) -} -``` - -### Basic usage for overlaying a `SceneView` - -Note that for a `SceneView`, you need to provide the `OverviewMap` only a viewpoint. - -```swift -@StateObject var scene = Scene(basemapStyle: .arcGISImageryLabels) - -@State private var viewpoint: Viewpoint? - -var body: some View { - SceneView(scene: scene) - .onViewpointChanged(type: .centerAndScale) { viewpoint = $0 } - .overlay( - OverviewMap.forSceneView(with: viewpoint) - .frame(width: 200, height: 132) - .padding(), - alignment: .topTrailing - ) -} -``` - -To use a custom map in the `OverviewMap`, use the `map` argument in either `OverviewMap.forMapView` or `OverviewMap.forSceneView`. - -To see the `OverviewMap` in action, and for examples of `OverviewMap` customization, check out the [Examples](../../Examples) and refer to [OverviewMapExampleView.swift](../../Examples/Examples/OverviewMapExampleView.swift) in the project. diff --git a/Documentation/Popup/README.md b/Documentation/Popup/README.md deleted file mode 100644 index 0f6229082..000000000 --- a/Documentation/Popup/README.md +++ /dev/null @@ -1,126 +0,0 @@ -# Popup - -The `PopupView` component will display a popup for an individual feature. This includes showing the feature's title, attributes, custom description, media, and attachments. The new online Map Viewer allows users to create a popup definition by assembling a list of “popup elements”. `PopupView` will support the display of popup elements created by the Map Viewer, including: - -- Text -- Fields -- Attachments -- Media (Images and Charts) - -Thanks to the backwards compatibility support in the API, it will also work with the legacy popup definitions created by the classic Map Viewer. It does not support editing. - -|iPhone|iPad| -|:--:|:--:| -|![image](https://user-images.githubusercontent.com/3998072/203422507-66b6c6dc-a6c3-4040-b996-9c0da8d4e580.png)|![image](https://user-images.githubusercontent.com/3998072/203422665-c4759c1f-5863-4251-94df-ed7a06ac7a8f.png)| - -> **NOTE**: Displaying charts in a popup requires running on a device with iOS 16.0 or greater. Displaying charts in MacCatalyst requires building your application with Xcode 14.1 or greater and running on a Mac with macOS 13.0 (Ventura) or greater. Also, Attachment previews are not available when running on MacCatalyst (regardless of Xcode version). - -## Features - -- Display a popup for a feature based on the popup definition defined in a web map. -- Supports image refresh intervals on image popup media, refreshing the image at a given interval defined in the popup element. -- Supports elements containing Arcade expression and automatically evaluates expressions. -- Displays media (images and charts) full-screen. -- Supports hyperlinks in text, media, and fields elements. -- Fully supports dark mode, as do all Toolkit components. - -## Key properties - -`PopupView` has the following initializer: - -```swift - /// Creates a `PopupView` with the given popup. - /// - Parameters - /// popup: The popup to display. - /// - isPresented: A Boolean value indicating if the view is presented. - public init(popup: Popup, isPresented: Binding? = nil) -``` - -`PopupView` has the following instance method: - -```swift - /// Specifies whether a "close" button should be shown to the right of the popup title. If the "close" - /// button is shown, you should pass in the `isPresented` argument to the `PopupView` - /// initializer, so that the the "close" button can close the view. - /// Defaults to `false`. - /// - Parameter newShowCloseButton: The new value. - /// - Returns: A new `PopupView`. - public func showCloseButton(_ newShowCloseButton: Bool) -> PopupView.PopupView -``` - -## Behavior: - -The popup view can display an optional "close" button, allowing the user to dismiss the view. The popup view can be embedded in any type of container view including, as demonstrated in the example, the Toolkit's `FloatingPanel`. - -## Usage - -### Basic usage for displaying a `PopupView`. - -```swift -static func makeMap() -> Map { - let portalItem = PortalItem( - portal: .arcGISOnline(connection: .anonymous), - id: Item.ID(rawValue: "67c72e385e6e46bc813e0b378696aba8")! - ) - return Map(item: portalItem) -} - -/// The map displayed in the map view. -@StateObject private var map = makeMap() - -/// The point on the screen the user tapped on to identify a feature. -@State private var identifyScreenPoint: CGPoint? - -/// The popup to be shown as the result of the layer identify operation. -@State private var popup: Popup? - -/// A Boolean value specifying whether the popup view should be shown or not. -@State private var showPopup = false - -/// The detent value specifying the initial `FloatingPanelDetent`. Defaults to "full". -@State private var floatingPanelDetent: FloatingPanelDetent = .full - -var body: some View { - MapViewReader { proxy in - VStack { - MapView(map: map) - .onSingleTapGesture { screenPoint, _ in - identifyScreenPoint = screenPoint - } - .task(id: identifyScreenPoint) { - guard let identifyScreenPoint = identifyScreenPoint, - let identifyResult = await Result(awaiting: { - try await proxy.identifyLayers( - screenPoint: identifyScreenPoint, - tolerance: 10, - returnPopupsOnly: true - ) - }) - .cancellationToNil() - else { - return - } - - self.identifyScreenPoint = nil - self.popup = try? identifyResult.get().first?.popups.first - self.showPopup = self.popup != nil - } - .floatingPanel( - selectedDetent: $floatingPanelDetent, - horizontalAlignment: .leading, - isPresented: $showPopup - ) { - Group { - if let popup = popup { - PopupView(popup: popup, isPresented: $showPopup) - .showCloseButton(true) - } - } - .padding() - } - } - } -} -``` - -To see it in action, try out the [Examples](../../Examples) and refer to [PopupExampleView.swift](../../Examples/Examples/PopupExampleView.swift) in the project. diff --git a/Documentation/README.md b/Documentation/README.md deleted file mode 100644 index 88858e530..000000000 --- a/Documentation/README.md +++ /dev/null @@ -1,15 +0,0 @@ -## Toolkit Component Documentation - -### Table of Contents - -* **[Authenticator](Authenticator)** - Displays a user interface when network and ArcGIS authentication challenges occur. -* **[BasemapGallery](BasemapGallery)** - Displays a collection of basemaps. -* **[Bookmarks](Bookmarks)** - Shows bookmarks, from a map, scene, or a list. -* **[Compass](Compass)** - Shows a compass direction when the map is rotated. Auto-hides when the map points north up. -* **[FloatingPanel](FloatingPanel)** - Allows display of view-related content in a "bottom sheet". -* **[FloorFilter](FloorFilter)** - Allows to filter floor plan data in a geo view by a site, a building in the site, or a floor in the building. -* **[Overview Map](OverviewMap)** - Displays an "overview" (or "inset") map on top of an existing map or scene view. -* **[Popup](Popup)** - Displays details, media, and attachments of features and graphics. -* **[Scalebar](Scalebar)** - Displays current scale reference. -* **[Search](Search)** - Displays a search experience for geo views. -* **[UtilityNetworkTrace](UtilityNetworkTrace)** - Runs traces on a web map published with a utility network and trace configurations. diff --git a/Documentation/Scalebar/README.md b/Documentation/Scalebar/README.md deleted file mode 100644 index 372dc9f70..000000000 --- a/Documentation/Scalebar/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# Scalebar - -A scalebar displays the representation of an accurate linear measurement on the map. It provides a visual indication through which users can determine the size of features or the distance between features on a map. A scale bar is a line or bar divided into parts. It is labeled with its ground length, usually in multiples of map units, such as tens of kilometers or hundreds of miles. - -![image](https://user-images.githubusercontent.com/3998072/203605457-df6f845c-9245-4608-a61e-6d1e2e63a81b.png) - -## Features - -Scalebar: - -- Can be configured to display as either a bar or line, with different styles for each. -- Can be configured with custom colors for fills, lines, shadows, and text. -- Can be configured to automatically hide after a pan or zoom operation. -- Displays both metric and imperial units. - -## Key properties - -`Scalebar` has the following initializer: - -```swift - /// A scalebar displays the current map scale. - /// - Parameters: - /// - maxWidth: The maximum screen width allotted to the scalebar. - /// - minScale: Set a minScale if you only want the scalebar to appear when you reach a large - /// enough scale maybe something like 10_000_000. This could be useful because the scalebar is - /// really only accurate for the center of the map on smaller scales (when zoomed way out). A - /// minScale of 0 means it will always be visible. - /// - settings: Appearance settings. - /// - spatialReference: The map's spatial reference. - /// - style: The visual appearance of the scalebar. - /// - units: The units to be displayed in the scalebar. - /// - unitsPerPoint: The current number of device independent pixels to map display units. - /// - useGeodeticCalculations: Set `false` to compute scale without a geodesic curve. - /// - viewpoint: The map's current viewpoint. - public init( - maxWidth: Double, - minScale: Double = .zero, - settings: ScalebarSettings = ScalebarSettings(), - spatialReference: Binding, - style: ScalebarStyle = .alternatingBar, - units: ScalebarUnits = NSLocale.current.usesMetricSystem ? .metric : .imperial, - unitsPerPoint: Binding, - useGeodeticCalculations: Bool = true, - viewpoint: Binding - ) -``` - -`Scalebar` has an associated `ScalebarSettings` struct to aid in configuration: - -```swift -public struct ScalebarSettings { - /// - Parameters: - /// - autoHide: Determines if the scalebar should automatically hide/show itself. - /// - autoHideDelay: The time to wait in seconds before the scalebar hides itself. - /// - barCornerRadius: The corner radius used by bar style scalebar renders. - /// - fillColor1: The darker fill color used by the alternating bar style render. - /// - fillColor2: The lighter fill color used by the bar style renders. - /// - lineColor: The color of the prominent scalebar line. - /// - shadowColor: The shadow color used by all scalebar style renders. - /// - shadowRadius: The shadow radius used by all scalebar style renders. - /// - textColor: The text color used by all scalebar style renders. - /// - textShadowColor: The text shadow color used by all scalebar style renders. - public init( - autoHide: Bool = false, - autoHideDelay: TimeInterval = 1.75, - barCornerRadius: Double = 2.5, - fillColor1: Color = .black, - fillColor2: Color = Color(uiColor: .lightGray).opacity(0.5), - lineColor: Color = .white, - shadowColor: Color = Color(uiColor: .black).opacity(0.65), - shadowRadius: Double = 1.0, - textColor: Color = .primary, - textShadowColor: Color = .white - ) -} -``` - -## Behavior: - -The scalebar uses geodetic calculations to provide accurate measurements for maps of any spatial reference. The measurement is accurate for the center of the map extent being displayed. This means at smaller scales (zoomed way out) you might find it somewhat inaccurate at the extremes of the visible extent. As the map is panned and zoomed, the scalebar automatically grows and shrinks and updates its measurement based on the new map extent. - -## Usage - -### Basic usage for displaying a `Scalebar`. - -```swift -MapView(map: map) - .onSpatialReferenceChanged { spatialReference = $0 } - .onUnitsPerPointChanged { unitsPerPoint = $0 } - .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } - .overlay(alignment: alignment) { - Scalebar( - maxWidth: maxWidth, - spatialReference: $spatialReference, - unitsPerPoint: $unitsPerPoint, - viewpoint: $viewpoint - ) - } -``` - -To see it in action, try out the [Examples](../../Examples) and refer to [ScalebarExampleView.swift](../../Examples/Examples/ScalebarExampleView.swift) in the project. diff --git a/Documentation/Search/README.md b/Documentation/Search/README.md deleted file mode 100644 index af0cadf53..000000000 --- a/Documentation/Search/README.md +++ /dev/null @@ -1,185 +0,0 @@ -# Search - -`SearchView` enables searching using one or more locators, with support for suggestions, automatic zooming, and custom search sources. - -|iPhone|iPad| -|:--:|:--:| -|![image](https://user-images.githubusercontent.com/3998072/203608897-5f3bf34a-0931-4d11-b3fc-18a5dd07131a.png)|![image](https://user-images.githubusercontent.com/3998072/203608708-45a0096c-a8d6-457c-9ee1-8cdb9e5bb15a.png)| - -## Features - -- Updates search suggestions as you type. -- Supports using the Esri world geocoder or any other ArcGIS locators. -- Supports searching using custom search sources. -- Allows for customization of the display of search results. -- Allows you to repeat a search within a defined area, and displays a button to enable that search when the view's viewpoint changes. - -## Key properties - -`SearchView` has the following initializer: - -```swift - /// Creates a `SearchView`. - /// - Parameters: - /// - sources: A collection of search sources to be used. - /// - viewpoint: The `Viewpoint` used to pan/zoom to results. If `nil`, there will be - /// no zooming to results. - /// - geoViewProxy: The proxy to provide access to geo view operations. - public init( - sources: [SearchSource] = [], - viewpoint: Binding? = nil, - geoViewProxy: GeoViewProxy? = nil - ) -``` - -`SearchView` uses search sources which implement the `SearchSource` protocol: - -```swift -/// Defines the contract for a search result provider. -public protocol SearchSource { - - /// Name to show when presenting this source in the UI. - var name: String { get set } - - /// The maximum results to return when performing a search. Most sources default to `6`. - var maximumResults: Int { get set } - - /// The maximum suggestions to return. Most sources default to `6`. - var maximumSuggestions: Int { get set } - - /// Returns the search suggestions for the specified query. - /// - Parameters: - /// - query: The query for which to provide search suggestions. - /// - searchArea: The area used to limit results. - /// - preferredSearchLocation: The location used as a starting point for searches. - /// - Returns: An array of search suggestions. - func suggest(_ query: String, searchArea: Geometry?, preferredSearchLocation: Point?) async throws -> [SearchSuggestion] - - /// Gets search results. - /// - Parameters: - /// - query: Text to be used for query. - /// - searchArea: The area used to limit results. - /// - preferredSearchLocation: The location used as a starting point for searches. - /// - Returns: An array of search results. - func search(_ query: String, searchArea: Geometry?, preferredSearchLocation: Point?) async throws -> [SearchResult] - - /// Returns the search results for the specified search suggestion. - /// - Parameters: - /// - searchSuggestion: The search suggestion for which to provide search results. - /// - searchArea: The area used to limit results. - /// - preferredSearchLocation: The location used as a starting point for searches. - /// - Returns: An array of search results. - func search(_ searchSuggestion: SearchSuggestion, searchArea: Geometry?, preferredSearchLocation: Point?) async throws -> [SearchResult] - - /// Repeats the last search. - /// - Parameters: - /// - query: Text to be used for query. - /// - searchExtent: Extent used to limit the results. - /// - Returns: An array of search results. - func repeatSearch(_ query: String, searchExtent: Envelope) async throws -> [SearchResult] -} -``` - -`SearchView` provides the following search sources: - -```swift -/// Uses a Locator to provide search and suggest results. Most configuration should be done on the -/// `GeocodeParameters` directly. -public class LocatorSearchSource : ObservableObject, SearchSource -``` - -```swift -/// Extends `LocatorSearchSource` with intelligent search behaviors; adds support for features like -/// type-specific placemarks, repeated search, and more on the world geocode service. -public class SmartLocatorSearchSource: LocatorSearchSource -``` - -`SearchView` provides several instance methods, allowing customization and additional search behaviors (such as displaying a "Repeat search here" button): - -```swift -/// Specifies whether a built-in result view will be shown. If `false`, the result display/selection -/// list is not shown. Set to `false` if you want to define a custom result list. You might use a -/// custom result list to show results in a separate list, disconnected from the rest of the search view. -/// Defaults to `true`. -/// - Parameter newEnableResultListView: The new value. -/// - Returns: A new `SearchView`. -public func enableResultListView(_ newEnableResultListView: Bool) -> Self - -/// The string shown in the search view when no user query is entered. -/// Defaults to "Find a place or address". -/// - Parameter newPrompt: The new value. -/// - Returns: A new `SearchView`. -public func prompt(_ newPrompt: String) -> Self - -/// Sets the message to show when there are no results or suggestions. -/// -/// The default message is "No results found". -/// - Parameter newNoResultsMessage: The new value. -/// - Returns: A new `SearchView`. -public func noResultsMessage(_ newNoResultsMessage: String) -> Self - -/// Sets the current query. -/// - Parameter newQueryString: The new value. -/// - Returns: The `SearchView`. -public func currentQuery(_ newQuery: String) -> Self - -/// Sets a closure to perform when the query changes. -/// - Parameters: -/// - action: The closure to performed when the query has changed. -/// - query: The new query. -public func onQueryChanged(perform action: @escaping (_ query: String) -> Void) -> Self - -/// The `GraphicsOverlay` used to display results. If `nil`, no results will be displayed. -/// - Parameter newResultsOverlay: The new value. -/// - Returns: The `SearchView`. -public func resultsOverlay(_ newResultsOverlay: GraphicsOverlay?) -> Self - -/// Defines how many results to return. -/// - Parameter newResultMode: The new value. -/// - Returns: The `SearchView`. -public func resultMode(_ newResultMode: SearchResultMode) -> Self - -/// The search area to be used for the current query. -/// - Parameter newQueryArea: The new value. -/// - Returns: The `SearchView`. -public func queryArea(_ newQueryArea: Binding) -> Self - -/// Defines the center for the search. -/// - Parameter newQueryCenter: The new value. -/// - Returns: The `SearchView`. -public func queryCenter(_ newQueryCenter: Binding) -> Self - -/// The current map/scene view extent. Defaults to `nil`. Used to allow repeat searches after -/// panning/zooming the map. Set to `nil` if repeat search behavior is not wanted. -/// - Parameter newGeoViewExtent: The new value. -/// - Returns: The `SearchView`. -public func geoViewExtent(_ newGeoViewExtent: Binding) -> Self - -/// Denotes whether the `GeoView` is navigating. Used for the repeat search behavior. -/// - Parameter newIsGeoViewNavigating: The new value. -/// - Returns: The `SearchView`. -public func isGeoViewNavigating(_ newIsGeoViewNavigating: Binding) -> Self -``` - -## Behavior: - -The `SearchView` will display the results list view at half height, exposing a portion of the underlying map below the list, on an iPhone in portrait orientation (and certain iPad multitasking configurations). The user can hide or show the result list after searching by clicking on the up/down chevron symbol on the right of the search bar. - -## Usage - -### Basic usage for displaying a `SearchView`. - -```swift -SearchView( - sources: [locatorDataSource], - viewpoint: $searchResultViewpoint, - geoViewProxy: mapViewProxy -) -.resultsOverlay(searchResultsOverlay) -.queryCenter($queryCenter) -.geoViewExtent($geoViewExtent) -.isGeoViewNavigating($isGeoViewNavigating) -.padding() -``` - -To see the `SearchView` in action, and for examples of `Search` customization, check out the [Examples](../../Examples) and refer to [SearchExampleView.swift](../../Examples/Examples/SearchExampleView.swift) in the project. diff --git a/Documentation/UtilityNetworkTrace/README.md b/Documentation/UtilityNetworkTrace/README.md deleted file mode 100644 index 937acb264..000000000 --- a/Documentation/UtilityNetworkTrace/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# UtilityNetworkTrace - -`UtilityNetworkTrace` runs traces on a webmap published with a utility network and trace configurations. - -|iPhone|iPad| -|:--:|:--:| -|![image](https://user-images.githubusercontent.com/3998072/204343568-a236ae0d-6b70-4175-a70c-41c902123ea1.png)|![image](https://user-images.githubusercontent.com/3998072/204344567-c86b3a49-6109-4333-8993-7fdc74f2b35d.png)| - -## Features - -The utility network trace tool displays a list of named trace configurations defined for utility networks in a web map. It enables users to add starting points and perform trace analysis from the selected named trace configuration. - -A named trace configuration defined for a utility network in a webmap comprises the parameters used for a utility network trace. - -## Key properties - -`UtilityNetworkTrace` has the following initializer: - -```swift - /// A graphical interface to run pre-configured traces on a map's utility networks. - /// - Parameters: - /// - activeDetent: The current detent of the floating panel. - /// - graphicsOverlay: The graphics overlay to hold generated starting point and trace graphics. - /// - map: The map containing the utility network(s). - /// - mapPoint: Acts as the point at which newly selected starting point graphics will be created. - /// - screenPoint: Acts as the point of identification for items tapped in the utility network. - /// - mapViewProxy: The proxy to provide access to map view operations. - /// - viewpoint: Allows the utility network trace tool to update the parent map view's viewpoint. - /// - startingPoints: An optional list of programmatically provided starting points. - public init( - graphicsOverlay: Binding, - map: Map, - mapPoint: Binding, - screenPoint: Binding, - mapViewProxy: MapViewProxy?, - viewpoint: Binding, - startingPoints: Binding<[UtilityNetworkTraceStartingPoint]> = .constant([]) - ) -``` - -`UtilityNetworkTrace` the following instance method: - -```swift - /// Sets the active detent for a hosting floating panel. - /// - Parameter detent: A binding to a value that determines the height of a hosting - /// floating panel. - /// - Returns: A trace tool that automatically sets and responds to detent values to improve user - /// experience. - public func floatingPanelDetent(_ detent: Binding) -> UtilityNetworkTrace -``` - -## Behavior: - -The tool allows users to: - - Choose between multiple networks (if more than one is defined in a webmap) . - - Choose between named trace configurations: - - ![image](https://user-images.githubusercontent.com/3998072/204346359-419b0056-3a30-4120-9b47-c68513abde42.png) - - - Add trace starting points either programmatically or by tapping on a map view, then use the inspection view to narrow the selection: - - ![image](https://user-images.githubusercontent.com/3998072/204346273-38374067-a0b8-4db4-8e40-62b38e1603c8.png) - - - View trace results: - - |iPhone|iPad| -|:--:|:--:| -|![image](https://user-images.githubusercontent.com/3998072/204343941-91775a25-8dc0-4866-8273-0d4bfaa91aeb.png)|![image](https://user-images.githubusercontent.com/3998072/204344435-173fbf34-59d6-4a0f-84bf-30ed5de3572e.png)| - - - Run multiple trace scenarios, then use color and name to compare results: - - ![image](https://user-images.githubusercontent.com/3998072/204346039-038ba4fa-201a-428c-ae84-be8f10c91cf7.png) - - - See user-friendly warnings to help avoid common mistakes, including specifying too many starting points or running the same trace configuration multiple times. - -## Usage - -```swift -UtilityNetworkTrace( - graphicsOverlay: $resultGraphicsOverlay, - map: map, - mapPoint: $mapPoint, - viewPoint: $viewPoint, - mapViewProxy: $mapViewProxy, - viewpoint: $viewpoint -) -``` - -To see the `UtilityNetworkTrace` in action, check out the [Examples](../../Examples) and refer to [UtilityNetworkTraceExampleView.swift](../../Examples/Examples/UtilityNetworkTraceExampleView.swift) in the project. diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index d771721c2..94ae72182 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -53,7 +53,7 @@ 75D41B2A27C6F21400624D7C /* ScalebarExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScalebarExampleView.swift; sourceTree = ""; }; E42BFBE82672BF9500159107 /* SearchExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchExampleView.swift; sourceTree = ""; }; E4624A24278CE815000D2A38 /* FloorFilterExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloorFilterExampleView.swift; sourceTree = ""; }; - E47ABE402652FE0900FD2FE3 /* Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Examples.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E47ABE402652FE0900FD2FE3 /* Toolkit Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Toolkit Examples.app"; sourceTree = BUILT_PRODUCTS_DIR; }; E47ABE432652FE0900FD2FE3 /* ExamplesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesApp.swift; sourceTree = ""; }; E47ABE472652FE0C00FD2FE3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E47ABE4A2652FE0C00FD2FE3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -123,7 +123,7 @@ E47ABE412652FE0900FD2FE3 /* Products */ = { isa = PBXGroup; children = ( - E47ABE402652FE0900FD2FE3 /* Examples.app */, + E47ABE402652FE0900FD2FE3 /* Toolkit Examples.app */, ); name = Products; sourceTree = ""; @@ -171,9 +171,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - E47ABE3F2652FE0900FD2FE3 /* Examples */ = { + E47ABE3F2652FE0900FD2FE3 /* Toolkit Examples */ = { isa = PBXNativeTarget; - buildConfigurationList = E47ABE4F2652FE0C00FD2FE3 /* Build configuration list for PBXNativeTarget "Examples" */; + buildConfigurationList = E47ABE4F2652FE0C00FD2FE3 /* Build configuration list for PBXNativeTarget "Toolkit Examples" */; buildPhases = ( E47ABE3C2652FE0900FD2FE3 /* Sources */, E47ABE3D2652FE0900FD2FE3 /* Frameworks */, @@ -185,12 +185,12 @@ ); dependencies = ( ); - name = Examples; + name = "Toolkit Examples"; packageProductDependencies = ( E4E57DC5265D8EB00077A093 /* ArcGISToolkit */, ); productName = Examples; - productReference = E47ABE402652FE0900FD2FE3 /* Examples.app */; + productReference = E47ABE402652FE0900FD2FE3 /* Toolkit Examples.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -220,7 +220,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( - E47ABE3F2652FE0900FD2FE3 /* Examples */, + E47ABE3F2652FE0900FD2FE3 /* Toolkit Examples */, ); }; /* End PBXProject section */ @@ -421,14 +421,14 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 200.1.0; + MARKETING_VERSION = 200.2.0; PRODUCT_BUNDLE_IDENTIFIER = "com.esri.arcgis-swift-sdk-toolkit-examples"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,6"; }; name = Debug; }; @@ -449,14 +449,14 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 200.1.0; + MARKETING_VERSION = 200.2.0; PRODUCT_BUNDLE_IDENTIFIER = "com.esri.arcgis-swift-sdk-toolkit-examples"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,6"; }; name = Release; }; @@ -472,7 +472,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - E47ABE4F2652FE0C00FD2FE3 /* Build configuration list for PBXNativeTarget "Examples" */ = { + E47ABE4F2652FE0C00FD2FE3 /* Build configuration list for PBXNativeTarget "Toolkit Examples" */ = { isa = XCConfigurationList; buildConfigurations = ( E47ABE502652FE0C00FD2FE3 /* Debug */, diff --git a/Examples/Examples/BasemapGalleryExampleView.swift b/Examples/Examples/BasemapGalleryExampleView.swift index 81ae54840..574feee99 100644 --- a/Examples/Examples/BasemapGalleryExampleView.swift +++ b/Examples/Examples/BasemapGalleryExampleView.swift @@ -29,7 +29,7 @@ struct BasemapGalleryExampleView: View { ) /// The initial list of basemaps. - private let basemaps = initialBasemaps() + @State private var basemaps = initialBasemaps() var body: some View { MapView(map: map, viewpoint: initialViewpoint) @@ -38,7 +38,7 @@ struct BasemapGalleryExampleView: View { doneButton .padding() BasemapGallery(items: basemaps, geoModel: map) - .style(.automatic()) + .style(.grid(maxItemWidth: 100)) .padding() } } diff --git a/Examples/Examples/FloatingPanelExampleView.swift b/Examples/Examples/FloatingPanelExampleView.swift index b9e86739f..72d12021b 100644 --- a/Examples/Examples/FloatingPanelExampleView.swift +++ b/Examples/Examples/FloatingPanelExampleView.swift @@ -21,10 +21,13 @@ struct FloatingPanelExampleView: View { map: Map(basemapStyle: .arcGISImagery) ) - @State var isPresented = true + /// The Floating Panel's current content. + @State private var demoContent: FloatingPanelDemoContent? - @State var selectedDetent: FloatingPanelDetent = .half + /// The Floating Panel's current detent. + @State private var selectedDetent: FloatingPanelDetent = .half + /// The initial viewpoint shown in the map. private let initialViewpoint = Viewpoint( center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), scale: 1_000_000 @@ -35,45 +38,144 @@ struct FloatingPanelExampleView: View { map: dataModel.map, viewpoint: initialViewpoint ) - .floatingPanel(selectedDetent: $selectedDetent, isPresented: $isPresented) { - List { - Section("Preset Heights") { - Button("Summary") { - selectedDetent = .summary - } - Button("Half") { - selectedDetent = .half + .floatingPanel(selectedDetent: $selectedDetent, isPresented: isPresented) { + switch demoContent { + case .list: + FloatingPanelListDemoContent(selectedDetent: $selectedDetent) + case .text: + FloatingPanelTextContent() + case .textField: + FloatingPanelTextFieldDemoContent(selectedDetent: $selectedDetent) + case .none: + EmptyView() + } + } + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if demoContent != nil { + Button("Dismiss") { + demoContent = nil } - Button("Full") { - selectedDetent = .full + } else { + Menu { + Button("List") { + demoContent = .list + } + Button("Text") { + demoContent = .text + } + Button("Text Field") { + demoContent = .textField + } + } label: { + Text("Present") } } - Section("Fractional Heights") { - Button("1/4") { - selectedDetent = .fraction(1 / 4) - } - Button("1/2") { - selectedDetent = .fraction(1 / 2) - } - Button("3/4") { - selectedDetent = .fraction(3 / 4) - } + } + } + } + + /// A Boolean value indicating whether the Floating Panel is displayed or not. + var isPresented: Binding { + .init { + demoContent != nil + } set: { _ in + } + } +} + +/// The types of content available for demo in the Floating Panel. +private enum FloatingPanelDemoContent { + case list + case text + case textField +} + +/// Demo content consisting of a list with inner sections each containing a set of buttons This +/// content also demonstrates the ability to control the Floating Panel's detent. +private struct FloatingPanelListDemoContent: View { + @Binding var selectedDetent: FloatingPanelDetent + + var body: some View { + List { + Section("Preset Heights") { + Button("Full") { + selectedDetent = .full } - Section("Value Heights") { - Button("200") { - selectedDetent = .height(200) - } - Button("600") { - selectedDetent = .height(600) - } + .disabled(selectedDetent == .full) + Button("Half") { + selectedDetent = .half + } + .disabled(selectedDetent == .half) + Button("Summary") { + selectedDetent = .summary } + .disabled(selectedDetent == .summary) } - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(isPresented ? "Close" : "Open") { - isPresented.toggle() + Section("Fractional Heights") { + Button("3/4") { + selectedDetent = .fraction(3 / 4) + } + .disabled(selectedDetent == .fraction(3 / 4)) + Button("1/2") { + selectedDetent = .fraction(1 / 2) + } + .disabled(selectedDetent == .fraction(1 / 2)) + Button("1/4") { + selectedDetent = .fraction(1 / 4) + } + .disabled(selectedDetent == .fraction(1 / 4)) + } + Section("Value Heights") { + Button("600") { + selectedDetent = .height(600) } + .disabled(selectedDetent == .height(600)) + Button("200") { + selectedDetent = .height(200) + } + .disabled(selectedDetent == .height(200)) + } + } + } +} + +/// Demo content consisting of a single instance of short text which demonstrates the Floating +/// Panel has a stable width, despite the width of its content. +private struct FloatingPanelTextContent: View { + var body: some View { + Text("Hello, world!") + } +} + +/// Demo content consisting of a vertical stack of items, including a text field which demonstrates +/// the Floating Panel's keyboard avoidance capability. +private struct FloatingPanelTextFieldDemoContent: View { + @Binding var selectedDetent: FloatingPanelDetent + + @State private var sampleText = "" + + @FocusState private var isFocused + + var body: some View { + VStack(alignment: .leading) { + Text("Text Field") + .font(.title) + Text("The Floating Panel has built-in keyboard avoidance.") + .font(.caption) + TextField( + "Text Field", + text: $sampleText, + prompt: Text("Enter sample text.") + ) + .focused($isFocused) + .textFieldStyle(.roundedBorder) + Spacer() + } + .padding() + .onChange(of: selectedDetent) { newDetent in + if newDetent != .full { + isFocused = false } } } diff --git a/Examples/Examples/FloorFilterExampleView.swift b/Examples/Examples/FloorFilterExampleView.swift index 19a49be1e..87a52fd2d 100644 --- a/Examples/Examples/FloorFilterExampleView.swift +++ b/Examples/Examples/FloorFilterExampleView.swift @@ -16,16 +16,11 @@ import ArcGISToolkit import ArcGIS struct FloorFilterExampleView: View { - /// Make a map from a portal item. - static func makeMap() -> Map { - Map(item: PortalItem( - portal: .arcGISOnline(connection: .anonymous), - id: Item.ID("b4b599a43a474d33946cf0df526426f5")! - )) - } - /// Determines the arrangement of the inner `FloorFilter` UI components. - private let floorFilterAlignment = Alignment.bottomLeading + private var floorFilterAlignment: Alignment { .bottomLeading } + + /// The height of the map view's attribution bar. + @State private var attributionBarHeight = 0.0 /// Determines the appropriate time to initialize the `FloorFilter`. @State private var isMapLoaded = false @@ -33,6 +28,15 @@ struct FloorFilterExampleView: View { /// A Boolean value indicating whether the map is currently being navigated. @State private var isNavigating = false + /// The `Map` displayed in the `MapView`. + @State private var map = Map( + item: PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("b4b599a43a474d33946cf0df526426f5")! + ) + ) + + /// A Boolean value indicating whether an error was encountered while loading the map. @State private var mapLoadError = false /// The initial viewpoint of the map. @@ -45,58 +49,54 @@ struct FloorFilterExampleView: View { scale: 100_000 ) - /// The data model containing the `Map` displayed in the `MapView`. - @StateObject private var dataModel = MapDataModel( - map: makeMap() - ) - var body: some View { - MapView( - map: dataModel.map, - viewpoint: viewpoint - ) - .onNavigatingChanged { - isNavigating = $0 - } - .onViewpointChanged(kind: .centerAndScale) { - viewpoint = $0 - } - // Preserve the current viewpoint when a keyboard is presented in landscape. - .ignoresSafeArea(.keyboard, edges: .bottom) - .overlay(alignment: floorFilterAlignment) { - if isMapLoaded, - let floorManager = dataModel.map.floorManager { - FloorFilter( - floorManager: floorManager, - alignment: floorFilterAlignment, - viewpoint: $viewpoint, - isNavigating: $isNavigating - ) - .frame( - maxWidth: 400, - maxHeight: 400 - ) - .padding(36) - } else if mapLoadError { - Label( - "Map load error!", - systemImage: "exclamationmark.triangle" - ) - .foregroundColor(.red) - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .center - ) + MapView(map: map, viewpoint: viewpoint) + .onAttributionBarHeightChanged { newHeight in + withAnimation { attributionBarHeight = newHeight } + } + .onNavigatingChanged { + isNavigating = $0 + } + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + // Preserve the current viewpoint when a keyboard is presented in landscape. + .ignoresSafeArea(.keyboard, edges: .bottom) + .overlay(alignment: floorFilterAlignment) { + if isMapLoaded, + let floorManager = map.floorManager { + FloorFilter( + floorManager: floorManager, + alignment: floorFilterAlignment, + viewpoint: $viewpoint, + isNavigating: $isNavigating + ) + .frame( + maxWidth: 400, + maxHeight: 400 + ) + .padding([.horizontal], 10) + .padding([.vertical], 10 + attributionBarHeight) + } else if mapLoadError { + Label( + "Map load error!", + systemImage: "exclamationmark.triangle" + ) + .foregroundColor(.red) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .center + ) + } } - } - .task { - do { - try await dataModel.map.load() - isMapLoaded = true - } catch { - mapLoadError = true + .task { + do { + try await map.load() + isMapLoaded = true + } catch { + mapLoadError = true + } } - } } } diff --git a/Examples/Examples/PopupExampleView.swift b/Examples/Examples/PopupExampleView.swift index bed264ab1..e90f015bb 100644 --- a/Examples/Examples/PopupExampleView.swift +++ b/Examples/Examples/PopupExampleView.swift @@ -43,43 +43,37 @@ struct PopupExampleView: View { var body: some View { MapViewReader { proxy in - VStack { - MapView(map: dataModel.map) - .onSingleTapGesture { screenPoint, _ in - identifyScreenPoint = screenPoint + MapView(map: dataModel.map) + .onSingleTapGesture { screenPoint, _ in + identifyScreenPoint = screenPoint + } + .task(id: identifyScreenPoint) { + guard let identifyScreenPoint = identifyScreenPoint, + let identifyResult = await Result(awaiting: { + try await proxy.identifyLayers( + screenPoint: identifyScreenPoint, + tolerance: 10, + returnPopupsOnly: true + ) + }) + .cancellationToNil() + else { + return } - .task(id: identifyScreenPoint) { - guard let identifyScreenPoint = identifyScreenPoint, - let identifyResult = await Result(awaiting: { - try await proxy.identifyLayers( - screenPoint: identifyScreenPoint, - tolerance: 10, - returnPopupsOnly: true - ) - }) - .cancellationToNil() - else { - return - } - - self.identifyScreenPoint = nil - self.popup = try? identifyResult.get().first?.popups.first - self.showPopup = self.popup != nil - } - .floatingPanel( - selectedDetent: $floatingPanelDetent, - horizontalAlignment: .leading, - isPresented: $showPopup - ) { - Group { - if let popup = popup { - PopupView(popup: popup, isPresented: $showPopup) - .showCloseButton(true) - } - } + + self.identifyScreenPoint = nil + self.popup = try? identifyResult.get().first?.popups.first + self.showPopup = self.popup != nil + } + .floatingPanel( + selectedDetent: $floatingPanelDetent, + horizontalAlignment: .leading, + isPresented: $showPopup + ) { + PopupView(popup: popup!, isPresented: $showPopup) + .showCloseButton(true) .padding() - } - } + } } } } diff --git a/Examples/Examples/ScalebarExampleView.swift b/Examples/Examples/ScalebarExampleView.swift index 4868b9f7c..2d69548fa 100644 --- a/Examples/Examples/ScalebarExampleView.swift +++ b/Examples/Examples/ScalebarExampleView.swift @@ -16,6 +16,9 @@ import ArcGISToolkit import SwiftUI struct ScalebarExampleView: View { + /// The height of the map view's attribution bar. + @State private var attributionBarHeight = 0.0 + /// Allows for communication between the `Scalebar` and `MapView`. @State private var spatialReference: SpatialReference? @@ -28,28 +31,29 @@ struct ScalebarExampleView: View { /// The location of the scalebar on screen. private let alignment: Alignment = .bottomLeading - /// The data model containing the `Map` displayed in the `MapView`. - @StateObject private var dataModel = MapDataModel( - map: Map(basemapStyle: .arcGISTopographic) - ) + /// The `Map` displayed in the `MapView`. + @State private var map = Map(basemapStyle: .arcGISTopographic) /// The maximum screen width allotted to the scalebar. private let maxWidth: Double = 175.0 var body: some View { - MapView(map: dataModel.map) + MapView(map: map) + .onAttributionBarHeightChanged { newHeight in + withAnimation { attributionBarHeight = newHeight } + } .onSpatialReferenceChanged { spatialReference = $0 } .onUnitsPerPointChanged { unitsPerPoint = $0 } .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } .overlay(alignment: alignment) { Scalebar( maxWidth: maxWidth, - spatialReference: $spatialReference, - unitsPerPoint: $unitsPerPoint, - viewpoint: $viewpoint + spatialReference: spatialReference, + unitsPerPoint: unitsPerPoint, + viewpoint: viewpoint ) .padding(.horizontal, 10) - .padding(.vertical, 50) + .padding(.vertical, 10 + attributionBarHeight) } } } diff --git a/Examples/Examples/SearchExampleView.swift b/Examples/Examples/SearchExampleView.swift index 7c0bc3a54..50d63d58a 100644 --- a/Examples/Examples/SearchExampleView.swift +++ b/Examples/Examples/SearchExampleView.swift @@ -31,6 +31,9 @@ struct SearchExampleView: View { /// The `GraphicsOverlay` used by the `SearchView` to display search results on the map. private let searchResultsOverlay = GraphicsOverlay() + /// The height of the map view's attribution bar. + @State private var attributionBarHeight = 0.0 + /// The map viewpoint used by the `SearchView` to pan/zoom the map /// to the extent of the search results. @State private var searchResultViewpoint: Viewpoint? = Viewpoint( @@ -57,6 +60,9 @@ struct SearchExampleView: View { viewpoint: searchResultViewpoint, graphicsOverlays: [searchResultsOverlay] ) + .onAttributionBarHeightChanged { newValue in + withAnimation { attributionBarHeight = newValue } + } .onNavigatingChanged { isGeoViewNavigating = $0 } .onViewpointChanged(kind: .centerAndScale) { queryCenter = $0.targetGeometry as? Point @@ -81,7 +87,8 @@ struct SearchExampleView: View { .queryCenter($queryCenter) .geoViewExtent($geoViewExtent) .isGeoViewNavigating($isGeoViewNavigating) - .padding() + .padding([.leading, .top, .trailing]) + .padding([.bottom], 10 + attributionBarHeight) } } } diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/Contents.json index 9221b9bb1..2f2a8e2a0 100644 --- a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,71 +1,85 @@ { "images" : [ { + "filename" : "iOS_40-1.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { + "filename" : "iOS_60.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { + "filename" : "iOS_58.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { + "filename" : "iOS_87.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { + "filename" : "iOS_80.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { + "filename" : "iOS_120-1.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { + "filename" : "iOS_120.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { + "filename" : "iOS_180.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { + "filename" : "iOS_20.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { + "filename" : "iOS_40-2.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { + "filename" : "iOS_29.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { + "filename" : "iOS_58-1.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { + "filename" : "iOS_40.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { + "filename" : "iOS_80-1.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" @@ -76,16 +90,19 @@ "size" : "76x76" }, { + "filename" : "iOS_152.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { + "filename" : "iOS_167.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { + "filename" : "iOS_1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_1024.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_1024.png new file mode 100644 index 000000000..4d11ab307 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_1024.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_120-1.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_120-1.png new file mode 100644 index 000000000..2c7624041 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_120-1.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_120.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_120.png new file mode 100644 index 000000000..2c7624041 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_120.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_152.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_152.png new file mode 100644 index 000000000..f45fb8206 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_152.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_167.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_167.png new file mode 100644 index 000000000..d4f43f943 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_167.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_180.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_180.png new file mode 100644 index 000000000..341f60558 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_180.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_20.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_20.png new file mode 100644 index 000000000..304ef292e Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_20.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_29.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_29.png new file mode 100644 index 000000000..9dca10b07 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_29.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_40-1.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_40-1.png new file mode 100644 index 000000000..f6759f106 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_40-1.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_40-2.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_40-2.png new file mode 100644 index 000000000..f6759f106 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_40-2.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_40.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_40.png new file mode 100644 index 000000000..f6759f106 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_40.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_58-1.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_58-1.png new file mode 100644 index 000000000..483fc4e63 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_58-1.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_58.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_58.png new file mode 100644 index 000000000..483fc4e63 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_58.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_60.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_60.png new file mode 100644 index 000000000..3c8235b3c Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_60.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_80-1.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_80-1.png new file mode 100644 index 000000000..b7aca9644 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_80-1.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_80.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_80.png new file mode 100644 index 000000000..b7aca9644 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_80.png differ diff --git a/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_87.png b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_87.png new file mode 100644 index 000000000..3aa7b1802 Binary files /dev/null and b/Examples/ExamplesApp/Assets.xcassets/AppIcon.appiconset/iOS_87.png differ diff --git a/Examples/ExamplesApp/ExampleView.swift b/Examples/ExamplesApp/ExampleView.swift index c5fb89478..6986ba73b 100644 --- a/Examples/ExamplesApp/ExampleView.swift +++ b/Examples/ExamplesApp/ExampleView.swift @@ -20,6 +20,7 @@ struct ExampleView: View { var body: some View { example.makeBody() .navigationTitle(example.name) + .navigationBarTitleDisplayMode(.inline) } } diff --git a/Examples/ExamplesApp/Examples.swift b/Examples/ExamplesApp/Examples.swift index c0ac7100b..2a4cc093f 100644 --- a/Examples/ExamplesApp/Examples.swift +++ b/Examples/ExamplesApp/Examples.swift @@ -25,7 +25,7 @@ struct Examples: View { List(lists) { (list) in NavigationLink(list.name, destination: list) } - .navigationBarTitle(Text("Examples"), displayMode: .inline) + .navigationBarTitle(Text("Toolkit Examples"), displayMode: .inline) } .navigationViewStyle(StackNavigationViewStyle()) } diff --git a/Examples/ExamplesApp/Info.plist b/Examples/ExamplesApp/Info.plist index 8e3dfc51d..fe305139a 100644 --- a/Examples/ExamplesApp/Info.plist +++ b/Examples/ExamplesApp/Info.plist @@ -10,6 +10,43 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleLocalizations + + ar + ca + cs + da + de + el + en + es + fi + fr + he + hr + hu + id + it + ja + ko + nb + nl + pl + pt-BR + pt-PT + ro + ru + sk + sv + th + tr + uk + vi + zh-Hans-CN + zh-Hans + zh-Hant-HK + zh-Hant-TW + CFBundleName $(PRODUCT_NAME) CFBundlePackageType diff --git a/Package.swift b/Package.swift index 2637fef88..d858a8d25 100644 --- a/Package.swift +++ b/Package.swift @@ -29,7 +29,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/Esri/arcgis-maps-sdk-swift", .upToNextMinor(from: "200.1.0")) + .package(url: "https://github.com/Esri/arcgis-maps-sdk-swift", .upToNextMinor(from: "200.2.0")) ], targets: [ .target( diff --git a/README.md b/README.md index 2739d532c..68dea7abe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ArcGIS Maps SDK for Swift Toolkit -[![doc](https://img.shields.io/badge/Doc-purple)](Documentation) [![SPM](https://img.shields.io/badge/SPM-compatible-4BC51D.svg?style=flat)](https://github.com/apple/swift-package-manager/) +[![doc](https://img.shields.io/badge/Doc-purple)](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/) [![SPM](https://img.shields.io/badge/SPM-compatible-4BC51D.svg?style=flat)](https://github.com/apple/swift-package-manager/) The ArcGIS Maps SDK for Swift Toolkit contains components that will simplify your Swift app development. It is built off of the new ArcGIS Maps SDK for Swift. @@ -10,21 +10,21 @@ To use Toolkit in your project: ## Toolkit Components -* **[Authenticator](Documentation/Authenticator)** - Displays a user interface when network and ArcGIS authentication challenges occur. -* **[BasemapGallery](Documentation/BasemapGallery)** - Displays a collection of basemaps. -* **[Bookmarks](Documentation/Bookmarks)** - Shows bookmarks, from a map, scene, or a list. -* **[Compass](Documentation/Compass)** - Shows a compass direction when the map is rotated. Auto-hides when the map points north. -* **[FloatingPanel](Documentation/FloatingPanel)** - Allows display of view-related content in a "bottom sheet". -* **[FloorFilter](Documentation/FloorFilter)** - Allows filtering of floor plan data in a geo view by a site, a building in the site, or a floor in the building. -* **[OverviewMap](Documentation/OverviewMap)** - Displays the visible extent of a geo view in a small "inset" map. -* **[Popup](Documentation/Popup)** - Displays details, media, and attachments of features and graphics. -* **[Scalebar](Documentation/Scalebar)** - Displays current scale reference. -* **[Search](Documentation/Search)** - Displays a search experience for geo views. -* **[UtilityNetworkTrace](Documentation/UtilityNetworkTrace)** - Runs traces on a web map published with a utility network and trace configurations. +* **[Authenticator](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/authenticator)** - Displays a user interface when network and ArcGIS authentication challenges occur. +* **[BasemapGallery](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/basemapgallery)** - Displays a collection of basemaps. +* **[Bookmarks](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/bookmarks)** - Shows bookmarks, from a map, scene, or a list. +* **[Compass](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/compass)** - Shows a compass direction when the map is rotated. Auto-hides when the map points north. +* **[FloatingPanel](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/floatingpanel)** - Allows display of view-related content in a "bottom sheet". +* **[FloorFilter](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/floorfilter)** - Allows filtering of floor plan data in a geo view by a site, a building in the site, or a floor in the building. +* **[OverviewMap](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/overviewmap)** - Displays the visible extent of a geo view in a small "inset" map. +* **[PopupView](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/popupview)** - Displays details, media, and attachments of features and graphics. +* **[Scalebar](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/scalebar)** - Displays current scale reference. +* **[SearchView](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/searchview)** - Displays a search experience for geo views. +* **[UtilityNetworkTrace](https://developers.arcgis.com/swift/toolkit-api-reference/documentation/arcgistoolkit/utilitynetworktrace)** - Runs traces on a web map published with a utility network and trace configurations. ## Requirements * ArcGIS Maps SDK for Swift -* Xcode 14.0 (or newer) +* Xcode 14.1 (or newer) The *ArcGIS Maps SDK for Swift Toolkit* has a *Target SDK* version of *15.0*, meaning that it can run on devices with *iOS 15.0* or newer. @@ -48,6 +48,7 @@ Some of the toolkit components and examples utilize a set of ready-to-use ArcGIS ## Additional Resources +* [Toolkit Tutorials](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/toolkittutorials) * [Developers guide documentation](https://developers.arcgis.com/swift) * [Maps SDK API Reference](https://developers.arcgis.com/swift/api-reference/documentation/arcgis) * [Samples](https://github.com/Esri/arcgis-maps-sdk-swift-samples) diff --git a/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift b/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift index bd909618d..c292182cd 100644 --- a/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift +++ b/Sources/ArcGISToolkit/Components/Authentication/Authenticator.swift @@ -15,15 +15,47 @@ import ArcGIS import SwiftUI import Combine -/// A configurable object that handles authentication challenges. +/// The `Authenticator` is a configurable object that handles authentication challenges. It will +/// display a user interface when network and ArcGIS authentication challenges occur. +/// +/// ![image](https://user-images.githubusercontent.com/3998072/203615041-c887d5e3-bb64-469a-a76b-126059329e92.png) +/// +/// **Features** +/// +/// The `Authenticator` has a view modifier that will display a prompt when the `Authenticator` is +/// asked to handle an authentication challenge. This will handle many different types of +/// authentication, for example: +/// +/// - ArcGIS authentication (token and OAuth) +/// - Integrated Windows Authentication (IWA) +/// - Client Certificate (PKI) +/// +/// The `Authenticator` can be configured to support securely persisting credentials to the keychain. +/// +/// `Authenticator` is accessible via a modifier on `View`: +/// +/// ```swift +/// /// Presents user experiences for collecting network authentication credentials from the user. +/// /// - Parameter authenticator: The authenticator for which credentials will be prompted. +/// @ViewBuilder func authenticator(_ authenticator: Authenticator) -> some View +/// ``` +/// +/// **Behavior** +/// +/// The `authenticator(_:)` view modifier will display an alert prompting the user for credentials. If +/// credentials were persisted to the keychain, the authenticator will use those instead of +/// requiring the user to re-enter credentials. +/// +/// To see the `Authenticator` in action, check out the [Authentication Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/AuthenticationExample) +/// and refer to [AuthenticationApp.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/AuthenticationExample/AuthenticationExample/AuthenticationApp.swift). +/// To learn more about using the `Authenticator`, see the [Authenticator Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/authenticatortutorial). @MainActor public final class Authenticator: ObservableObject { + /// A value indicating whether we should prompt the user when encountering an untrusted host. + let promptForUntrustedHosts: Bool /// The OAuth configurations that this authenticator can work with. let oAuthUserConfigurations: [OAuthUserConfiguration] - /// A value indicating whether we should prompt the user when encountering an untrusted host. - var promptForUntrustedHosts: Bool - /// Creates an authenticator. /// - Parameters: /// - promptForUntrustedHosts: A value indicating whether we should prompt the user when @@ -51,7 +83,14 @@ extension Authenticator: ArcGISAuthenticationChallengeHandler { // Create the correct challenge type. if let configuration = oAuthUserConfigurations.first(where: { $0.canBeUsed(for: challenge.requestURL) }) { - return .continueWithCredential(try await OAuthUserCredential.credential(for: configuration)) + do { + return .continueWithCredential(try await OAuthUserCredential.credential(for: configuration)) + } catch is CancellationError { + // If user cancels the creation of OAuth user credential then catch the + // cancellation error and cancel the challenge. This will make the request which + // issued the challenge fail with `ArcGISChallengeCancellationError`. + return .cancel + } } else { let tokenChallengeContinuation = TokenChallengeContinuation(arcGISChallenge: challenge) diff --git a/Sources/ArcGISToolkit/Components/Authentication/CertificatePickerViewModifier.swift b/Sources/ArcGISToolkit/Components/Authentication/CertificatePickerViewModifier.swift index 8f2862b24..ec1a4973a 100644 --- a/Sources/ArcGISToolkit/Components/Authentication/CertificatePickerViewModifier.swift +++ b/Sources/ArcGISToolkit/Components/Authentication/CertificatePickerViewModifier.swift @@ -115,13 +115,13 @@ extension CertificateImportError: LocalizedError { public var errorDescription: String? { switch self { case .invalidData: - return String(localized: "The certificate file was invalid.", bundle: .module) + return String(localized: "The certificate file was invalid.", bundle: .toolkitModule) case .invalidPassword: - return String(localized: "The password was invalid.", bundle: .module) + return String(localized: "The password was invalid.", bundle: .toolkitModule) default: return SecCopyErrorMessageString(rawValue, nil) as String? ?? String( localized: "The certificate file or password was invalid.", - bundle: .module + bundle: .toolkitModule ) } } @@ -131,7 +131,7 @@ extension CertificatePickerViewModel.CertificateError: LocalizedError { var errorDescription: String? { switch self { case .couldNotAccessCertificateFile: - return String(localized: "Could not access the certificate file.", bundle: .module) + return String(localized: "Could not access the certificate file.", bundle: .toolkitModule) case .importError(let error): return error.localizedDescription case .other(let error): @@ -164,16 +164,22 @@ struct CertificatePickerViewModifier: ViewModifier { .credentialInput( fields: .password, isPresented: $viewModel.showPassword, - message: "Please enter a password for the chosen certificate.", - title: "Password Required", + message: String( + localized: "Please enter a password for the chosen certificate.", + bundle: .toolkitModule + ), + title: String( + localized: "Password Required", + bundle: .toolkitModule + ), cancelAction: .init( - title: "Cancel", + title: String(localized: "Cancel", bundle: .toolkitModule), handler: { _, _ in viewModel.cancel() } ), continueAction: .init( - title: "OK", + title: String(localized: "OK", bundle: .toolkitModule), handler: { _, password in viewModel.proceed(withPassword: password) } @@ -200,15 +206,30 @@ private extension View { isPresented: Binding, viewModel: CertificatePickerViewModel ) -> some View { - alert("Certificate Required", isPresented: isPresented, presenting: viewModel.challengingHost) { _ in - Button("Browse For Certificate") { + alert( + Text("Certificate Required", bundle: .toolkitModule), + isPresented: isPresented, + presenting: viewModel.challengingHost + ) { _ in + Button { viewModel.proceedFromPrompt() + } label: { + Text("Browse For Certificate", bundle: .toolkitModule) } - Button("Cancel", role: .cancel) { + Button(role: .cancel) { viewModel.cancel() + } label: { + Text("Cancel", bundle: .toolkitModule) } } message: { _ in - Text("A certificate is required to access content on \(viewModel.challengingHost).") + Text( + "A certificate is required to access content on \(viewModel.challengingHost).", + bundle: .toolkitModule, + comment: """ + An alert message indicating that a certificate is required to access + content on a remote host. The variable is the host that prompted the challenge. + """ + ) } } } @@ -243,19 +264,25 @@ private extension View { isPresented: Binding, viewModel: CertificatePickerViewModel ) -> some View { - - alert("Error importing certificate", isPresented: isPresented) { - Button("Try Again") { + alert( + Text("Error importing certificate", bundle: .toolkitModule), + isPresented: isPresented + ) { + Button { viewModel.proceedFromPrompt() + } label: { + Text("Try Again", bundle: .toolkitModule) } - Button("Cancel", role: .cancel) { + Button(role: .cancel) { viewModel.cancel() + } label: { + Text("Cancel", bundle: .toolkitModule) } } message: { Text( viewModel.certificateError?.localizedDescription ?? String( localized: "The certificate file or password was invalid.", - bundle: .module + bundle: .toolkitModule ) ) } diff --git a/Sources/ArcGISToolkit/Components/Authentication/Login.swift b/Sources/ArcGISToolkit/Components/Authentication/Login.swift deleted file mode 100644 index 198b17309..000000000 --- a/Sources/ArcGISToolkit/Components/Authentication/Login.swift +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright 2022 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 SwiftUI -import ArcGIS - -/// A value that contains a username and password pair. -struct LoginCredential: Hashable { - /// The username. - let username: String - - /// The password. - let password: String -} - -/// A type that provides the business logic for a view that prompts the user to login with a -/// username and password. -@MainActor -final class LoginViewModel: ObservableObject { - /// The username. - @Published var username = "" { - didSet { updateSignInButtonEnabled() } - } - - /// The password. - @Published var password = "" { - didSet { updateSignInButtonEnabled() } - } - - /// A Boolean value indicating if the sign-in button is enabled. - @Published var signInButtonEnabled = false - - /// The action to perform when the user signs in. This is a closure that takes a username - /// and password, respectively. - var signInAction: (LoginCredential) -> Void - - /// The action to perform when the user cancels. - var cancelAction: () -> Void - - /// Creates a `UsernamePasswordViewModel`. - /// - Parameters: - /// - challengingHost: The host that prompted the challenge. - /// - signInAction: The action to perform when the user signs in. This is a closure that takes - /// a username and password, respectively. - /// - cancelAction: The action to perform when the user cancels. - init( - challengingHost: String, - onSignIn signInAction: @escaping (LoginCredential) -> Void, - onCancel cancelAction: @escaping () -> Void - ) { - self.challengingHost = challengingHost - self.signInAction = signInAction - self.cancelAction = cancelAction - } - - private func updateSignInButtonEnabled() { - signInButtonEnabled = !username.isEmpty && !password.isEmpty - } - - /// The host that initiated the challenge. - var challengingHost: String - - /// Attempts to log in with a username and password. - func signIn() { - signInAction(LoginCredential(username: username, password: password)) - } - - /// Cancels the challenge. - func cancel() { - cancelAction() - } -} - -/// A view modifier that prompts a user to login with a username and password. -struct LoginViewModifier: ViewModifier { - /// The view model. - let viewModel: LoginViewModel - - /// A Boolean value indicating whether or not the prompt to login is displayed. - @State var isPresented = false - - func body(content: Content) -> some View { - content - .onAppear { isPresented = true } - .credentialInput( - fields: .usernamePassword, - isPresented: $isPresented, - message: "You must sign in to access '\(viewModel.challengingHost)'", - title: "Authentication Required", - cancelAction: .init( - title: "Cancel", - handler: { _, _ in - viewModel.cancel() - } - ), - continueAction: .init( - title: "Continue", - handler: { username, password in - viewModel.username = username - viewModel.password = password - viewModel.signIn() - } - ) - ) - } -} - -extension LoginViewModifier { - /// Creates a `LoginViewModifier` with a network challenge continuation. - @MainActor init(challenge: NetworkChallengeContinuation) { - self.init( - viewModel: LoginViewModel( - challengingHost: challenge.host, - onSignIn: { loginCredential in - challenge.resume( - with: .continueWithCredential( - .password(username: loginCredential.username, password: loginCredential.password) - ) - ) - }, - onCancel: { - challenge.resume(with: .cancel) - } - ) - ) - } - - /// Creates a `LoginViewModifier` with an ArcGIS challenge continuation. - @MainActor init(challenge: TokenChallengeContinuation) { - self.init( - viewModel: LoginViewModel( - challengingHost: challenge.host, - onSignIn: { loginCredential in - challenge.resume(with: loginCredential) - }, - onCancel: { - challenge.cancel() - } - ) - ) - } -} diff --git a/Sources/ArcGISToolkit/Components/Authentication/LoginCredential.swift b/Sources/ArcGISToolkit/Components/Authentication/LoginCredential.swift new file mode 100644 index 000000000..7f64fb206 --- /dev/null +++ b/Sources/ArcGISToolkit/Components/Authentication/LoginCredential.swift @@ -0,0 +1,20 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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. + +/// A value that contains a username and password pair. +struct LoginCredential: Hashable { + /// The username. + let username: String + /// The password. + let password: String +} diff --git a/Sources/ArcGISToolkit/Components/Authentication/LoginViewModifier.swift b/Sources/ArcGISToolkit/Components/Authentication/LoginViewModifier.swift new file mode 100644 index 000000000..28ce1e104 --- /dev/null +++ b/Sources/ArcGISToolkit/Components/Authentication/LoginViewModifier.swift @@ -0,0 +1,98 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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 ArcGIS +import SwiftUI + +/// A view modifier that prompts a user to login with a username and password. +struct LoginViewModifier: ViewModifier { + /// The host that initiated the challenge. + let challengingHost: String + /// The action to perform when the user signs in. This is a closure that + /// takes a login credential. + let onSignIn: (LoginCredential) -> Void + /// The action to perform when the user cancels. + let onCancel: () -> Void + + /// A Boolean value indicating whether or not the prompt to login is displayed. + @State private var isPresented = false + + func body(content: Content) -> some View { + content + .onAppear { isPresented = true } + .credentialInput( + fields: .usernamePassword, + isPresented: $isPresented, + message: String( + localized: "You must sign in to access '\(challengingHost)'", + bundle: .toolkitModule, + comment: """ + A label explaining that credentials are required to authenticate with specified host. + The host is indicated in the variable. + """ + ), + title: String(localized: "Authentication Required", bundle: .toolkitModule), + cancelAction: .init( + title: String(localized: "Cancel", bundle: .toolkitModule), + handler: { _, _ in + onCancel() + } + ), + continueAction: .init( + title: String(localized: "Continue", bundle: .toolkitModule), + handler: { username, password in + let loginCredential = LoginCredential( + username: username, password: password + ) + onSignIn(loginCredential) + } + ) + ) + } +} + +@MainActor +extension LoginViewModifier { + /// Creates an instance from a network challenge continuation. + init(challenge: NetworkChallengeContinuation) { + self.init( + challengingHost: challenge.host, + onSignIn: { loginCredential in + challenge.resume( + with: .continueWithCredential( + .password( + username: loginCredential.username, + password: loginCredential.password + ) + ) + ) + }, + onCancel: { + challenge.resume(with: .cancel) + } + ) + } + + /// Creates an instance from an ArcGIS challenge continuation. + init(challenge: TokenChallengeContinuation) { + self.init( + challengingHost: challenge.host, + onSignIn: { loginCredential in + challenge.resume(with: loginCredential) + }, + onCancel: { + challenge.cancel() + } + ) + } +} diff --git a/Sources/ArcGISToolkit/Components/Authentication/TrustHostViewModifier.swift b/Sources/ArcGISToolkit/Components/Authentication/TrustHostViewModifier.swift index 9d16cc513..eddaab9dc 100644 --- a/Sources/ArcGISToolkit/Components/Authentication/TrustHostViewModifier.swift +++ b/Sources/ArcGISToolkit/Components/Authentication/TrustHostViewModifier.swift @@ -34,19 +34,39 @@ struct TrustHostViewModifier: ViewModifier { func body(content: Content) -> some View { content - .task { + .onAppear { // Present the alert right away. This makes it animated. isPresented = true } - .alert("Certificate Trust Warning", isPresented: $isPresented, presenting: challenge) { _ in - Button("Dangerous: Allow Connection", role: .destructive) { + .alert( + Text( + "Certificate Trust Warning", + bundle: .toolkitModule, + comment: "A label indicating that the remote host's certificate is not trusted." + ), + isPresented: $isPresented, + presenting: challenge + ) { _ in + Button(role: .destructive) { challenge.resume(with: .continueWithCredential(.serverTrust)) + } label: { + Text( + "Allow", + bundle: .toolkitModule, + comment: "A button indicating the user accepts a potentially dangerous action." + ) } - Button("Cancel", role: .cancel) { + Button(role: .cancel) { challenge.resume(with: .cancel) + } label: { + Text("Cancel", bundle: .toolkitModule) } } message: { _ in - Text("The certificate provided by '\(challenge.host)' is not signed by a trusted authority.") + Text( + "Dangerous: The certificate provided by '\(challenge.host)' is not signed by a trusted authority.", + bundle: .toolkitModule, + comment: "A warning that the host service (challenge.host) is providing a potentially unsafe certificate." + ) } } } diff --git a/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGallery.swift b/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGallery.swift index 34f1e8834..8d6831f7a 100644 --- a/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGallery.swift +++ b/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGallery.swift @@ -14,11 +14,43 @@ import SwiftUI import ArcGIS -/// The `BasemapGallery` tool displays a collection of basemaps from either -/// ArcGIS Online, a user-defined portal, or an array of `BasemapGalleryItem`s. -/// When a new basemap is selected from the `BasemapGallery` and the optional -/// `BasemapGalleryViewModel.geoModel` property is set, then the basemap of the -/// `geoModel` is replaced with the basemap in the gallery. +/// The `BasemapGallery` displays a collection of basemaps from ArcGIS Online, a user-defined +/// portal, or an array of ``BasemapGalleryItem`` objects. When a new basemap is selected from the +/// `BasemapGallery` and a geo model was provided when the basemap gallery was created, the +/// basemap of the `geoModel` is replaced with the basemap in the gallery. +/// +/// | iPhone | iPad | +/// | ------ | ---- | +/// | ![image](https://user-images.githubusercontent.com/3998072/205385086-cb9bc0a0-3c46-484d-aefa-8878c7112a3e.png) | ![image](https://user-images.githubusercontent.com/3998072/205384854-79f25efe-25f4-4330-a487-b64b528a9daf.png) | +/// +/// > Note: `BasemapGallery` uses metered ArcGIS basemaps by default, so you will need to configure +/// an API key. See [Security and authentication documentation](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/#api-keys) +/// for more information. +/// +/// **Features** +/// +/// - Can be configured to use a list, grid, or automatic layout. When using an +/// automatic layout, list or grid presentation is chosen based on the horizontal size class of the +/// environment. +/// - Displays basemaps from a portal or a custom collection. If neither a custom portal or array of +/// basemaps is provided, the list of basemaps will be loaded from ArcGIS Online. +/// - Displays a representation of the map or scene's current basemap if that basemap exists in the +/// gallery. +/// - Displays a name and thumbnail for each basemap. +/// - Can be configured to automatically change the basemap of a geo model based on user selection. +/// +/// `BasemapGallery` has the following helper class: ``BasemapGalleryItem`` +/// +/// **Behavior** +/// +/// Selecting a basemap with a spatial reference that does not match that of the geo model +/// will display an error. It will also display an error if a provided base map cannot be loaded. If +/// a `GeoModel` is provided to the `BasemapGallery`, selecting an item in the gallery will set that +/// basemap on the geo model. +/// +/// To see it in action, try out the [Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/Examples/Examples) +/// and refer to [BasemapGalleryExampleView.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/Examples/Examples/BasemapGalleryExampleView.swift) +/// in the project. To learn more about using the `BasemapGallery` see the [BasemapGallery Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/basemapgallerytutorial). public struct BasemapGallery: View { /// The view style of the gallery. public enum Style { @@ -189,7 +221,7 @@ private extension BasemapGallery { // MARK: Modifiers public extension BasemapGallery { - /// The style of the basemap gallery. Defaults to ``Style/automatic(listWidth:gridWidth:)``. + /// The style of the basemap gallery. Defaults to ``Style/automatic(maxGridItemWidth:)``. /// - Parameter style: The `Style` to use. /// - Returns: The `BasemapGallery`. func style( @@ -214,8 +246,8 @@ extension AlertItem { /// - Parameter loadBasemapError: The load basemap error. init(loadBasemapError: Error) { self.init( - title: "Error loading basemap.", - message: "\((loadBasemapError as? ArcGISError)?.details ?? "The basemap failed to load for an unknown reason.")" + title: String.basemapFailedToLoadTitle, + message: (loadBasemapError as? ArcGISError)?.details ?? String.basemapFailedToLoadFallbackError ) } @@ -226,16 +258,33 @@ extension AlertItem { switch (spatialReferenceMismatchError.basemapSpatialReference, spatialReferenceMismatchError.geoModelSpatialReference) { case (.some(_), .some(_)): - message = "The basemap has a spatial reference that is incompatible with the map." + message = String(localized: "The basemap has a spatial reference that is incompatible with the map.", bundle: .toolkitModule) case (_, .none): - message = "The map does not have a spatial reference." + message = String(localized: "The map does not have a spatial reference.", bundle: .toolkitModule) case (.none, _): - message = "The basemap does not have a spatial reference." + message = String(localized: "The basemap does not have a spatial reference.", bundle: .toolkitModule) } self.init( - title: "Spatial reference mismatch.", + title: String(localized: "Spatial reference mismatch.", bundle: .toolkitModule), message: message ) } } + +private extension String { + static let basemapFailedToLoadFallbackError = String( + localized: "The basemap failed to load for an unknown reason.", + bundle: .toolkitModule, + comment: """ + An error to be displayed when a basemap chosen from the basemap gallery fails to + load for an unknown reason. + """ + ) + + static let basemapFailedToLoadTitle = String( + localized: "Error loading basemap.", + bundle: .toolkitModule, + comment: "An error to be displayed when a basemap chosen from the basemap gallery fails to load." + ) +} diff --git a/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryCell.swift b/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryCell.swift index c3ac77c10..4ca3f7fd9 100644 --- a/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryCell.swift +++ b/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryCell.swift @@ -57,7 +57,9 @@ struct BasemapGalleryCell: View { .multilineTextAlignment(.center) .foregroundColor(item.hasError ? .secondary : .primary) } - }).disabled(item.isBasemapLoading) + }) + .buttonStyle(.plain) + .disabled(item.isBasemapLoading) } /// Creates an overlay which is either a selection outline or an error icon. diff --git a/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryItem.swift b/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryItem.swift index 476f2b9b3..a294db7c2 100644 --- a/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryItem.swift +++ b/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryItem.swift @@ -172,6 +172,6 @@ private extension UIImage { /// A default thumbnail image. /// - Returns: The default thumbnail. static func defaultThumbnail() -> UIImage { - return UIImage(named: "defaultthumbnail", in: .module, with: nil)! + return UIImage(named: "defaultthumbnail", in: .toolkitModule, with: nil)! } } diff --git a/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryViewModel.swift b/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryViewModel.swift index 0aeb49862..476beccc6 100644 --- a/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryViewModel.swift +++ b/Sources/ArcGISToolkit/Components/BasemapGallery/BasemapGalleryViewModel.swift @@ -89,7 +89,12 @@ import ArcGIS @Published private(set) var currentItem: BasemapGalleryItem? = nil { didSet { guard let item = currentItem else { return } - geoModel?.basemap = item.basemap + // If the portal is nil, the user passed in their own array + // of basemaps, so clone the selected one prior to setting. This + // prevents the "Object already owned" error. + // If portal is non-nil, there's no need to clone the basemap + // as the list of basemaps is reloaded from the portal each time. + geoModel?.basemap = portal == nil ? item.basemap.clone() : item.basemap } } diff --git a/Sources/ArcGISToolkit/Components/Bookmarks/Bookmarks.swift b/Sources/ArcGISToolkit/Components/Bookmarks/Bookmarks.swift index 8771f3830..f6971586e 100644 --- a/Sources/ArcGISToolkit/Components/Bookmarks/Bookmarks.swift +++ b/Sources/ArcGISToolkit/Components/Bookmarks/Bookmarks.swift @@ -14,7 +14,36 @@ import ArcGIS import SwiftUI -/// `Bookmarks` allows a user to view and select from a set of bookmarks. +/// The `Bookmarks` component will display a list of bookmarks and allow the user to select a +/// bookmark and perform some action. You can create the component with either an array of +/// `Bookmark` values, or with a `Map` or `Scene` containing the bookmarks to display. +/// +/// `Bookmarks` can be configured to handle automated bookmark selection (zooming the map/scene to +/// the bookmark’s viewpoint) by passing in a `Viewpoint` binding or the client can handle bookmark +/// selection changes manually using ``onSelectionChanged(perform:)``. +/// +/// | iPhone | iPad | +/// | ------ | ---- | +/// | ![image](https://user-images.githubusercontent.com/3998072/202765630-894bee44-a0c2-4435-86f4-c80c4cc4a0b9.png) | ![image](https://user-images.githubusercontent.com/3998072/202765729-91c52555-4677-4c2b-b62b-215e6c3790a6.png) | +/// +/// **Features** +/// +/// - Can be configured to display bookmarks from a map or scene, or from an array of user-defined +/// bookmarks. +/// - Can be configured to automatically zoom the map or scene to a bookmark selection. +/// - Can be configured to perform a user-defined action when a bookmark is selected. +/// - Will automatically hide when a bookmark is selected. +/// +/// **Behavior** +/// +/// If a `Viewpoint` binding is provided to the `Bookmarks` view, selecting a bookmark will set that +/// viewpoint binding to the viewpoint of the bookmark. Selecting a bookmark will dismiss the +/// `Bookmarks` view. If a `GeoModel` is provided, that geo model's bookmarks will be displayed to +/// the user. +/// +/// To see it in action, try out the [Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/Examples/Examples) +/// and refer to [BookmarksExampleView.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/Examples/Examples/BookmarksExampleView.swift) +/// in the project. To learn more about using the `Bookmarks` component see the [Bookmarks Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/bookmarkstutorial). public struct Bookmarks: View { /// A list of selectable bookmarks. @State private var bookmarks: [Bookmark] = [] diff --git a/Sources/ArcGISToolkit/Components/Bookmarks/BookmarksHeader.swift b/Sources/ArcGISToolkit/Components/Bookmarks/BookmarksHeader.swift index dc55d8d9f..3801ba3c0 100644 --- a/Sources/ArcGISToolkit/Components/Bookmarks/BookmarksHeader.swift +++ b/Sources/ArcGISToolkit/Components/Bookmarks/BookmarksHeader.swift @@ -34,9 +34,9 @@ struct BookmarksHeader: View { HStack(alignment: .top) { Image(systemName: "bookmark") VStack(alignment: .leading) { - Text("Bookmarks") + Text("Bookmarks", bundle: .toolkitModule) .font(.headline) - Text("Select a bookmark") + Text("Select a bookmark", bundle: .toolkitModule) .font(.subheadline) .foregroundColor(.secondary) } @@ -49,8 +49,12 @@ struct BookmarksHeader: View { Button { isPresented.toggle() } label: { - Text("Done") - .fontWeight(.semibold) + Text( + "Done", + bundle: .toolkitModule, + comment: "A button to close the bookmark selection menu." + ) + .fontWeight(.semibold) } } } diff --git a/Sources/ArcGISToolkit/Components/Bookmarks/BookmarksList.swift b/Sources/ArcGISToolkit/Components/Bookmarks/BookmarksList.swift index 74a6b5540..392ace76f 100644 --- a/Sources/ArcGISToolkit/Components/Bookmarks/BookmarksList.swift +++ b/Sources/ArcGISToolkit/Components/Bookmarks/BookmarksList.swift @@ -39,7 +39,7 @@ struct BookmarksList: View { Group { if bookmarks.isEmpty { Label { - Text("No bookmarks") + Text("No bookmarks", bundle: .toolkitModule) } icon: { Image(systemName: "bookmark.slash") } diff --git a/Sources/ArcGISToolkit/Components/Compass/Compass.swift b/Sources/ArcGISToolkit/Components/Compass/Compass.swift index ff737eccb..edd5b6ed7 100644 --- a/Sources/ArcGISToolkit/Components/Compass/Compass.swift +++ b/Sources/ArcGISToolkit/Components/Compass/Compass.swift @@ -14,7 +14,21 @@ import ArcGIS import SwiftUI -/// A `Compass` (alias North arrow) shows where north is in a `MapView`. +/// A `Compass` (alias North arrow) shows where north is in a `MapView` or `SceneView`. +/// +/// ![image](https://user-images.githubusercontent.com/3998072/202810369-a0b82778-77d4-404e-bebf-1a84841fbb1b.png) +/// - Automatically hides when the rotation is zero. +/// - Can be configured to be always visible. +/// - Will reset the map/scene rotation to North when tapped on. +/// +/// Whenever the map is not orientated North (non-zero bearing) the compass appears. When reset to +/// north, it disappears. The `automaticallyHides` view modifier allows you to disable the auto-hide +/// feature so that it is always displayed. +/// When the compass is tapped, the map orients back to north (zero bearing). +/// +/// To see it in action, try out the [Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/Examples/Examples) +/// and refer to [CompassExampleView.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/Examples/Examples/CompassExampleView.swift) +/// in the project. To learn more about using the `Compass` see the [Compass Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/compasstutorial). public struct Compass: View { /// The opacity of the compass. @State private var opacity: Double = .zero @@ -82,7 +96,18 @@ public struct Compass: View { action() } } - .accessibilityLabel("Compass, heading \(Int(heading.rounded())) degrees \(CompassDirection(heading).rawValue)") + .accessibilityLabel( + String( + localized: "Compass, heading \(Int(heading.rounded())) degrees \(CompassDirection(heading).rawValue)", + bundle: .toolkitModule, + comment: """ + An compass description to be read by a screen reader describing the + current heading. The first variable being a degree value and the + second being a corresponding cardinal direction (north, northeast, + east, etc.). + """ + ) + ) } } } diff --git a/Sources/ArcGISToolkit/Components/FloatingPanel/FloatingPanel.swift b/Sources/ArcGISToolkit/Components/FloatingPanel/FloatingPanel.swift index a201fb1a5..c8fbef763 100644 --- a/Sources/ArcGISToolkit/Components/FloatingPanel/FloatingPanel.swift +++ b/Sources/ArcGISToolkit/Components/FloatingPanel/FloatingPanel.swift @@ -31,7 +31,7 @@ struct FloatingPanel: View where Content: View { let backgroundColor: Color /// The content shown in the floating panel. - let content: Content + let content: () -> Content /// Creates a `FloatingPanel`. /// - Parameters: @@ -43,28 +43,22 @@ struct FloatingPanel: View where Content: View { backgroundColor: Color, selectedDetent: Binding, isPresented: Binding, - @ViewBuilder content: () -> Content + @ViewBuilder content: @escaping () -> Content ) { self.backgroundColor = backgroundColor self.selectedDetent = selectedDetent self.isPresented = isPresented - self.content = content() + self.content = content } - /// A binding to the currently selected detent. - private var selectedDetent: Binding - /// The color of the handle. @State private var handleColor: Color = .defaultHandleColor /// The height of the content. @State private var height: CGFloat = .minHeight - /// A binding to a Boolean value that determines whether the view is presented. - private var isPresented: Binding - /// The latest recorded drag gesture value. - @State var latestDragGesture: DragGesture.Value? + @State private var latestDragGesture: DragGesture.Value? /// The maximum allowed height of the content. @State private var maximumHeight: CGFloat = .infinity @@ -74,22 +68,31 @@ struct FloatingPanel: View where Content: View { horizontalSizeClass == .compact && verticalSizeClass == .regular } + /// A binding to a Boolean value that determines whether the view is presented. + private var isPresented: Binding + + /// A binding to the currently selected detent. + private var selectedDetent: Binding + public var body: some View { GeometryReader { geometryProxy in VStack(spacing: 0) { - if isCompact && isPresented.wrappedValue { - makeHandleView() - Divider() - } - content - .frame(height: height) - .clipped() - .padding(.bottom, isPresented.wrappedValue ? (isCompact ? 25 : 10) : .zero) - if !isCompact && isPresented.wrappedValue { + if isPresented.wrappedValue { + if isCompact { + makeHandleView() + Divider() + } + content() + .frame(height: height) + .clipped() + .padding(.bottom, isCompact ? 25 : 10) + if !isCompact { Divider() makeHandleView() + } } } + .frame(maxWidth: .infinity) .background(backgroundColor) .clipShape( RoundedCorners( @@ -103,6 +106,7 @@ struct FloatingPanel: View where Content: View { height: geometryProxy.size.height, alignment: isCompact ? .bottom : .top ) + .animation(.easeInOut, value: isPresented.wrappedValue) .onSizeChange { maximumHeight = $0.height if height > maximumHeight { @@ -124,6 +128,16 @@ struct FloatingPanel: View where Content: View { height = heightFor(detent: selectedDetent) } } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + withAnimation { + height = heightFor(detent: .full) + } + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)) { _ in + withAnimation { + height = heightFor(detent: selectedDetent.wrappedValue) + } + } } .padding([.leading, .top, .trailing], isCompact ? 0 : 10) .padding([.bottom], isCompact ? 0 : 50) diff --git a/Sources/ArcGISToolkit/Components/FloatingPanel/FloatingPanelModifier.swift b/Sources/ArcGISToolkit/Components/FloatingPanel/FloatingPanelModifier.swift index 94aa6b4ca..99c43f5f9 100644 --- a/Sources/ArcGISToolkit/Components/FloatingPanel/FloatingPanelModifier.swift +++ b/Sources/ArcGISToolkit/Components/FloatingPanel/FloatingPanelModifier.swift @@ -42,7 +42,7 @@ public extension View { horizontalAlignment: HorizontalAlignment = .trailing, isPresented: Binding = .constant(true), maxWidth: CGFloat = 400, - _ content: @escaping () -> Content + @ViewBuilder _ content: @escaping () -> Content ) -> some View where Content: View { modifier( FloatingPanelModifier( @@ -51,7 +51,7 @@ public extension View { horizontalAlignment: horizontalAlignment, isPresented: isPresented, maxWidth: maxWidth, - panelContent: content() + panelContent: content ) ) } @@ -83,7 +83,7 @@ private struct FloatingPanelModifier: ViewModifier where PanelCont let maxWidth: CGFloat /// The content to be displayed within the floating panel. - let panelContent: PanelContent + let panelContent: () -> PanelContent func body(content: Content) -> some View { content @@ -91,10 +91,9 @@ private struct FloatingPanelModifier: ViewModifier where PanelCont FloatingPanel( backgroundColor: backgroundColor, selectedDetent: selectedDetent, - isPresented: isPresented - ) { - panelContent - } + isPresented: isPresented, + content: panelContent + ) .ignoresSafeArea(.all, edges: .bottom) .frame(maxWidth: isCompact ? .infinity : maxWidth) } diff --git a/Sources/ArcGISToolkit/Components/FloorFilter/FloorFilter.swift b/Sources/ArcGISToolkit/Components/FloorFilter/FloorFilter.swift index fb2c70592..d543f7a53 100644 --- a/Sources/ArcGISToolkit/Components/FloorFilter/FloorFilter.swift +++ b/Sources/ArcGISToolkit/Components/FloorFilter/FloorFilter.swift @@ -17,6 +17,50 @@ import ArcGIS /// The `FloorFilter` component simplifies visualization of GIS data for a specific floor of a /// building in your application. It allows you to filter the floor plan data displayed in your map /// or scene view to a site, a facility (building) in the site, or a floor in the facility. +/// +/// The ArcGIS Maps SDK currently supports filtering a 2D floor aware map based on the sites, +/// buildings, or levels in the map. +/// +/// | iPhone | iPad | +/// | ------ | ---- | +/// | ![image](https://user-images.githubusercontent.com/3998072/202811733-dcd640e9-3b27-43a8-8bec-fd9aeb6798c7.png) | ![image](https://user-images.githubusercontent.com/3998072/202811772-bf6009e7-82ec-459f-86ae-6651f519b2ef.png) | +/// +/// **Features** +/// +/// - Automatically hides the floor browsing view when the associated map or scene is not floor-aware. +/// - Selects the facility in view automatically (can be configured through the +/// ``FloorFilterAutomaticSelectionMode ``). +/// - Shows the selected facility's levels in proper vertical order. +/// - Filters the map/scene content to show the selected level. +/// - Allows browsing the full floor-aware hierarchy of sites, facilities, and levels. +/// - Shows the ground floor of all facilities when there is no active selection. +/// - Updates the visibility of floor levels across all facilities (e.g. if you are looking at floor +/// 3 in building A, floor 3 will be shown in neighboring buildings). +/// - Adjusts layout and presentation to work well regardless of positioning - left/right and +/// top/bottom. +/// - Keeps the selected facility visible in the list while the selection is changing in response to +/// map navigation. +/// +/// **Behavior** +/// +/// | Sites Button | +/// | ----------- | +/// | ![Image of button that displays the list of sites when tapped](https://user-images.githubusercontent.com/3998072/203417956-5161103d-5d29-42fa-8564-de254159efe2.png) | +/// +/// When the Site button is tapped, a prompt opens so the user can select a site and then a +/// facility. After selecting a site and facility, a list of levels is displayed. The list of sites +/// and facilities can be dynamically filtered using the search bar. +/// +/// **Associated Types** +/// +/// Floor Filter has two associated enum type: +/// +/// - ``FloorFilterAutomaticSelectionMode`` +/// - ``FloorFilterSelection`` +/// +/// To see it in action, try out the [Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/Examples/Examples) +/// and refer to [FloorFilterExampleView.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/Examples/Examples/FloorFilterExampleView.swift) +/// in the project. To learn more about using the `FloorFilter` see the [FloorFilter Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/floorfiltertutorial). public struct FloorFilter: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass: UserInterfaceSizeClass? diff --git a/Sources/ArcGISToolkit/Components/FloorFilter/SiteAndFacilitySelector.swift b/Sources/ArcGISToolkit/Components/FloorFilter/SiteAndFacilitySelector.swift index ff78725a2..469896919 100644 --- a/Sources/ArcGISToolkit/Components/FloorFilter/SiteAndFacilitySelector.swift +++ b/Sources/ArcGISToolkit/Components/FloorFilter/SiteAndFacilitySelector.swift @@ -42,7 +42,7 @@ struct SiteAndFacilitySelector: View { facilities: viewModel.facilities, isHidden: isHidden ) - .navigationBarBackButtonHidden() + .navigationBarBackButtonHidden(true) } } .navigationBarTitleDisplayMode(.inline) @@ -100,11 +100,15 @@ struct SiteAndFacilitySelector: View { .searchable( text: $query, placement: .navigationBarDrawer(displayMode: .always), - prompt: "Filter sites" + prompt: String( + localized: "Filter sites", + bundle: .toolkitModule, + comment: "A search field allowing user to filter a list of sites by name." + ) ) .keyboardType(.alphabet) .disableAutocorrection(true) - .navigationTitle("Sites") + .navigationTitle(String(localized: "Sites", bundle: .toolkitModule)) } /// The "All sites" button. @@ -112,7 +116,7 @@ struct SiteAndFacilitySelector: View { /// This button presents the facilities list in a special format where the facilities list /// shows every facility in every site within the floor manager. var allSitesButton: some View { - NavigationLink("All sites") { + NavigationLink { FacilitiesList( usesAllSitesStyling: true, facilities: viewModel.sites.flatMap(\.facilities), @@ -123,6 +127,12 @@ struct SiteAndFacilitySelector: View { CloseButton { isHidden.wrappedValue.toggle() } } } + } label: { + Text( + "All sites", + bundle: .toolkitModule, + comment: "A button allowing users to view a list of all sites defined in a floor aware map." + ) } .buttonStyle(.bordered) .padding([.bottom], horizontalSizeClass == .compact ? 5 : 0) @@ -220,12 +230,18 @@ struct SiteAndFacilitySelector: View { .searchable( text: $query, placement: .navigationBarDrawer(displayMode: .always), - prompt: "Filter facilities" + prompt: String( + localized: "Filter facilities", + bundle: .toolkitModule, + comment: "A search field allowing user to filter a list of facilities by name." + ) ) .keyboardType(.alphabet) .disableAutocorrection(true) .navigationTitle( - usesAllSitesStyling ? "All Sites" : viewModel.selection?.site?.name ?? "Select a facility" + usesAllSitesStyling ? + String(localized: "All Sites", bundle: .toolkitModule) : + viewModel.selection?.site?.name ?? String(localized: "Select a facility", bundle: .toolkitModule) ) } @@ -282,7 +298,7 @@ struct SiteAndFacilitySelector: View { /// Displays text "No matches found". private struct NoMatchesView: View { var body: some View { - Text("No matches found") + Text("No matches found", bundle: .toolkitModule) .frame(maxHeight: .infinity) } } diff --git a/Sources/ArcGISToolkit/Components/OverviewMap.swift b/Sources/ArcGISToolkit/Components/OverviewMap.swift index cd1276eb2..cb4fba45c 100644 --- a/Sources/ArcGISToolkit/Components/OverviewMap.swift +++ b/Sources/ArcGISToolkit/Components/OverviewMap.swift @@ -16,7 +16,36 @@ import Combine import ArcGIS /// `OverviewMap` is a small, secondary `MapView` (sometimes called an "inset map"), superimposed -/// on an existing `GeoView`, which shows the visible extent of that `GeoView`. +/// on an existing `GeoView`, which shows a representation of the current `visibleArea` (for a `MapView`) or `viewpoint` (for a `SceneView`). +/// +/// | MapView | SceneView | +/// | ------- | --------- | +/// | ![OverviewMap - MapView](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/assets/16397058/61415dd8-cdbc-4048-a439-92cf13729e3e) | ![OverviewMap - SceneView](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/assets/16397058/5a201035-c303-48a5-bc95-1324796385ea) | +/// +/// > Note: OverviewMap uses metered ArcGIS basemaps by default, so you will need to configure an API key. See [Security and authentication documentation](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/#api-keys) for more information. +/// +/// **Features** +/// +/// - Displays a representation of the current visible area or viewpoint for a connected `GeoView`. +/// - Supports a configurable scaling factor for setting the overview map's zoom level relative to +/// the connected view. +/// - Supports a configurable symbol for visualizing the current visible area or viewpoint +/// representation (a `FillSymbol` for a connected `MapView`; a `MarkerSymbol` for a connected +/// `SceneView`). +/// - Supports using a custom map in the overview map display. +/// +/// **Behavior** +/// +/// For an `OverviewMap` on a `MapView`, the `MapView`'s `visibleArea` property will be represented in the `OverviewMap` as a polygon, which will rotate as the `MapView` rotates. +/// +/// For an `OverviewMap` on a `SceneView`, the center point of the `SceneView`'s `currentViewpoint` property will be represented in the `OverviewMap` by a point. +/// +/// To use a custom map in the `OverviewMap`, use the `map` argument in either ``OverviewMap/forMapView(with:visibleArea:map:)`` or ``OverviewMap/forSceneView(with:map:)``. +/// +/// To see the `OverviewMap` in action, and for examples of `OverviewMap` customization, check out +/// the [Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/Examples/Examples) +/// and refer to [OverviewMapExampleView.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/Examples/Examples/OverviewMapExampleView.swift) +/// in the project. To learn more about using the `OverviewMap` see the [OverviewMap Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/overviewmaptutorial). public struct OverviewMap: View { /// The `Viewpoint` of the main `GeoView`. let viewpoint: Viewpoint? @@ -40,7 +69,7 @@ public struct OverviewMap: View { /// The user-defined map used in the overview map. Defaults to `nil`. private let userProvidedMap: Map? - /// The actual map used in the overaview map. + /// The actual map used in the overview map. private var effectiveMap: Map { userProvidedMap ?? dataModel.defaultMap } diff --git a/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentList.swift b/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentList.swift index 5b4eb8c33..f39064d7a 100644 --- a/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentList.swift +++ b/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentList.swift @@ -20,13 +20,8 @@ struct AttachmentList: View { var attachmentModels: [AttachmentModel] var body: some View { - VStack(alignment: .leading, spacing: 6) { - ForEach(attachmentModels) { attachmentModel in - AttachmentRow(attachmentModel: attachmentModel) - if attachmentModel != attachmentModels.last { - Divider() - } - } + ForEach(attachmentModels) { attachmentModel in + AttachmentRow(attachmentModel: attachmentModel) } } } @@ -48,7 +43,7 @@ struct AttachmentRow: View { Text(attachmentModel.attachment.name) .lineLimit(1) .truncationMode(.middle) - Text("\(attachmentModel.attachment.size.formatted(.byteCount(style: .file)))") + Text(Int64(attachmentModel.attachment.size), format: .byteCount(style: .file)) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) @@ -85,6 +80,7 @@ struct AttachmentLoadButton: View { Image(systemName: "square.and.arrow.down") .resizable() .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) case .loading: ProgressView() case .loaded: diff --git a/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentsPopupElementModel.swift b/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentModel.swift similarity index 84% rename from Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentsPopupElementModel.swift rename to Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentModel.swift index 4528c6d49..fc31adac9 100644 --- a/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentsPopupElementModel.swift +++ b/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentModel.swift @@ -15,17 +15,11 @@ import SwiftUI import ArcGIS import QuickLook -/// The view model for an `AttachmentPopupElement`. -@MainActor class AttachmentsPopupElementModel: ObservableObject { - /// The array of `AttachmentModels`, one for each popup attachment. - @Published var attachmentModels = [AttachmentModel]() -} - /// A view model representing the combination of a `PopupAttachment` and /// an associated `UIImage` used as a thumbnail. @MainActor class AttachmentModel: ObservableObject { /// The `PopupAttachment`. - @Published var attachment: PopupAttachment + let attachment: PopupAttachment /// The thumbnail representing the attachment. @Published var thumbnail: UIImage? { @@ -69,9 +63,14 @@ import QuickLook Task { loadStatus = .loading try await self.attachment.load() + loadStatus = attachment.loadStatus + if attachment.loadStatus == .failed || attachment.fileURL == nil { + defaultSystemName = "exclamationmark.circle.fill" + return + } let request = QLThumbnailGenerator.Request( - fileAt: attachment.fileURL, + fileAt: attachment.fileURL!, size: CGSize(width: thumbnailSize.width, height: thumbnailSize.height), scale: displayScale, representationTypes: .thumbnail) @@ -83,11 +82,6 @@ import QuickLook if let thumbnail = thumbnail { self.thumbnail = thumbnail.uiImage } - else if self.attachment.loadStatus == .failed { - self.defaultSystemName = "exclamationmark.circle.fill" - } - - self.loadStatus = self.attachment.loadStatus } } } diff --git a/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentPreview.swift b/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentPreview.swift index ae58561ca..cae537fc5 100644 --- a/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentPreview.swift +++ b/Sources/ArcGISToolkit/Components/Popups/Attachments/AttachmentPreview.swift @@ -58,7 +58,7 @@ struct AttachmentPreview: View { .lineLimit(1) .truncationMode(.middle) .padding([.leading, .trailing], 4) - Text("\(attachmentModel.attachment.size.formatted(.byteCount(style: .file)))") + Text(Int64(attachmentModel.attachment.size), format: .byteCount(style: .file)) .foregroundColor(.secondary) .padding([.leading, .trailing], 4) } diff --git a/Sources/ArcGISToolkit/Components/Popups/AttachmentsPopupElementView.swift b/Sources/ArcGISToolkit/Components/Popups/AttachmentsPopupElementView.swift index 5b8df8b97..bdd33eea0 100644 --- a/Sources/ArcGISToolkit/Components/Popups/AttachmentsPopupElementView.swift +++ b/Sources/ArcGISToolkit/Components/Popups/AttachmentsPopupElementView.swift @@ -20,9 +20,6 @@ struct AttachmentsPopupElementView: View { /// The `PopupElement` to display. var popupElement: AttachmentsPopupElement - /// The model for the view. - @StateObject private var viewModel: AttachmentsPopupElementModel - @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.verticalSizeClass) var verticalSizeClass @@ -31,60 +28,66 @@ struct AttachmentsPopupElementView: View { !(horizontalSizeClass == .compact && verticalSizeClass == .regular) } - /// A Boolean value specifying whether the attachments are currently being loaded. - @State var isLoadingAttachments = true + /// The states of loading attachments. + private enum AttachmentLoadingState { + /// Attachments have not been loaded. + case notLoaded + /// Attachments are being loaded. + case loading + /// Attachments have been loaded. + case loaded([AttachmentModel]) + } + + @State private var attachmentLoadingState: AttachmentLoadingState = .notLoaded /// Creates a new `AttachmentsPopupElementView`. /// - Parameter popupElement: The `AttachmentsPopupElement`. init(popupElement: AttachmentsPopupElement) { self.popupElement = popupElement - _viewModel = StateObject( - wrappedValue: AttachmentsPopupElementModel() - ) } - @State var isExpanded: Bool = true + @State private var isExpanded: Bool = true var body: some View { Group { - if isLoadingAttachments { + switch attachmentLoadingState { + case .notLoaded, .loading: ProgressView() .padding() - } else if viewModel.attachmentModels.count > 0 { - DisclosureGroup(isExpanded: $isExpanded) { - Divider() - .padding(.bottom, 4) - switch popupElement.displayType { - case .list: - AttachmentList(attachmentModels: viewModel.attachmentModels) - case .preview: - AttachmentPreview(attachmentModels: viewModel.attachmentModels) - case .auto: - if isRegularWidth { - AttachmentPreview(attachmentModels: viewModel.attachmentModels) - } else { - AttachmentList(attachmentModels: viewModel.attachmentModels) + case .loaded(let attachmentModels): + if !attachmentModels.isEmpty { + DisclosureGroup(isExpanded: $isExpanded) { + switch popupElement.displayType { + case .list: + AttachmentList(attachmentModels: attachmentModels) + case .preview: + AttachmentPreview(attachmentModels: attachmentModels) + case .auto: + if isRegularWidth { + AttachmentPreview(attachmentModels: attachmentModels) + } else { + AttachmentList(attachmentModels: attachmentModels) + } + @unknown default: + EmptyView() } - @unknown default: - EmptyView() - } - } label: { - VStack(alignment: .leading) { + } label: { PopupElementHeader( title: popupElement.displayTitle, description: popupElement.description ) } } - Divider() } } .task { - let attachmentModels = try? await popupElement.attachments.reversed().map { attachment in - AttachmentModel(attachment: attachment) - } - viewModel.attachmentModels.append(contentsOf: attachmentModels ?? []) - isLoadingAttachments = false + guard case .notLoaded = attachmentLoadingState else { return } + attachmentLoadingState = .loading + let attachments = (try? await popupElement.attachments) ?? [] + let attachmentModels = attachments + .reversed() + .map { AttachmentModel(attachment: $0) } + attachmentLoadingState = .loaded(attachmentModels) } } } @@ -92,6 +95,6 @@ struct AttachmentsPopupElementView: View { private extension AttachmentsPopupElement { /// Provides a default title to display if `title` is empty. var displayTitle: String { - title.isEmpty ? "Attachments" : title + title.isEmpty ? String(localized: "Attachments", bundle: .toolkitModule) : title } } diff --git a/Sources/ArcGISToolkit/Components/Popups/FieldsPopupElementView.swift b/Sources/ArcGISToolkit/Components/Popups/FieldsPopupElementView.swift index a27eda597..368d407dd 100644 --- a/Sources/ArcGISToolkit/Components/Popups/FieldsPopupElementView.swift +++ b/Sources/ArcGISToolkit/Components/Popups/FieldsPopupElementView.swift @@ -35,33 +35,14 @@ struct FieldsPopupElementView: View { var body: some View { DisclosureGroup(isExpanded: $isExpanded) { - Divider() - .padding(.bottom, 4) - FieldsList(fields: displayFields) - } label: { - VStack(alignment: .leading) { - PopupElementHeader( - title: popupElement.displayTitle, - description: popupElement.description - ) - } - } - Divider() - } - - /// A view displaying the labels and values. - private struct FieldsList: View { - let fields: [DisplayField] - - var body: some View { - VStack { - ForEach(fields) { field in - FieldRow(field: field) - if field != fields.last { - Divider() - } - } + ForEach(displayFields) { field in + FieldRow(field: field) } + } label: { + PopupElementHeader( + title: popupElement.displayTitle, + description: popupElement.description + ) } } @@ -107,6 +88,6 @@ private struct DisplayField: Hashable, Identifiable { private extension FieldsPopupElement { /// Provides a default title to display if `title` is empty. var displayTitle: String { - title.isEmpty ? "Fields" : title + title.isEmpty ? String(localized: "Fields", bundle: .toolkitModule) : title } } diff --git a/Sources/ArcGISToolkit/Components/Popups/MediaPopupElementView.swift b/Sources/ArcGISToolkit/Components/Popups/MediaPopupElementView.swift index 7c34d9ff4..e40769571 100644 --- a/Sources/ArcGISToolkit/Components/Popups/MediaPopupElementView.swift +++ b/Sources/ArcGISToolkit/Components/Popups/MediaPopupElementView.swift @@ -24,21 +24,16 @@ struct MediaPopupElementView: View { var body: some View { if displayableMediaCount > 0 { DisclosureGroup(isExpanded: $isExpanded) { - Divider() - .padding(.bottom, 4) PopupMediaView( popupMedia: popupElement.media, displayableMediaCount: displayableMediaCount ) } label: { - VStack(alignment: .leading) { - PopupElementHeader( - title: popupElement.displayTitle, - description: popupElement.description - ) - } + PopupElementHeader( + title: popupElement.displayTitle, + description: popupElement.description + ) } - Divider() } } @@ -123,6 +118,6 @@ extension PopupMedia: Identifiable {} private extension MediaPopupElement { /// Provides a default title to display if `title` is empty. var displayTitle: String { - title.isEmpty ? "Media" : title + title.isEmpty ? String(localized: "Media", bundle: .toolkitModule) : title } } diff --git a/Sources/ArcGISToolkit/Components/Popups/PopupElementHeader.swift b/Sources/ArcGISToolkit/Components/Popups/PopupElementHeader.swift index b222ee461..acba45145 100644 --- a/Sources/ArcGISToolkit/Components/Popups/PopupElementHeader.swift +++ b/Sources/ArcGISToolkit/Components/Popups/PopupElementHeader.swift @@ -37,6 +37,8 @@ struct PopupElementHeader: View { .foregroundColor(.secondary) } } - .padding([.bottom], 4) + #if targetEnvironment(macCatalyst) + .padding(.leading, 4) + #endif } } diff --git a/Sources/ArcGISToolkit/Components/Popups/PopupMedia/Images/AsyncImageView.swift b/Sources/ArcGISToolkit/Components/Popups/PopupMedia/Images/AsyncImageView.swift index 20f582693..28caaa98c 100644 --- a/Sources/ArcGISToolkit/Components/Popups/PopupMedia/Images/AsyncImageView.swift +++ b/Sources/ArcGISToolkit/Components/Popups/PopupMedia/Images/AsyncImageView.swift @@ -67,7 +67,10 @@ struct AsyncImageView: View { Image(systemName: "exclamationmark.circle") .aspectRatio(contentMode: .fit) .foregroundColor(.red) - Text("An error occurred loading the image: \(error.localizedDescription).") + Text( + "An error occurred loading the image: \(error.localizedDescription).", + bundle: .toolkitModule + ) } .padding([.top, .bottom]) } diff --git a/Sources/ArcGISToolkit/Components/Popups/PopupMedia/Images/AsyncImageViewModel.swift b/Sources/ArcGISToolkit/Components/Popups/PopupMedia/Images/AsyncImageViewModel.swift index 0936219da..77c02f3b9 100644 --- a/Sources/ArcGISToolkit/Components/Popups/PopupMedia/Images/AsyncImageViewModel.swift +++ b/Sources/ArcGISToolkit/Components/Popups/PopupMedia/Images/AsyncImageViewModel.swift @@ -107,9 +107,10 @@ struct LoadImageError: Error { extension LoadImageError: LocalizedError { public var errorDescription: String? { - return NSLocalizedString( - "The URL could not be reached or did not contain image data", - comment: "No Data" + return String( + localized: "The URL could not be reached or did not contain image data", + bundle: .toolkitModule, + comment: "Description of error thrown when a remote image could not be loaded." ) } } diff --git a/Sources/ArcGISToolkit/Components/Popups/PopupMedia/MediaDetailView.swift b/Sources/ArcGISToolkit/Components/Popups/PopupMedia/MediaDetailView.swift index 56d388c12..4ca716463 100644 --- a/Sources/ArcGISToolkit/Components/Popups/PopupMedia/MediaDetailView.swift +++ b/Sources/ArcGISToolkit/Components/Popups/PopupMedia/MediaDetailView.swift @@ -29,7 +29,7 @@ struct MediaDetailView : View { Button { isShowingDetailView.wrappedValue = false } label: { - Text("Done") + Text("Done", bundle: .toolkitModule) .fontWeight(.semibold) } .padding([.bottom], 4) @@ -59,7 +59,7 @@ struct MediaDetailView : View { } if popupMedia.value?.linkURL != nil { HStack { - Text("Tap on the image for more information.") + Text("Tap on the image for more information.", bundle: .toolkitModule) .font(.subheadline) .foregroundColor(.secondary) Spacer() diff --git a/Sources/ArcGISToolkit/Components/Popups/PopupView.swift b/Sources/ArcGISToolkit/Components/Popups/PopupView.swift index 01e4fdefb..9952d0363 100644 --- a/Sources/ArcGISToolkit/Components/Popups/PopupView.swift +++ b/Sources/ArcGISToolkit/Components/Popups/PopupView.swift @@ -14,7 +14,44 @@ import SwiftUI import ArcGIS -/// A view displaying the elements of a single Popup. +/// The `PopupView` component will display a popup for an individual feature. This includes showing +/// the feature's title, attributes, custom description, media, and attachments. The new online Map +/// Viewer allows users to create a popup definition by assembling a list of “popup elements”. +/// `PopupView` will support the display of popup elements created by the Map Viewer, including: +/// Text, Fields, Attachments, and Media (Images and Charts). +/// +/// Thanks to the backwards compatibility support in the API, it will also work with the legacy +/// popup definitions created by the classic Map Viewer. It does not support editing. +/// +/// | iPhone | iPad | +/// | ------ | ---- | +/// | ![image](https://user-images.githubusercontent.com/3998072/203422507-66b6c6dc-a6c3-4040-b996-9c0da8d4e580.png) | ![image](https://user-images.githubusercontent.com/3998072/203422665-c4759c1f-5863-4251-94df-ed7a06ac7a8f.png) | +/// +/// > Note: Displaying charts in a popup requires running on a device with iOS 16.0 or greater. +/// Displaying charts on Mac requires building your application with Xcode 14.1 or greater +/// and running on a Mac with macOS 13.0 (Ventura) or greater. Also, attachment previews are not +/// available when running on Mac (regardless of Xcode version). +/// +/// **Features** +/// +/// - Display a popup for a feature based on the popup definition defined in a web map. +/// - Supports image refresh intervals on image popup media, refreshing the image at a given +/// interval defined in the popup element. +/// - Supports elements containing Arcade expression and automatically evaluates expressions. +/// - Displays media (images and charts) full-screen. +/// - Supports hyperlinks in text, media, and fields elements. +/// - Fully supports dark mode, as do all Toolkit components. +/// +/// **Behavior** +/// +/// The popup view can display an optional "close" button, allowing the user to dismiss the view. +/// The popup view can be embedded in any type of container view including, as demonstrated in the +/// example, the Toolkit's `FloatingPanel`. +/// +/// To see it in action, try out the [Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/Examples/Examples) +/// and refer to +/// [PopupExampleView.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/Examples/Examples/PopupExampleView.swift) +/// in the project. To learn more about using the `PopupView` see the [PopupView Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/popupviewtutorial). public struct PopupView: View { /// Creates a `PopupView` with the given popup. /// - Parameters @@ -38,7 +75,7 @@ public struct PopupView: View { /// A binding to a Boolean value that determines whether the view is presented. private var isPresented: Binding? - + public var body: some View { VStack(alignment: .leading) { HStack { @@ -56,6 +93,7 @@ public struct PopupView: View { .foregroundColor(.secondary) .padding([.top, .bottom, .trailing], 4) }) + .buttonStyle(.plain) } } Divider() @@ -63,13 +101,20 @@ public struct PopupView: View { if let evaluateExpressionsResult { switch evaluateExpressionsResult { case .success(_): - PopupElementScrollView(popupElements: popup.evaluatedElements) + PopupElementList(popupElements: popup.evaluatedElements) case .failure(let error): - Text("Popup evaluation failed: \(error.localizedDescription)") + Text( + "Popup evaluation failed: \(error.localizedDescription)", + bundle: .toolkitModule, + comment: """ + An error message shown when a popup cannot be displayed. The + variable provides additional data. + """ + ) } } else { VStack(alignment: .center) { - Text("Evaluating popup expressions...") + Text("Evaluating popup expressions…", bundle: .toolkitModule) ProgressView() } .frame(maxWidth: .infinity) @@ -84,28 +129,28 @@ public struct PopupView: View { } } - struct PopupElementScrollView: View { + private struct PopupElementList: View { let popupElements: [PopupElement] var body: some View { - ScrollView { - VStack(alignment: .leading) { - ForEach(popupElements) { popupElement in - switch popupElement { - case let popupElement as AttachmentsPopupElement: - AttachmentsPopupElementView(popupElement: popupElement) - case let popupElement as FieldsPopupElement: - FieldsPopupElementView(popupElement: popupElement) - case let popupElement as MediaPopupElement: - MediaPopupElementView(popupElement: popupElement) - case let popupElement as TextPopupElement: - TextPopupElementView(popupElement: popupElement) - default: - EmptyView() - } + List(popupElements) { popupElement in + Group { + switch popupElement { + case let popupElement as AttachmentsPopupElement: + AttachmentsPopupElementView(popupElement: popupElement) + case let popupElement as FieldsPopupElement: + FieldsPopupElementView(popupElement: popupElement) + case let popupElement as MediaPopupElement: + MediaPopupElementView(popupElement: popupElement) + case let popupElement as TextPopupElement: + TextPopupElementView(popupElement: popupElement) + default: + EmptyView() } } + .listRowInsets(.init(top: 8, leading: 0, bottom: 8, trailing: 0)) } + .listStyle(.plain) } } } diff --git a/Sources/ArcGISToolkit/Components/Popups/TextPopupElementView.swift b/Sources/ArcGISToolkit/Components/Popups/TextPopupElementView.swift index ab104c690..8d7576c50 100644 --- a/Sources/ArcGISToolkit/Components/Popups/TextPopupElementView.swift +++ b/Sources/ArcGISToolkit/Components/Popups/TextPopupElementView.swift @@ -32,7 +32,6 @@ struct TextPopupElementView: View { ProgressView() } } - Divider() } } } diff --git a/Sources/ArcGISToolkit/Components/Scalebar/Scalebar.swift b/Sources/ArcGISToolkit/Components/Scalebar/Scalebar.swift index 25d4f4e0d..a1962807b 100644 --- a/Sources/ArcGISToolkit/Components/Scalebar/Scalebar.swift +++ b/Sources/ArcGISToolkit/Components/Scalebar/Scalebar.swift @@ -14,7 +14,40 @@ import ArcGIS import SwiftUI -/// Displays the current scale on-screen +/// A scalebar displays the representation of an accurate linear measurement on the map. It provides +/// a visual indication through which users can determine the size of features or the distance +/// between features on a map. A scale bar is a line or bar divided into parts. It is labeled with +/// its ground length, usually in multiples of map units, such as tens of kilometers or hundreds of +/// miles. +/// +/// ![An image of a map with a Scalebar overlaid](https://user-images.githubusercontent.com/3998072/203605457-df6f845c-9245-4608-a61e-6d1e2e63a81b.png) +/// +/// **Features** +/// +/// - Can be configured to display as either a bar or line, with different styles for each. +/// - Can be configured with custom colors for fills, lines, shadows, and text. +/// - Can be configured to automatically hide after a pan or zoom operation. +/// - Displays both metric and imperial units. +/// +/// **Behavior** +/// +/// The scalebar uses geodetic calculations to provide accurate measurements for maps of any +/// spatial reference. The measurement is accurate for the center of the map extent being displayed. +/// This means at smaller scales (zoomed way out) you might find it somewhat inaccurate at the +/// extremes of the visible extent. As the map is panned and zoomed, the scalebar automatically +/// grows and shrinks and updates its measurement based on the new map extent. +/// +/// **Associated Types** +/// +/// Scalebar has the following associated types: +/// +/// - ``ScalebarSettings`` +/// - ``ScalebarStyle`` +/// - ``ScalebarUnits`` +/// +/// To see it in action, try out the [Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/Examples/Examples) +/// and refer to [ScalebarExampleView.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/Examples/Examples/ScalebarExampleView.swift) +/// in the project. To learn more about using the `Scalebar` see the [Scalebar Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/scalebartutorial). public struct Scalebar: View { // - MARK: Internal/Private vars @@ -43,8 +76,14 @@ public struct Scalebar: View { return "".size(withAttributes: [.font: Scalebar.font.uiFont]).height } - /// Acts as a data provider of the current scale. - private var viewpoint: Binding + /// The spatial reference to calculate the scale with. + private var spatialReference: SpatialReference? + + /// The units per point to calculate the scale with. + private var unitsPerPoint: Double? + + /// The viewpoint to calculate the scale with. + private var viewpoint: Viewpoint? // - MARK: Internal/Private constants @@ -89,28 +128,27 @@ public struct Scalebar: View { maxWidth: Double, minScale: Double = .zero, settings: ScalebarSettings = ScalebarSettings(), - spatialReference: Binding, + spatialReference: SpatialReference?, style: ScalebarStyle = .alternatingBar, units: ScalebarUnits = NSLocale.current.usesMetricSystem ? .metric : .imperial, - unitsPerPoint: Binding, + unitsPerPoint: Double?, useGeodeticCalculations: Bool = true, - viewpoint: Binding + viewpoint: Viewpoint? ) { _opacity = State(initialValue: settings.autoHide ? .zero : 1) self.settings = settings + self.spatialReference = spatialReference self.style = style + self.unitsPerPoint = unitsPerPoint self.viewpoint = viewpoint _viewModel = StateObject( wrappedValue: ScalebarViewModel( maxWidth, minScale, - spatialReference, style, units, - unitsPerPoint, - useGeodeticCalculations, - viewpoint.wrappedValue + useGeodeticCalculations ) ) } @@ -131,8 +169,11 @@ public struct Scalebar: View { } } .opacity(opacity) - .onChange(of: viewpoint.wrappedValue) { - viewModel.viewpointSubject.send($0) + .onChange(of: spatialReference) { viewModel.update($0) } + .onChange(of: unitsPerPoint) { viewModel.update($0) } + .onChange(of: viewpoint) { + viewModel.update($0) + viewModel.updateScale() if settings.autoHide { withAnimation { opacity = 1 diff --git a/Sources/ArcGISToolkit/Components/Scalebar/ScalebarStyleRenderer.swift b/Sources/ArcGISToolkit/Components/Scalebar/ScalebarStyleRenderer.swift index 6a778debb..211497123 100644 --- a/Sources/ArcGISToolkit/Components/Scalebar/ScalebarStyleRenderer.swift +++ b/Sources/ArcGISToolkit/Components/Scalebar/ScalebarStyleRenderer.swift @@ -94,7 +94,7 @@ extension Scalebar { path.move(to: CGPoint(x: zero, y: zero)) path.addLine(to: CGPoint(x: zero, y: maxY)) - // Horiontal cross bar + // Horizontal cross bar path.move(to: CGPoint(x: zero, y: midY)) path.addLine(to: CGPoint(x: maxX, y: midY)) @@ -125,6 +125,8 @@ extension Scalebar { ) .frame(height: Scalebar.fontHeight) } + // Despite the language direction, this renderer should always place labels on the right. + .environment(\.layoutDirection, .leftToRight) } /// Renders a scalebar with `ScalebarStyle.graduatedLine`. diff --git a/Sources/ArcGISToolkit/Components/Scalebar/ScalebarUnits.swift b/Sources/ArcGISToolkit/Components/Scalebar/ScalebarUnits.swift index 8231ea75b..b72135f82 100644 --- a/Sources/ArcGISToolkit/Components/Scalebar/ScalebarUnits.swift +++ b/Sources/ArcGISToolkit/Components/Scalebar/ScalebarUnits.swift @@ -21,7 +21,7 @@ public enum ScalebarUnits { /// Metric units (meters, etc) case metric - /// Mulitplier options. + /// Multiplier options. /// 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] diff --git a/Sources/ArcGISToolkit/Components/Scalebar/ScalebarViewModel.swift b/Sources/ArcGISToolkit/Components/Scalebar/ScalebarViewModel.swift index 309b97222..797294b6e 100644 --- a/Sources/ArcGISToolkit/Components/Scalebar/ScalebarViewModel.swift +++ b/Sources/ArcGISToolkit/Components/Scalebar/ScalebarViewModel.swift @@ -12,7 +12,6 @@ // limitations under the License. import ArcGIS -import Combine import Foundation import SwiftUI @@ -28,7 +27,7 @@ final class ScalebarViewModel: ObservableObject { // - MARK: Public vars - /// A screen length and displayable string for the equivalent length in the alternate unit. + /// A screen length and displayable localized string for the equivalent length in the alternate unit. var alternateUnit: (screenLength: CGFloat, label: String) { guard let displayUnit = displayUnit else { return (.zero, "") @@ -54,87 +53,49 @@ final class ScalebarViewModel: ObservableObject { value: displayFactor ) let altScreenLength = altMapLength / convertedDisplayFactor - let numberString = numberFormatter.string( - from: NSNumber(value: altMapLength) - ) ?? "" - let bottomUnitsText = " \(altDisplayUnits.abbreviation)" - let label = "\(numberString)\(bottomUnitsText)" + + let measurement = Measurement(value: altMapLength, linearUnit: altDisplayUnits) + let label = measurement.formatted(.scaleMeasurement) + return (altScreenLength, label) } - /// A subject to which viewpoint updates can be submitted. - var viewpointSubject = PassthroughSubject() - // - MARK: Public methods /// A scalebar view model controls the underlying data used to render a scalebar. /// - Parameters: /// - maxWidth: The maximum screen width allotted to the scalebar. /// - minScale: A value of 0 indicates the scalebar segments should always recalculate. - /// - spatialReference: The map's spatial reference. /// - style: The visual appearance of the scalebar. /// - units: The units to be displayed in the scalebar. - /// - unitsPerPoint: The current number of device independent pixels to map display units. /// - useGeodeticCalculations: Determines if a geodesic curve should be used to compute /// the scale. - /// - viewpoint: The map's current viewpoint. init( _ maxWidth: Double, _ minScale: Double, - _ spatialReference: Binding, _ style: ScalebarStyle, _ units: ScalebarUnits, - _ unitsPerPoint: Binding, - _ useGeodeticCalculations: Bool, - _ viewpoint: Viewpoint? + _ useGeodeticCalculations: Bool ) { self.maxWidth = maxWidth self.minScale = minScale - self.spatialReference = spatialReference self.style = style self.units = units - self.unitsPerPoint = unitsPerPoint self.useGeodeticCalculations = useGeodeticCalculations - self.viewpoint = viewpoint - - viewpointSubscription = viewpointSubject - .debounce(for: delay, scheduler: DispatchQueue.main) - .sink(receiveValue: { [weak self] in - guard let self = self else { - return - } - self.viewpoint = $0 - self.updateScaleDisplay() - }) - - updateScaleDisplay() } // - MARK: Private constants - /// The amount of time to wait between value calculations. - private let delay = DispatchQueue.SchedulerTimeType.Stride.seconds(0.05) - /// The curve type to use when performing scale calculations. private let geodeticCurveType: GeometryEngine.GeodeticCurveType = .geodesic /// A `minScale` of 0 means the scalebar segments will always recalculate. private let minScale: Double - /// Converts numbers into a readable format. - private let numberFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - numberFormatter.formatterBehavior = .behavior10_4 - numberFormatter.maximumFractionDigits = 2 - numberFormatter.minimumFractionDigits = 0 - return numberFormatter - }() - /// The visual appearance of the scalebar. private let style: ScalebarStyle - // - MARK: Private vars + // - MARK: Private variables /// Determines the amount of display space to use based on the scalebar style. private var availableLineDisplayLength: CGFloat { @@ -151,31 +112,35 @@ final class ScalebarViewModel: ObservableObject { /// The units to be displayed in the scalebar. private var displayUnit: LinearUnit? = nil + /// A Boolean value indicating whether an initial scale has been calculated. + /// + /// The scale requires 3 values (spatial reference, units per point and a viewpoint) to be + /// calculated. As these values are initially received in a non-deterministic order, this allows + /// a calculation to be attempted upon initial receipt of each of the 3 values. + private var initialScaleWasCalculated = false + /// The length of the line to display in map units. private var lineMapLength: Double = .zero /// The maximum screen width allotted to the scalebar. private var maxWidth: Double - /// The map's spatial reference. - private var spatialReference: Binding + /// The spatial reference to calculate the scale with. + private var spatialReference: SpatialReference? /// Unit of measure in use. private var units: ScalebarUnits - /// The current number of device independent pixels to map display units. - private var unitsPerPoint: Binding + /// The units per point to calculate the scale with. + private var unitsPerPoint: Double? /// Allows a user to toggle geodetic calculations. private var useGeodeticCalculations: Bool - /// Acts as a data provider of the current scale. + /// The viewpoint to calculate the scale with. private var viewpoint: Viewpoint? - /// A subscription to handle listening for viewpoint changes. - private var viewpointSubscription: AnyCancellable? - - // - MARK: Private methods + // - MARK: Methods /// Updates the labels to be displayed by the scalebar. private func updateLabels() { @@ -183,7 +148,7 @@ final class ScalebarViewModel: ObservableObject { // Use a string with at least a few characters in case the number string // only has 1. The dividers will be decimal values and we want to make - // sure they all fit very basic hueristics. + // sure they all fit very basic heuristics. let minSegmentTestString: String if lineMapLength >= 100 { minSegmentTestString = String(Int(lineMapLength)) @@ -215,7 +180,7 @@ final class ScalebarViewModel: ObservableObject { ScalebarLabel( index: -1, xOffset: .zero, - text: "0" + text: NumberFormatter.localizedString(from: 0, number: .decimal) ) ) @@ -223,9 +188,15 @@ final class ScalebarViewModel: ObservableObject { currSegmentX += segmentScreenLength let segmentMapLength = Double((segmentScreenLength * CGFloat(index + 1)) / lineDisplayLength) * lineMapLength - var segmentText = numberFormatter.string(from: NSNumber(value: segmentMapLength)) ?? "" - if index == numSegments - 1, let displayUnit = displayUnit?.abbreviation { - segmentText += " \(displayUnit)" + let segmentText: String + if index == numSegments - 1, let displayUnit { + let measurement = Measurement( + value: segmentMapLength, + linearUnit: displayUnit + ) + segmentText = measurement.formatted(.scaleMeasurement) + } else { + segmentText = segmentMapLength.formatted(.number) } let label = ScalebarLabel( @@ -243,12 +214,31 @@ final class ScalebarViewModel: ObservableObject { } } - /// Updates the information necessary to render a scalebar based off the latest viewpoint and units per - /// point information. - private func updateScaleDisplay() { - guard let spatialReference = spatialReference.wrappedValue, - let unitsPerPoint = unitsPerPoint.wrappedValue, - let viewpoint = viewpoint, + /// Update the stored spatial reference value for use in the next scale calculation. + /// - Parameter spatialReference: The spatial reference to calculate the scale with. + func update(_ spatialReference: SpatialReference?) { + self.spatialReference = spatialReference + if !initialScaleWasCalculated { updateScale() } + } + + /// Updates the stored units per point value for use in the next scale calculation. + /// - Parameter unitsPerPoint: The units per point to calculate the scale with. + func update(_ unitsPerPoint: Double?) { + self.unitsPerPoint = unitsPerPoint + if !initialScaleWasCalculated { updateScale() } + } + + /// Updates the stored units viewpoint value for use in the next scale calculation. + /// - Parameter viewpoint: The viewpoint to calculate the scale with. + func update(_ viewpoint: Viewpoint?) { + self.viewpoint = viewpoint + if !initialScaleWasCalculated { updateScale() } + } + + /// Update the information necessary to render a scalebar based off the stored viewpoint, units + /// per point and spatial reference values. + func updateScale() { + guard let spatialReference, let unitsPerPoint, let viewpoint, minScale <= 0 || viewpoint.targetScale < minScale else { return } @@ -326,6 +316,20 @@ final class ScalebarViewModel: ObservableObject { self.displayUnit = displayUnit self.lineMapLength = lineMapLength + initialScaleWasCalculated = true + updateLabels() } } + +private extension Measurement where UnitType == UnitLength { + init(value: Double, linearUnit: LinearUnit) { + self.init(value: value, unit: .fromLinearUnit(linearUnit)) + } +} + +private extension FormatStyle where Self == Measurement.FormatStyle { + static var scaleMeasurement: Self { + .measurement(width: .abbreviated, usage: .asProvided) + } +} diff --git a/Sources/ArcGISToolkit/Components/Search/SearchSuggestion.swift b/Sources/ArcGISToolkit/Components/Search/SearchSuggestion.swift index 49d9fe39e..7cbc76e12 100644 --- a/Sources/ArcGISToolkit/Components/Search/SearchSuggestion.swift +++ b/Sources/ArcGISToolkit/Components/Search/SearchSuggestion.swift @@ -73,7 +73,7 @@ extension SearchSuggestion: Hashable { public func hash(into hasher: inout Hasher) { // Note: We're not hashing `suggestResult` as `SearchSuggestion` is // created from a `SuggestResult` and `suggestResult` will be different - // for two sepate geocode operations even though they represent the + // for two separate geocode operations even though they represent the // same suggestion. hasher.combine(id) } diff --git a/Sources/ArcGISToolkit/Components/Search/SearchView.swift b/Sources/ArcGISToolkit/Components/Search/SearchView.swift index a8ac17e17..d1e5d8802 100644 --- a/Sources/ArcGISToolkit/Components/Search/SearchView.swift +++ b/Sources/ArcGISToolkit/Components/Search/SearchView.swift @@ -14,7 +14,51 @@ import SwiftUI import ArcGIS -/// `SearchView` presents a search experience, powered by an underlying `SearchViewModel`. +/// `SearchView` enables searching using one or more locators, with support for suggestions, +/// automatic zooming, and custom search sources. +/// +/// | iPhone | iPad | +/// | ------ | ---- | +/// | ![image](https://user-images.githubusercontent.com/3998072/203608897-5f3bf34a-0931-4d11-b3fc-18a5dd07131a.png) | ![image](https://user-images.githubusercontent.com/3998072/203608708-45a0096c-a8d6-457c-9ee1-8cdb9e5bb15a.png) | +/// +/// **Features** +/// +/// - Updates search suggestions as you type. +/// - Supports using the Esri world geocoder or any other ArcGIS locators. +/// - Supports searching using custom search sources. +/// - Allows for customization of the display of search results. +/// - Allows you to repeat a search within a defined area, and displays a button to enable that +/// search when the view's viewpoint changes. +/// +/// `SearchView` uses search sources which implement the ``SearchSource`` protocol. +/// +/// `SearchView` provides the following search sources: +/// +/// - ``LocatorSearchSource`` +/// - ``SmartLocatorSearchSource`` +/// +/// `SearchView` provides several instance methods, allowing customization and additional search +/// behaviors (such as displaying a "Repeat search here" button). See "Instance Methods" below. +/// +/// **Behavior** +/// +/// The `SearchView` will display the results list view at half height, exposing a portion of the +/// underlying map below the list, in compact environments. The user can hide or show the result +/// list after searching by clicking on the up/down chevron symbol on the right of the search bar. +/// +/// **Associated Types** +/// +/// `SearchView` has the following associated types: +/// +/// - ``SearchField`` +/// - ``SearchResult`` +/// - ``SearchSuggestion`` +/// - ``SearchOutcome`` +/// - ``SearchResultMode`` +/// +/// To see the `SearchView` in action, and for examples of `Search` customization, check out the [Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/Examples/Examples) +/// and refer to [SearchExampleView.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/Examples/Examples/SearchExampleView.swift) +/// in the project. To learn more about using the `SearchView` see the [SearchView Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/searchviewtutorial). public struct SearchView: View { /// Creates a `SearchView`. /// - Parameters: @@ -88,7 +132,7 @@ public struct SearchView: View { /// The string shown in the search view when no user query is entered. /// Defaults to "Find a place or address". Note: this is set using the /// `prompt` modifier. - private var prompt = "Find a place or address" + private var prompt = String(localized: "Find a place or address", bundle: .toolkitModule) /// Determines whether a built-in result view will be shown. Defaults to `true`. /// If `false`, the result display/selection list is not shown. Set to false if you want to hide the results @@ -99,7 +143,11 @@ public struct SearchView: View { /// Message to show when there are no results or suggestions. Defaults to "No results found". /// Note: this is set using the `noResultsMessage` modifier. - private var noResultsMessage = "No results found" + private var noResultsMessage = String( + localized: "No results found", + bundle: .toolkitModule, + comment: "A message to show when there are no results or suggestions." + ) /// The width of the search bar, taking into account the horizontal and vertical size classes /// of the device. This will cause the search field to display full-width on an iPhone in portrait @@ -118,6 +166,9 @@ public struct SearchView: View { /// Determines whether the results lists are displayed. @State private var isResultListHidden = false + /// A Boolean value indicating whether the search field is focused or not. + @FocusState private var searchFieldIsFocused: Bool + public var body: some View { VStack { GeometryReader { geometry in @@ -127,6 +178,7 @@ public struct SearchView: View { SearchField( query: $viewModel.currentQuery, prompt: prompt, + isFocused: $searchFieldIsFocused, isResultsButtonHidden: !enableResultListView, isResultListHidden: $isResultListHidden ) @@ -164,8 +216,17 @@ public struct SearchView: View { } Spacer() if viewModel.isEligibleForRequery { - Button("Repeat Search Here") { + Button { viewModel.repeatSearch() + } label: { + Text( + "Repeat Search Here", + bundle: .toolkitModule, + comment: """ + A button to show when a user has panned the map away from the + original search location. + """ + ) } .esriBorder() } @@ -175,6 +236,12 @@ public struct SearchView: View { onQueryChangedAction?(viewModel.currentQuery) viewModel.updateSuggestions() } + .onChange(of: viewModel.selectedResult) { _ in + searchFieldIsFocused = false + } + .onChange(of: viewModel.currentSuggestion) { _ in + searchFieldIsFocused = false + } .onChange(of: geoViewExtent) { _ in viewModel.geoViewExtent = geoViewExtent } @@ -416,7 +483,8 @@ extension ResultRow { Image( uiImage: UIImage( named: "pin", - in: Bundle.module, with: nil + in: .toolkitModule, + with: nil )! ) ) diff --git a/Sources/ArcGISToolkit/Components/Search/SearchViewModel.swift b/Sources/ArcGISToolkit/Components/Search/SearchViewModel.swift index ef5921549..e16516ffe 100644 --- a/Sources/ArcGISToolkit/Components/Search/SearchViewModel.swift +++ b/Sources/ArcGISToolkit/Components/Search/SearchViewModel.swift @@ -435,6 +435,6 @@ private extension Symbol { extension UIImage { static var mapPin: UIImage { - return UIImage(named: "MapPin", in: Bundle.module, with: nil)! + return UIImage(named: "MapPin", in: .toolkitModule, with: nil)! } } diff --git a/Sources/ArcGISToolkit/Components/Search/SmartLocatorSearchSource.swift b/Sources/ArcGISToolkit/Components/Search/SmartLocatorSearchSource.swift index 9d75feffe..e76325cde 100644 --- a/Sources/ArcGISToolkit/Components/Search/SmartLocatorSearchSource.swift +++ b/Sources/ArcGISToolkit/Components/Search/SmartLocatorSearchSource.swift @@ -63,7 +63,7 @@ public class SmartLocatorSearchSource: LocatorSearchSource { searchArea: Geometry? = nil, preferredSearchLocation: Point? = nil ) async throws -> [SearchResult] { - // First, peform super class search. + // First, perform super class search. var results = try await super.search( query, searchArea: searchArea, diff --git a/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTrace.swift b/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTrace.swift index 7440052b8..c5619eaa8 100644 --- a/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTrace.swift +++ b/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTrace.swift @@ -14,6 +14,52 @@ import ArcGIS import SwiftUI +/// `UtilityNetworkTrace` runs traces on a webmap published with a utility network and trace configurations. +/// +/// | iPhone | iPad | +/// | ------ | ---- | +/// | ![image](https://user-images.githubusercontent.com/3998072/204343568-a236ae0d-6b70-4175-a70c-41c902123ea1.png) | ![image](https://user-images.githubusercontent.com/3998072/204344567-c86b3a49-6109-4333-8993-7fdc74f2b35d.png) | +/// +/// **Features** +/// +/// The utility network trace tool displays a list of named trace configurations defined for utility +/// networks in a web map. It enables users to add starting points and perform trace analysis from +/// the selected named trace configuration. +/// +/// A named trace configuration defined for a utility network in a webmap comprises the parameters +/// used for a utility network trace. +/// +/// **Behavior** +/// +/// The tool allows users to: +/// +/// - Choose between multiple networks (if more than one is defined in a webmap). +/// - Choose between named trace configurations: +/// ![image](https://user-images.githubusercontent.com/3998072/204346359-419b0056-3a30-4120-9b47-c68513abde42.png) +/// - Add trace starting points either programmatically or by tapping on a map view, then use the +/// inspection view to narrow the selection: +/// ![image](https://user-images.githubusercontent.com/3998072/204346273-38374067-a0b8-4db4-8e40-62b38e1603c8.png) +/// - View trace results: +/// +/// | iPhone | iPad | +/// | ------ | ---- | +/// | ![image](https://user-images.githubusercontent.com/3998072/204343941-91775a25-8dc0-4866-8273-0d4bfaa91aeb.png) | ![image](https://user-images.githubusercontent.com/3998072/204344435-173fbf34-59d6-4a0f-84bf-30ed5de3572e.png) | +/// +/// - Run multiple trace scenarios, then use color and name to compare results: +/// ![image](https://user-images.githubusercontent.com/3998072/204346039-038ba4fa-201a-428c-ae84-be8f10c91cf7.png) +/// +/// - See user-friendly warnings to help avoid common mistakes, including specifying too many +/// starting points or running the same trace configuration multiple times. +/// +/// **Associated Types** +/// +/// `UtilityNetworkTrace` has the following associated type: +/// +/// - ``UtilityNetworkTraceStartingPoint`` +/// +/// To see the `UtilityNetworkTrace` in action, check out the [Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/Examples/Examples) +/// and refer to [UtilityNetworkTraceExampleView.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/Examples/Examples/UtilityNetworkTraceExampleView.swift) +/// in the project. To learn more about using the `UtilityNetworkTrace` see the [UtilityNetworkTrace Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/utilitynetworktracetutorial). public struct UtilityNetworkTrace: View { /// The proxy to provide access to map view operations. private var mapViewProxy: MapViewProxy? @@ -97,7 +143,7 @@ public struct UtilityNetworkTrace: View { /// Allows the user to switch between the trace creation and viewing tabs. private var activityPicker: some View { Picker( - "Mode", + String.modePickerTitle, selection: Binding( get: { switch currentActivity { @@ -111,8 +157,8 @@ public struct UtilityNetworkTrace: View { } ) ) { - Text("New trace").tag(UserActivity.creatingTrace(nil)) - Text("Results").tag(UserActivity.viewingTraces(nil)) + Text(String.newTraceOptionLabel).tag(UserActivity.creatingTrace(nil)) + Text(String.resultsOptionLabel).tag(UserActivity.viewingTraces(nil)) } .pickerStyle(.segmented) .padding() @@ -120,11 +166,9 @@ public struct UtilityNetworkTrace: View { /// Allows the user to cancel out of selecting a new starting point. private var cancelAddStartingPoints: some View { - Button(role: .destructive) { + Button(String.cancelStartingPointSelection, role: .destructive) { currentActivity = .creatingTrace(nil) activeDetent = .half - } label: { - Text("Cancel starting point selection") } .buttonStyle(.bordered) } @@ -133,7 +177,7 @@ public struct UtilityNetworkTrace: View { @ViewBuilder private var assetGroupDetail: some View { if let assetGroupName = selectedAssetGroupName, let assetTypeGroups = viewModel.selectedTrace?.elementsByType(inGroupNamed: assetGroupName) { - makeBackButton(title: featureResultsTitle) { + makeBackButton(title: .featureResultsTitle) { currentActivity = .viewingTraces(.viewingFeatureResults) } makeDetailSectionHeader(title: assetGroupName) @@ -159,7 +203,11 @@ public struct UtilityNetworkTrace: View { } } label: { Label { - Text("Object ID \(element.objectID, format: .number.grouping(.never))") + Text( + "Object ID: \(element.objectID, format: .number.grouping(.never))", + bundle: .toolkitModule, + comment: "A string identifying a utility network object." + ) } icon: { Image(systemName: "scope") } @@ -177,7 +225,7 @@ public struct UtilityNetworkTrace: View { /// Displays the list of available named trace configurations. @ViewBuilder private var configurationsList: some View { if viewModel.configurations.isEmpty { - Text("No configurations available") + Text(String.noConfigurationsAvailable) } else { ForEach(viewModel.configurations, id: \.name) { configuration in Button { @@ -208,9 +256,9 @@ public struct UtilityNetworkTrace: View { @ViewBuilder private var newTraceTab: some View { List { if viewModel.networks.count > 1 { - Section("Network") { + Section(String.networkSectionLabel) { DisclosureGroup( - viewModel.network?.name ?? "None selected", + viewModel.network?.name ?? .noneSelected, isExpanded: Binding( get: { isFocused(traceCreationActivity: .viewingNetworkOptions) }, set: { currentActivity = .creatingTrace($0 ? .viewingNetworkOptions : nil) } @@ -220,67 +268,66 @@ public struct UtilityNetworkTrace: View { } } } - Section("Trace Configuration") { + Section(String.traceConfigurationSectionLabel) { DisclosureGroup( - viewModel.pendingTrace.configuration?.name ?? "None selected", - isExpanded: Binding( - get: { isFocused(traceCreationActivity: .viewingTraceConfigurations) }, - set: { currentActivity = .creatingTrace($0 ? .viewingTraceConfigurations : nil) } - ) + viewModel.pendingTrace.configuration?.name ?? .noneSelected, + isExpanded: Binding { + isFocused(traceCreationActivity: .viewingTraceConfigurations) + } set: { + currentActivity = .creatingTrace($0 ? .viewingTraceConfigurations : nil) + } ) { configurationsList } } - Section(startingPointsTitle) { - Button { + Section(String.startingPointsTitle) { + Button(String.addNewButtonLabel) { currentActivity = .creatingTrace(.addingStartingPoints) activeDetent = .summary - } label: { - Text("Add new") } if !viewModel.pendingTrace.startingPoints.isEmpty { DisclosureGroup( - "\(viewModel.pendingTrace.startingPoints.count) selected", - isExpanded: Binding( - get: { isFocused(traceCreationActivity: .viewingStartingPoints) }, - set: { currentActivity = .creatingTrace($0 ? .viewingStartingPoints : nil) } - ) + isExpanded: Binding { + isFocused(traceCreationActivity: .viewingStartingPoints) + } set: { + currentActivity = .creatingTrace($0 ? .viewingStartingPoints : nil) + } ) { startingPointsList + } label: { + Text( + "\(viewModel.pendingTrace.startingPoints.count) selected", + bundle: .toolkitModule, + comment: "A label declaring the number of starting points selected for a utility network trace." + ) } } } Section { DisclosureGroup( - "Advanced Options", - isExpanded: Binding( - get: { isFocused(traceCreationActivity: .viewingAdvancedOptions) }, - set: { currentActivity = .creatingTrace($0 ? .viewingAdvancedOptions : nil) } - ) - ) { - HStack { - Text("Name") - Spacer() - TextField( - "Name", - text: $viewModel.pendingTrace.name - ) - .onSubmit { - viewModel.pendingTrace.userDidSpecifyName = true + String.advancedOptionsHeaderLabel, + isExpanded: Binding { + isFocused(traceCreationActivity: .viewingAdvancedOptions) + } set: { + currentActivity = .creatingTrace($0 ? .viewingAdvancedOptions : nil) + }, content: { + HStack { + Text(String.nameLabel) + Spacer() + TextField(String.nameLabel, text: $viewModel.pendingTrace.name) + .onSubmit { + viewModel.pendingTrace.userDidSpecifyName = true + } + .multilineTextAlignment(.trailing) + .foregroundColor(.blue) } - .multilineTextAlignment(.trailing) - .foregroundColor(.blue) - } - ColorPicker(selection: $viewModel.pendingTrace.color) { - Text("Color") - } - Toggle(isOn: $shouldZoomOnTraceCompletion) { - Text("Zoom to result") + ColorPicker(String.colorLabel, selection: $viewModel.pendingTrace.color) + Toggle(String.zoomToResult, isOn: $shouldZoomOnTraceCompletion) } - } + ) } } - Button { + Button(String.traceButtonLabel) { Task { if await viewModel.trace() { currentActivity = .viewingTraces(nil) @@ -290,8 +337,6 @@ public struct UtilityNetworkTrace: View { } } } - } label: { - Text("Trace") } .buttonStyle(.bordered) .disabled(!viewModel.canRunTrace) @@ -328,7 +373,7 @@ public struct UtilityNetworkTrace: View { if let selectedTrace = viewModel.selectedTrace { Menu(selectedTrace.name) { if let resultExtent = selectedTrace.resultExtent { - Button("Zoom To") { + Button(String.zoomToButtonLabel) { let newViewpoint = Viewpoint(boundingGeometry: resultExtent) if let mapViewProxy { Task { await mapViewProxy.setViewpoint(newViewpoint, duration: nil) } @@ -337,7 +382,7 @@ public struct UtilityNetworkTrace: View { } } } - Button("Delete", role: .destructive) { + Button(String.deleteButtonLabel) { if viewModel.completedTraces.count == 1 { currentActivity = .creatingTrace(nil) } @@ -348,13 +393,13 @@ public struct UtilityNetworkTrace: View { } if activeDetent != .summary { List { - Section(featureResultsTitle) { + Section(String.featureResultsTitle) { DisclosureGroup( - "(\(viewModel.selectedTrace?.elementResults.count ?? 0))", - isExpanded: Binding( - get: { isFocused(traceViewingActivity: .viewingFeatureResults) }, - set: { currentActivity = .viewingTraces($0 ? .viewingFeatureResults : nil) } - ) + isExpanded: Binding { + isFocused(traceViewingActivity: .viewingFeatureResults) + } set: { + currentActivity = .viewingTraces($0 ? .viewingFeatureResults : nil) + } ) { if let selectedTrace = viewModel.selectedTrace { ForEach(selectedTrace.assetGroupNames.sorted(), id: \.self) { assetGroupName in @@ -370,15 +415,17 @@ public struct UtilityNetworkTrace: View { } } } + } label: { + Text(viewModel.selectedTrace?.elementResults.count ?? 0, format: .number) } } - Section("Function Results") { + Section(String.functionResultsSectionTitle) { DisclosureGroup( - "(\(viewModel.selectedTrace?.utilityFunctionTraceResult?.functionOutputs.count ?? 0))", - isExpanded: Binding( - get: { isFocused(traceViewingActivity: .viewingFunctionResults) }, - set: { currentActivity = .viewingTraces($0 ? .viewingFunctionResults : nil) } - ) + isExpanded: Binding { + isFocused(traceViewingActivity: .viewingFunctionResults) + } set: { + currentActivity = .viewingTraces($0 ? .viewingFunctionResults : nil) + } ) { if let selectedTrace = viewModel.selectedTrace { ForEach(selectedTrace.functionOutputs, id: \.objectID) { item in @@ -389,64 +436,72 @@ public struct UtilityNetworkTrace: View { Text(item.function.functionType.title) .font(.caption) .foregroundColor(.secondary) - Text((item.result as? Double).map { "\($0)" } ?? "N/A") + if let result = item.result as? Double { + Text(result, format: .number) + } else { + Text( + "Not Available", + bundle: .toolkitModule, + comment: "A trace function output result is not available." + ) + } } } } } + } label: { + Text(viewModel.selectedTrace?.utilityFunctionTraceResult?.functionOutputs.count ?? 0, format: .number) } } Section { DisclosureGroup( - "Advanced Options", - isExpanded: Binding( - get: { isFocused(traceViewingActivity: .viewingAdvancedOptions) }, - set: { currentActivity = .viewingTraces($0 ? .viewingAdvancedOptions : nil) } - ) + String.advancedOptionsHeaderLabel, + isExpanded: Binding { + isFocused(traceViewingActivity: .viewingAdvancedOptions) + } set: { + currentActivity = .viewingTraces($0 ? .viewingAdvancedOptions : nil) + } ) { ColorPicker( - selection: Binding(get: { + String.colorLabel, + selection: Binding { viewModel.selectedTrace?.color ?? Color.clear - }, set: { newValue in + } set: { newValue in if var trace = viewModel.selectedTrace { trace.color = newValue viewModel.update(completedTrace: trace) } - }) - ) { - Text("Color") - } + } + ) } } } .padding([.vertical], 2) - Button("Clear All Results", role: .destructive) { + Button(String.clearAllResultsButtonLabel, role: .destructive) { isShowingClearAllResultsConfirmationDialog = true } .buttonStyle(.bordered) .confirmationDialog( - "Clear all results?", + String.clearAllResultsQuestion, isPresented: $isShowingClearAllResultsConfirmationDialog ) { - Button(role: .destructive) { + Button(String.clearAllResultsButtonLabel, role: .destructive) { viewModel.deleteAllTraces() currentActivity = .creatingTrace(nil) - } label: { - Text("Clear All Results") } } message: { - Text("All the trace inputs and results will be lost.") + Text(String.clearAllResultsMessage) } } } /// Displays information about a chosen starting point. @ViewBuilder private var startingPointDetail: some View { - makeBackButton(title: startingPointsTitle) { + makeBackButton(title: .startingPointsTitle) { currentActivity = .creatingTrace(.viewingStartingPoints) } - Menu(selectedStartingPoint?.utilityElement?.assetType.name ?? "Unnamed Asset Type") { - Button("Zoom To") { + Menu(selectedStartingPoint?.utilityElement?.assetType.name ?? String.unnamedAssetType) { + Button(String.zoomToButtonLabel) { if let selectedStartingPoint = selectedStartingPoint, let extent = selectedStartingPoint.geoElement.geometry?.extent { let newViewpoint = Viewpoint(boundingGeometry: extent) @@ -457,7 +512,7 @@ public struct UtilityNetworkTrace: View { } } } - Button("Delete", role: .destructive) { + Button(String.deleteButtonLabel, role: .destructive) { if let startingPoint = selectedStartingPoint { viewModel.deleteStartingPoint(startingPoint) currentActivity = .creatingTrace(.viewingStartingPoints) @@ -467,31 +522,33 @@ public struct UtilityNetworkTrace: View { .font(.title3) List { if selectedStartingPoint?.utilityElement?.networkSource.kind == .edge { - Section("Fraction Along Edge") { - Slider(value: Binding(get: { - viewModel.pendingTrace.startingPoints.first { - $0 == selectedStartingPoint - }?.utilityElement?.fractionAlongEdge ?? .zero - }, set: { newValue in - if let selectedStartingPoint = selectedStartingPoint { - viewModel.setFractionAlongEdgeFor( - startingPoint: selectedStartingPoint, - to: newValue - ) + Section(String.fractionAlongEdgeSectionTitle) { + Slider( + value: Binding { + viewModel.pendingTrace.startingPoints.first { + $0 == selectedStartingPoint + }?.utilityElement?.fractionAlongEdge ?? .zero + } set: { newValue in + if let selectedStartingPoint = selectedStartingPoint { + viewModel.setFractionAlongEdgeFor( + startingPoint: selectedStartingPoint, + to: newValue + ) + } } - })) + ) } } else if selectedStartingPoint?.utilityElement?.networkSource.kind == .junction && selectedStartingPoint?.utilityElement?.terminal != nil && !(selectedStartingPoint?.utilityElement?.assetType.terminalConfiguration?.terminals.isEmpty ?? true) { Section { Picker( - "Terminal Configuration", - selection: Binding(get: { + String.terminalConfigurationPickerTitle, + selection: Binding { selectedStartingPoint!.utilityElement!.terminal! - }, set: { newValue in + } set: { newValue in viewModel.setTerminalConfigurationFor(startingPoint: selectedStartingPoint!, to: newValue) - }) + } ) { ForEach(viewModel.pendingTrace.startingPoints.first { $0 == selectedStartingPoint @@ -502,7 +559,7 @@ public struct UtilityNetworkTrace: View { .foregroundColor(.blue) } } - Section("Attributes") { + Section(String.attributesSectionTitle) { ForEach(Array(selectedStartingPoint!.geoElement.attributes.sorted(by: { $0.key < $1.key})), id: \.key) { item in HStack{ Text(item.key) @@ -655,9 +712,13 @@ public struct UtilityNetworkTrace: View { // MARK: Computed Properties /// Indicates the number of the trace currently being viewed out the total number of traces. - private var currentTraceLabel: LocalizedStringKey { + private var currentTraceLabel: String { guard let index = viewModel.selectedTraceIndex else { return "Error" } - return "Trace \(index+1) of \(viewModel.completedTraces.count)" + return String( + localized: "Trace \(index+1, specifier: "%lld") of \(viewModel.completedTraces.count, specifier: "%lld")", + bundle: .toolkitModule, + comment: "A label indicating the index of the trace being viewed out of the total number of traces completed." + ) } /// The name of the selected utility element asset group. @@ -725,10 +786,144 @@ public struct UtilityNetworkTrace: View { .lineLimit(1) .frame(maxWidth: .infinity, alignment: .center) } +} + +private extension String { + static let addNewButtonLabel = String( + localized: "Add new", + bundle: .toolkitModule, + comment: "A button to add new utility trace starting points." + ) + + static let advancedOptionsHeaderLabel = String( + localized: "Advanced Options", + bundle: .toolkitModule, + comment: "A section header for advanced options." + ) + + static let attributesSectionTitle = String( + localized: "Attributes", + bundle: .toolkitModule + ) + + static let cancelStartingPointSelection = String( + localized: "Cancel starting point selection", + bundle: .toolkitModule + ) + + static let clearAllResultsButtonLabel = String( + localized: "Clear All Results", + bundle: .toolkitModule + ) + + static let clearAllResultsQuestion = String( + localized: "Clear all results?", + bundle: .toolkitModule + ) + + static let clearAllResultsMessage = String( + localized: "All the trace inputs and results will be lost.", + bundle: .toolkitModule, + comment: "A message describing the outcome of clearing all utility network trace results." + ) + + static let colorLabel = String( + localized: "Color", + bundle: .toolkitModule + ) + + static let deleteButtonLabel = String( + localized: "Delete", + bundle: .toolkitModule + ) /// Title for the feature results section - private let featureResultsTitle = "Feature Results" + static let featureResultsTitle = String( + localized: "Feature Results", + bundle: .toolkitModule + ) + + static let fractionAlongEdgeSectionTitle = String( + localized: "Fraction Along Edge", + bundle: .toolkitModule + ) + + static let functionResultsSectionTitle = String( + localized: "Function Results", + bundle: .toolkitModule + ) + + static let modePickerTitle = String( + localized: "Mode", + bundle: .toolkitModule + ) + + static let nameLabel = String( + localized: "Name", + bundle: .toolkitModule + ) + + static let networkSectionLabel = String( + localized: "Network", + bundle: .toolkitModule + ) + + static let newTraceOptionLabel = String( + localized: "New trace", + bundle: .toolkitModule + ) + + static let noConfigurationsAvailable = String( + localized: "No configurations available", + bundle: .toolkitModule + ) + + static let noneSelected = String( + localized: "None selected", + bundle: .toolkitModule + ) + + static let resultsOptionLabel = String( + localized: "Results", + bundle: .toolkitModule + ) /// Title for the starting points section - private let startingPointsTitle = "Starting Points" + static let startingPointsTitle = String( + localized: "Starting Points", + bundle: .toolkitModule + ) + + static let terminalConfigurationPickerTitle = String( + localized: "Terminal Configuration", + bundle: .toolkitModule + ) + + static let traceButtonLabel = String( + localized: "Trace", + bundle: .toolkitModule + ) + + static let traceConfigurationSectionLabel = String( + localized: "Trace Configuration", + bundle: .toolkitModule + ) + + static let unnamedAssetType = String( + localized: "Unnamed Asset Type", + bundle: .toolkitModule, + comment: "A label to use in place of a utility element asset type name." + ) + + static let zoomToButtonLabel = String( + localized: "Zoom To", + bundle: .toolkitModule, + comment: "A button to change the map to the extent of the selected trace." + ) + + static let zoomToResult = String( + localized: "Zoom to result", + bundle: .toolkitModule, + comment: "A user option specifying that a map should automatically change to show completed trace results." + ) } diff --git a/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTraceUserAlert.swift b/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTraceUserAlert.swift index cba8cb974..913bd039b 100644 --- a/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTraceUserAlert.swift +++ b/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTraceUserAlert.swift @@ -16,7 +16,7 @@ import SwiftUI /// A user presentable alert. struct UtilityNetworkTraceUserAlert { /// Title of the alert. - var title: String = "Error" + var title: String = String(localized: "Error", bundle: .toolkitModule) /// Description of the alert. var description: String @@ -24,3 +24,63 @@ struct UtilityNetworkTraceUserAlert { /// An additional action to be taken on the alert. var button: Button? } + +extension UtilityNetworkTraceUserAlert { + static var startingLocationNotDefined: Self { + .init( + description: String( + localized: "Please set at least 1 starting location.", + bundle: .toolkitModule + ) + ) + } + + static var startingLocationsNotDefined: Self { + .init( + description: String( + localized: "Please set at least 2 starting locations.", + bundle: .toolkitModule + ) + ) + } + + static var duplicateStartingPoint: Self { + .init( + title: String( + localized: "Failed to set starting point", + bundle: .toolkitModule + ), + description: String( + localized: "Duplicate starting points cannot be added.", + bundle: .toolkitModule + ) + ) + } + + static var noTraceTypesFound: Self { + .init( + description: String( + localized: "No trace types found.", + bundle: .toolkitModule + ) + ) + } + + static var noUtilityNetworksFound: Self { + .init( + description: String( + localized: "No utility networks found.", + bundle: .toolkitModule + ) + ) + } + + static var unableToIdentifyElement: Self { + .init( + description: String( + localized: "Element could not be identified.", + bundle: .toolkitModule + ) + ) + } +} diff --git a/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTraceViewModel.swift b/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTraceViewModel.swift index d5ab73179..6a7c38987 100644 --- a/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTraceViewModel.swift +++ b/Sources/ArcGISToolkit/Components/UtilityNetworkTrace/UtilityNetworkTraceViewModel.swift @@ -25,7 +25,7 @@ import SwiftUI @Published private(set) var configurations = [UtilityNamedTraceConfiguration]() { didSet { if configurations.isEmpty { - userAlert = .init(description: "No trace types found.") + userAlert = .noTraceTypesFound } } } @@ -193,7 +193,7 @@ import SwiftUI network = map.utilityNetworks.first configurations = await utilityNamedTraceConfigurations(from: map) if map.utilityNetworks.isEmpty { - userAlert = .init(description: "No utility networks found.") + userAlert = .noUtilityNetworksFound } await addExternalStartingPoints() } @@ -272,7 +272,7 @@ import SwiftUI func processAndAdd(startingPoint: UtilityNetworkTraceStartingPoint) async { guard let feature = startingPoint.geoElement as? ArcGISFeature, let globalid = feature.globalID else { - userAlert = .init(description: "Element could not be identified") + userAlert = .unableToIdentifyElement return } @@ -280,10 +280,7 @@ import SwiftUI guard !pendingTrace.startingPoints.contains(where: { startingPoint in return startingPoint.utilityElement?.globalID == globalid }) else { - userAlert = .init( - title: "Failed to set starting point", - description: "Duplicate starting points cannot be added" - ) + userAlert = .duplicateStartingPoint return } @@ -347,10 +344,13 @@ import SwiftUI guard let configuration = pendingTrace.configuration, let network = network else { return false } - let minStartingPoints = configuration.minimumStartingLocations == .one ? 1 : 2 + if pendingTrace.startingPoints.isEmpty && configuration.minimumStartingLocations == .one { + userAlert = .startingLocationNotDefined + return false + } - guard pendingTrace.startingPoints.count >= minStartingPoints else { - userAlert = .init(description: "Please set at least \(minStartingPoints) starting location\(minStartingPoints > 1 ? "s" : "").") + if pendingTrace.startingPoints.count < 2 && configuration.minimumStartingLocations == .many { + userAlert = .startingLocationsNotDefined return false } diff --git a/Sources/ArcGISToolkit/Documentation.docc/ArcGISToolkit.md b/Sources/ArcGISToolkit/Documentation.docc/ArcGISToolkit.md index 74e17920e..699e53560 100644 --- a/Sources/ArcGISToolkit/Documentation.docc/ArcGISToolkit.md +++ b/Sources/ArcGISToolkit/Documentation.docc/ArcGISToolkit.md @@ -1,3 +1,23 @@ # ``ArcGISToolkit`` The ArcGIS Maps SDK for Swift Toolkit contains components that will simplify your iOS app development. It is built off of the new ArcGIS Maps SDK for Swift. + +### Toolkit Tutorials + +Learn how to use ArcGISToolkit with + +## Topics + +### Components + +- ``Authenticator`` +- ``BasemapGallery`` +- ``Bookmarks`` +- ``Compass`` +- +- ``FloorFilter`` +- ``OverviewMap`` +- ``PopupView`` +- ``Scalebar`` +- ``SearchView`` +- ``UtilityNetworkTrace`` diff --git a/Sources/ArcGISToolkit/Documentation.docc/FloatingPanel.md b/Sources/ArcGISToolkit/Documentation.docc/FloatingPanel.md new file mode 100644 index 000000000..9629c50ac --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/FloatingPanel.md @@ -0,0 +1,51 @@ +# Floating Panel + +A floating panel is a view that overlays a view and supplies view-related +content. For a map view, for instance, it could display a legend, bookmarks, search results, etc. +Apple Maps, Google Maps, Windows 10, and Collector have floating panel +implementations, sometimes referred to as a "bottom sheet". + +Floating panels are non-modal and can be transient, only displaying +information for a short period of time like identify results, +or persistent, where the information is always displayed, for example a +dedicated search panel. They will also be primarily simple containers +that clients will fill with their own content. + +The floating panel allows for interaction with background content by default, unlike native +sheets or popovers. + +The following images are of a simple list of numbers in a floating panel. + +| iPhone | iPad | +| ------ | ---- | +| ![image](https://user-images.githubusercontent.com/3998072/202795901-b86d6d26-3572-4c88-8f6e-84473ce57002.png) | ![image](https://user-images.githubusercontent.com/3998072/202796009-92e3b5c3-d88b-4124-8d9f-bad6df445f02.png) | +- Note: The Floating Panel is exposed as a view modifier. + +**Features** + +- Can display any custom content. +- Can be resized by dragging the panel's handle. +- Has three predefined height settings, called "detents", that the panel will snap to when the +user drags and releases the handle. +- Can be configured with a custom detent, specifying either a fraction of the maximum height or +a fixed value. + +**Behavior** + +- Content in a floating panel can be resized using a “handle” on the bottom (for regular-width +environments) or on the top (compact-width environments). +- The height of the floating panel is determined by a selected “detent”. There are pre-defined +detents for full screen height, half screen height, and a “summary” height, along with the +ability to set custom detent heights. Dragging and releasing the handle will snap the floating +panel height to the nearest detent. +- The floating panel view modifier allows you to set the content along with a number of other +properties, including: + - `backgroundColor`: The background color of the floating panel. + - `selectedDetent`: A binding to the currently selected detent. + - `horizontalAlignment`: The horizontal alignment of the floating panel. + - `maxWidth`: The maximum width of the floating panel. + +To see it in action, try out the [Examples](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/tree/main/Examples/Examples) +and refer to [FloatingPanelExampleView.swift](https://github.com/Esri/arcgis-maps-sdk-swift-toolkit/blob/main/Examples/Examples/FloatingPanelExampleView.swift) +in the project. To learn more about using the Floating Panel see the +[FloatingPanel Tutorial](https://developers.arcgis.com/swift/toolkit-api-reference/tutorials/arcgistoolkit/floatingpaneltutorial). diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/Authenticator.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/Authenticator.png new file mode 100644 index 000000000..83a8e3996 Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/Authenticator.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep1.swift new file mode 100644 index 000000000..56c3e973b --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep1.swift @@ -0,0 +1,12 @@ +import SwiftUI +import ArcGISToolkit +import ArcGIS + +@main +struct AuthenticationApp: App { + @ObservedObject var authenticator: Authenticator + + init() { + authenticator = Authenticator() + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep2.swift new file mode 100644 index 000000000..ff2b8186d --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep2.swift @@ -0,0 +1,13 @@ +import SwiftUI +import ArcGISToolkit +import ArcGIS + +@main +struct AuthenticationApp: App { + @ObservedObject var authenticator: Authenticator + + init() { + authenticator = Authenticator() + ArcGISEnvironment.authenticationManager.handleChallenges(using: authenticator) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep3.swift new file mode 100644 index 000000000..575642ed3 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep3.swift @@ -0,0 +1,23 @@ +import SwiftUI +import ArcGISToolkit +import ArcGIS + +@main +struct AuthenticationApp: App { + @ObservedObject var authenticator: Authenticator + + init() { + authenticator = Authenticator() + ArcGISEnvironment.authenticationManager.handleChallenges(using: authenticator) + } + + var body: some SwiftUI.Scene { + WindowGroup { + Group { + HomeView() + } + .authenticator(authenticator) + .environmentObject(authenticator) + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep4.swift new file mode 100644 index 000000000..fb7377f87 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep4.swift @@ -0,0 +1,26 @@ +import SwiftUI +import ArcGISToolkit +import ArcGIS + +@main +struct AuthenticationApp: App { + @ObservedObject var authenticator: Authenticator + + init() { + authenticator = Authenticator() + ArcGISEnvironment.authenticationManager.handleChallenges(using: authenticator) + } + + var body: some SwiftUI.Scene { + WindowGroup { + Group { + HomeView() + } + .authenticator(authenticator) + .environmentObject(authenticator) + .task { + try? await ArcGISEnvironment.authenticationManager.setupPersistentCredentialStorage(access: .whenUnlockedThisDeviceOnly) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep5.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep5.swift new file mode 100644 index 000000000..664a4f374 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Authenticator/AuthenticatorStep5.swift @@ -0,0 +1,6 @@ +func signOut() { + Task { + await ArcGISEnvironment.authenticationManager.revokeOAuthTokens() + await ArcGISEnvironment.authenticationManager.clearCredentialStores() + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGallery.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGallery.png new file mode 100644 index 000000000..bfb5ab27d Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGallery.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep1.swift new file mode 100644 index 000000000..dce09d0cb --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep1.swift @@ -0,0 +1,16 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct BasemapGalleryExampleView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + let initialViewpoint = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1_000_000 + ) + + var body: some View { + MapView(map: map, viewpoint: initialViewpoint) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep2.swift new file mode 100644 index 000000000..357f845a0 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep2.swift @@ -0,0 +1,30 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct BasemapGalleryExampleView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + let initialViewpoint = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1_000_000 + ) + + @State private var basemaps = initialBasemaps() + + var body: some View { + MapView(map: map, viewpoint: initialViewpoint) + } + + private static func initialBasemaps() -> [BasemapGalleryItem] { + let identifiers = [ + "46a87c20f09e4fc48fa3c38081e0cae6", + "f33a34de3a294590ab48f246e99958c9" + ] + + return identifiers.map { identifier in + let url = URL(string: "https://www.arcgis.com/home/item.html?id=\(identifier)")! + return BasemapGalleryItem(basemap: Basemap(item: PortalItem(url: url)!)) + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep3.swift new file mode 100644 index 000000000..2b0764ade --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep3.swift @@ -0,0 +1,37 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct BasemapGalleryExampleView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + @State private var showBasemapGallery = false + + let initialViewpoint = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1_000_000 + ) + + @State private var basemaps = initialBasemaps() + + var body: some View { + MapView(map: map, viewpoint: initialViewpoint) + .sheet(isPresented: $showBasemapGallery) { + BasemapGallery(items: basemaps, geoModel: map) + .style(.grid(maxItemWidth: 100)) + .padding() + } + } + + private static func initialBasemaps() -> [BasemapGalleryItem] { + let identifiers = [ + "46a87c20f09e4fc48fa3c38081e0cae6", + "f33a34de3a294590ab48f246e99958c9" + ] + + return identifiers.map { identifier in + let url = URL(string: "https://www.arcgis.com/home/item.html?id=\(identifier)")! + return BasemapGalleryItem(basemap: Basemap(item: PortalItem(url: url)!)) + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep4.swift new file mode 100644 index 000000000..f588154e0 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep4.swift @@ -0,0 +1,44 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct BasemapGalleryExampleView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + @State private var showBasemapGallery = false + + let initialViewpoint = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1_000_000 + ) + + @State private var basemaps = initialBasemaps() + + var body: some View { + MapView(map: map, viewpoint: initialViewpoint) + .sheet(isPresented: $showBasemapGallery) { + BasemapGallery(items: basemaps, geoModel: map) + .style(.grid(maxItemWidth: 100)) + .padding() + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Toggle(isOn: $showBasemapGallery) { + Image("basemap", label: Text("Show base map")) + } + } + } + } + + private static func initialBasemaps() -> [BasemapGalleryItem] { + let identifiers = [ + "46a87c20f09e4fc48fa3c38081e0cae6", + "f33a34de3a294590ab48f246e99958c9" + ] + + return identifiers.map { identifier in + let url = URL(string: "https://www.arcgis.com/home/item.html?id=\(identifier)")! + return BasemapGalleryItem(basemap: Basemap(item: PortalItem(url: url)!)) + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep5.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep5.swift new file mode 100644 index 000000000..6476de254 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/BasemapGallery/BasemapGalleryStep5.swift @@ -0,0 +1,56 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct BasemapGalleryExampleView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + @State private var showBasemapGallery = false + + let initialViewpoint = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1_000_000 + ) + + @State private var basemaps = initialBasemaps() + + var body: some View { + MapView(map: map, viewpoint: initialViewpoint) + .sheet(isPresented: $showBasemapGallery) { + VStack(alignment: .trailing) { + doneButton + .padding() + BasemapGallery(items: basemaps, geoModel: map) + .style(.grid(maxItemWidth: 100)) + .padding() + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Toggle(isOn: $showBasemapGallery) { + Image("basemap", label: Text("Show base map")) + } + } + } + } + + private var doneButton: some View { + Button { + showBasemapGallery.toggle() + } label: { + Text("Done") + } + } + + private static func initialBasemaps() -> [BasemapGalleryItem] { + let identifiers = [ + "46a87c20f09e4fc48fa3c38081e0cae6", + "f33a34de3a294590ab48f246e99958c9" + ] + + return identifiers.map { identifier in + let url = URL(string: "https://www.arcgis.com/home/item.html?id=\(identifier)")! + return BasemapGalleryItem(basemap: Basemap(item: PortalItem(url: url)!)) + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/Bookmarks.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/Bookmarks.png new file mode 100644 index 000000000..6bdf58a1d Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/Bookmarks.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep1.swift new file mode 100644 index 000000000..8f48bdecd --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep1.swift @@ -0,0 +1,18 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct BookmarksExampleView: View { + @State private var map = Map(url: URL(string: "https://www.arcgis.com/home/item.html?id=16f1b8ba37b44dc3884afc8d5f454dd2")!)! + + @State var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { mapViewProxy in + MapView(map: map, viewpoint: viewpoint) + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep2.swift new file mode 100644 index 000000000..d78ffadc2 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep2.swift @@ -0,0 +1,32 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct BookmarksExampleView: View { + @State private var map = Map(url: URL(string: "https://www.arcgis.com/home/item.html?id=16f1b8ba37b44dc3884afc8d5f454dd2")!)! + + @State var showingBookmarks = false + + @State var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { mapViewProxy in + MapView(map: map, viewpoint: viewpoint) + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showingBookmarks.toggle() + } label: { + Label( + "Show Bookmarks", + systemImage: "bookmark" + ) + } + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep3.swift new file mode 100644 index 000000000..e0aadcd78 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep3.swift @@ -0,0 +1,38 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct BookmarksExampleView: View { + @State private var map = Map(url: URL(string: "https://www.arcgis.com/home/item.html?id=16f1b8ba37b44dc3884afc8d5f454dd2")!)! + + @State var showingBookmarks = false + + @State var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { mapViewProxy in + MapView(map: map, viewpoint: viewpoint) + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showingBookmarks.toggle() + } label: { + Label( + "Show Bookmarks", + systemImage: "bookmark" + ) + } + .popover(isPresented: $showingBookmarks) { + Bookmarks( + isPresented: $showingBookmarks, + geoModel: map + ) + } + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep4.swift new file mode 100644 index 000000000..9370c1cad --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep4.swift @@ -0,0 +1,41 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct BookmarksExampleView: View { + @State private var map = Map(url: URL(string: "https://www.arcgis.com/home/item.html?id=16f1b8ba37b44dc3884afc8d5f454dd2")!)! + + @State var selectedBookmark: Bookmark? + + @State var showingBookmarks = false + + @State var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { mapViewProxy in + MapView(map: map, viewpoint: viewpoint) + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showingBookmarks.toggle() + } label: { + Label( + "Show Bookmarks", + systemImage: "bookmark" + ) + } + .popover(isPresented: $showingBookmarks) { + Bookmarks( + isPresented: $showingBookmarks, + geoModel: map + ) + .onSelectionChanged { selectedBookmark = $0 } + } + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep5.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep5.swift new file mode 100644 index 000000000..de772d755 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Bookmarks/BookmarksStep5.swift @@ -0,0 +1,46 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct BookmarksExampleView: View { + @State private var map = Map(url: URL(string: "https://www.arcgis.com/home/item.html?id=16f1b8ba37b44dc3884afc8d5f454dd2")!)! + + @State var selectedBookmark: Bookmark? + + @State var showingBookmarks = false + + @State var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { mapViewProxy in + MapView(map: map, viewpoint: viewpoint) + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + .task(id: selectedBookmark) { + if let selectedBookmark, let viewpoint = selectedBookmark.viewpoint { + await mapViewProxy.setViewpoint(viewpoint) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showingBookmarks.toggle() + } label: { + Label( + "Show Bookmarks", + systemImage: "bookmark" + ) + } + .popover(isPresented: $showingBookmarks) { + Bookmarks( + isPresented: $showingBookmarks, + geoModel: map + ) + .onSelectionChanged { selectedBookmark = $0 } + } + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/Compass.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/Compass.png new file mode 100644 index 000000000..c564af000 Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/Compass.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep1.swift new file mode 100644 index 000000000..5632bf79c --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep1.swift @@ -0,0 +1,13 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct CompassExampleView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + var body: some View { + MapViewReader { proxy in + MapView(map: map) + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep2.swift new file mode 100644 index 000000000..63be15018 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep2.swift @@ -0,0 +1,16 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct CompassExampleView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + @State private var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { proxy in + MapView(map: map) + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep3.swift new file mode 100644 index 000000000..3e7bdfb91 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep3.swift @@ -0,0 +1,19 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct CompassExampleView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + @State private var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { proxy in + MapView(map: map) + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + .overlay(alignment: .topTrailing) { + Compass(rotation: viewpoint?.rotation, mapViewProxy: proxy) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep4.swift new file mode 100644 index 000000000..451688a9b --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep4.swift @@ -0,0 +1,21 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct CompassExampleView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + @State private var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { proxy in + MapView(map: map) + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + .overlay(alignment: .topTrailing) { + Compass(rotation: viewpoint?.rotation, mapViewProxy: proxy) + .compassSize(size: 44) + .padding() + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep5.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep5.swift new file mode 100644 index 000000000..f70e5ba30 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Compass/CompassStep5.swift @@ -0,0 +1,22 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct CompassExampleView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + @State private var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { proxy in + MapView(map: map) + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + .overlay(alignment: .topTrailing) { + Compass(rotation: viewpoint?.rotation, mapViewProxy: proxy) + .autoHideDisabled() + .compassSize(size: 44) + .padding() + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanel.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanel.png new file mode 100644 index 000000000..7277f0ca5 Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanel.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep1.swift new file mode 100644 index 000000000..7c5a82aa9 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep1.swift @@ -0,0 +1,8 @@ +import SwiftUI +import ArcGISToolkit + +struct FloatingPanelExampleView: View { + var body: some View { + LinearGradient(colors: [.blue, .black], startPoint: .top, endPoint: .bottom) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep2.swift new file mode 100644 index 000000000..858e7f95d --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep2.swift @@ -0,0 +1,12 @@ +import SwiftUI +import ArcGISToolkit + +struct FloatingPanelExampleView: View { + @State private var selectedDetent: FloatingPanelDetent = .half + + @State private var isPresented = true + + var body: some View { + LinearGradient(colors: [.blue, .black], startPoint: .top, endPoint: .bottom) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep3.swift new file mode 100644 index 000000000..4b7867888 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep3.swift @@ -0,0 +1,14 @@ +import SwiftUI +import ArcGISToolkit + +struct FloatingPanelExampleView: View { + @State private var selectedDetent: FloatingPanelDetent = .half + + @State private var isPresented = true + + var body: some View { + LinearGradient(colors: [.blue, .black], startPoint: .top, endPoint: .bottom) + .floatingPanel(selectedDetent: $selectedDetent, isPresented: $isPresented) { + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep4.swift new file mode 100644 index 000000000..53e2ed7fc --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloatingPanel/FloatingPanelStep4.swift @@ -0,0 +1,17 @@ +import SwiftUI +import ArcGISToolkit + +struct FloatingPanelExampleView: View { + @State private var selectedDetent: FloatingPanelDetent = .half + + @State private var isPresented = true + + var body: some View { + LinearGradient(colors: [.blue, .black], startPoint: .top, endPoint: .bottom) + .floatingPanel(selectedDetent: $selectedDetent, isPresented: $isPresented) { + List(1..<10) { number in + Text(String(describing: number)) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilter.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilter.png new file mode 100644 index 000000000..d56e2d9b7 Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilter.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep1.swift new file mode 100644 index 000000000..69cfdf8e6 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep1.swift @@ -0,0 +1,25 @@ +import SwiftUI +import ArcGISToolkit +import ArcGIS + +struct FloorFilterExampleView: View { + @State private var map = Map( + item: PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("b4b599a43a474d33946cf0df526426f5")! + ) + ) + + @State private var viewpoint: Viewpoint? = Viewpoint( + center: Point( + x: -117.19496, + y: 34.05713, + spatialReference: .wgs84 + ), + scale: 100_000 + ) + + var body: some View { + MapView(map: map, viewpoint: viewpoint) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep2.swift new file mode 100644 index 000000000..cad7527ca --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep2.swift @@ -0,0 +1,27 @@ +import SwiftUI +import ArcGISToolkit +import ArcGIS + +struct FloorFilterExampleView: View { + private var floorFilterAlignment: Alignment { .bottomLeading } + + @State private var map = Map( + item: PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("b4b599a43a474d33946cf0df526426f5")! + ) + ) + + @State private var viewpoint: Viewpoint? = Viewpoint( + center: Point( + x: -117.19496, + y: 34.05713, + spatialReference: .wgs84 + ), + scale: 100_000 + ) + + var body: some View { + MapView(map: map, viewpoint: viewpoint) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep3.swift new file mode 100644 index 000000000..c6e6b24e6 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep3.swift @@ -0,0 +1,39 @@ +import SwiftUI +import ArcGISToolkit +import ArcGIS + +struct FloorFilterExampleView: View { + private var floorFilterAlignment: Alignment { .bottomLeading } + + @State private var isMapLoaded = false + + @State private var map = Map( + item: PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("b4b599a43a474d33946cf0df526426f5")! + ) + ) + + @State private var mapLoadError = false + + @State private var viewpoint: Viewpoint? = Viewpoint( + center: Point( + x: -117.19496, + y: 34.05713, + spatialReference: .wgs84 + ), + scale: 100_000 + ) + + var body: some View { + MapView(map: map, viewpoint: viewpoint) + .task { + do { + try await map.load() + isMapLoaded = true + } catch { + mapLoadError = true + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep4.swift new file mode 100644 index 000000000..982f2bf06 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep4.swift @@ -0,0 +1,47 @@ +import SwiftUI +import ArcGISToolkit +import ArcGIS + +struct FloorFilterExampleView: View { + private var floorFilterAlignment: Alignment { .bottomLeading } + + @State private var isMapLoaded = false + + @State private var isNavigating = false + + @State private var map = Map( + item: PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("b4b599a43a474d33946cf0df526426f5")! + ) + ) + + @State private var mapLoadError = false + + @State private var viewpoint: Viewpoint? = Viewpoint( + center: Point( + x: -117.19496, + y: 34.05713, + spatialReference: .wgs84 + ), + scale: 100_000 + ) + + var body: some View { + MapView(map: map, viewpoint: viewpoint) + .onNavigatingChanged { + isNavigating = $0 + } + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + .task { + do { + try await map.load() + isMapLoaded = true + } catch { + mapLoadError = true + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep5.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep5.swift new file mode 100644 index 000000000..fad2d8c91 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/FloorFilter/FloorFilterStep5.swift @@ -0,0 +1,74 @@ +import SwiftUI +import ArcGISToolkit +import ArcGIS + +struct FloorFilterExampleView: View { + private var floorFilterAlignment: Alignment { .bottomLeading } + + @State private var isMapLoaded = false + + @State private var isNavigating = false + + @State private var map = Map( + item: PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("b4b599a43a474d33946cf0df526426f5")! + ) + ) + + @State private var mapLoadError = false + + @State private var viewpoint: Viewpoint? = Viewpoint( + center: Point( + x: -117.19496, + y: 34.05713, + spatialReference: .wgs84 + ), + scale: 100_000 + ) + + var body: some View { + MapView(map: map, viewpoint: viewpoint) + .onNavigatingChanged { + isNavigating = $0 + } + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + .overlay(alignment: floorFilterAlignment) { + if isMapLoaded, + let floorManager = map.floorManager { + FloorFilter( + floorManager: floorManager, + alignment: floorFilterAlignment, + viewpoint: $viewpoint, + isNavigating: $isNavigating + ) + .frame( + maxWidth: 400, + maxHeight: 400 + ) + .padding([.horizontal], 10) + } else if mapLoadError { + Label( + "Map load error!", + systemImage: "exclamationmark.triangle" + ) + .foregroundColor(.red) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .center + ) + } + } + .task { + do { + try await map.load() + isMapLoaded = true + } catch { + mapLoadError = true + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMap.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMap.png new file mode 100644 index 000000000..e9682d824 Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMap.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForMapStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForMapStep1.swift new file mode 100644 index 000000000..3b38ca4f7 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForMapStep1.swift @@ -0,0 +1,13 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct OverviewMapForMapView: View { + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + var body: some View { + MapView(map: dataModel.map) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForMapStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForMapStep2.swift new file mode 100644 index 000000000..2f746ed46 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForMapStep2.swift @@ -0,0 +1,19 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct OverviewMapForMapView: View { + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + @State private var viewpoint: Viewpoint? + + @State private var visibleArea: ArcGIS.Polygon? + + var body: some View { + MapView(map: dataModel.map) + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + .onVisibleAreaChanged { visibleArea = $0 } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForMapStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForMapStep3.swift new file mode 100644 index 000000000..9c7ffaa1d --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForMapStep3.swift @@ -0,0 +1,28 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct OverviewMapForMapView: View { + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + @State private var viewpoint: Viewpoint? + + @State private var visibleArea: ArcGIS.Polygon? + + var body: some View { + MapView(map: dataModel.map) + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + .onVisibleAreaChanged { visibleArea = $0 } + .overlay( + OverviewMap.forMapView( + with: viewpoint, + visibleArea: visibleArea + ) + .frame(width: 200, height: 132) + .padding(), + alignment: .topTrailing + ) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForSceneStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForSceneStep1.swift new file mode 100644 index 000000000..4f90e9391 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForSceneStep1.swift @@ -0,0 +1,13 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct OverviewMapForSceneView: View { + @StateObject private var dataModel = SceneDataModel( + scene: Scene(basemapStyle: .arcGISImagery) + ) + + var body: some View { + SceneView(scene: dataModel.scene) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForSceneStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForSceneStep2.swift new file mode 100644 index 000000000..4d4a9b40c --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForSceneStep2.swift @@ -0,0 +1,16 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct OverviewMapForSceneView: View { + @StateObject private var dataModel = SceneDataModel( + scene: Scene(basemapStyle: .arcGISImagery) + ) + + @State private var viewpoint: Viewpoint? + + var body: some View { + SceneView(scene: dataModel.scene) + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForSceneStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForSceneStep3.swift new file mode 100644 index 000000000..51c1c99a5 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/OverviewMap/OverviewMapForSceneStep3.swift @@ -0,0 +1,24 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct OverviewMapForSceneView: View { + @StateObject private var dataModel = SceneDataModel( + scene: Scene(basemapStyle: .arcGISImagery) + ) + + @State private var viewpoint: Viewpoint? + + var body: some View { + SceneView(scene: dataModel.scene) + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + .overlay( + OverviewMap.forSceneView( + with: viewpoint + ) + .frame(width: 200, height: 132) + .padding(), + alignment: .topTrailing + ) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupView.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupView.png new file mode 100644 index 000000000..84c2d2932 Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupView.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep1.swift new file mode 100644 index 000000000..1a3155270 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep1.swift @@ -0,0 +1,17 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct PopupExampleView: View { + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("9f3a674e998f461580006e626611f9ad")! + ) + return Map(item: portalItem) + } + + @StateObject private var dataModel = MapDataModel( + map: makeMap() + ) +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep2.swift new file mode 100644 index 000000000..37eeb85ec --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep2.swift @@ -0,0 +1,23 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct PopupExampleView: View { + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("9f3a674e998f461580006e626611f9ad")! + ) + return Map(item: portalItem) + } + + @StateObject private var dataModel = MapDataModel( + map: makeMap() + ) + + @State private var identifyScreenPoint: CGPoint? + + @State private var popup: Popup? + + @State private var showPopup = false +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep3.swift new file mode 100644 index 000000000..3a32162b6 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep3.swift @@ -0,0 +1,31 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct PopupExampleView: View { + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("9f3a674e998f461580006e626611f9ad")! + ) + return Map(item: portalItem) + } + + @StateObject private var dataModel = MapDataModel( + map: makeMap() + ) + + @State private var identifyScreenPoint: CGPoint? + + @State private var popup: Popup? + + @State private var showPopup = false + + var body: some View { + MapViewReader { proxy in + VStack { + MapView(map: dataModel.map) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep4.swift new file mode 100644 index 000000000..d4a439198 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep4.swift @@ -0,0 +1,34 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct PopupExampleView: View { + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("9f3a674e998f461580006e626611f9ad")! + ) + return Map(item: portalItem) + } + + @StateObject private var dataModel = MapDataModel( + map: makeMap() + ) + + @State private var identifyScreenPoint: CGPoint? + + @State private var popup: Popup? + + @State private var showPopup = false + + var body: some View { + MapViewReader { proxy in + VStack { + MapView(map: dataModel.map) + .onSingleTapGesture { screenPoint, _ in + identifyScreenPoint = screenPoint + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep5.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep5.swift new file mode 100644 index 000000000..adc50de78 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep5.swift @@ -0,0 +1,52 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct PopupExampleView: View { + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("9f3a674e998f461580006e626611f9ad")! + ) + return Map(item: portalItem) + } + + @StateObject private var dataModel = MapDataModel( + map: makeMap() + ) + + @State private var identifyScreenPoint: CGPoint? + + @State private var popup: Popup? + + @State private var showPopup = false + + var body: some View { + MapViewReader { proxy in + VStack { + MapView(map: dataModel.map) + .onSingleTapGesture { screenPoint, _ in + identifyScreenPoint = screenPoint + } + .task(id: identifyScreenPoint) { + guard let identifyScreenPoint = identifyScreenPoint, + let identifyResult = await Result(awaiting: { + try await proxy.identifyLayers( + screenPoint: identifyScreenPoint, + tolerance: 10, + returnPopupsOnly: true + ) + }) + .cancellationToNil() + else { + return + } + + self.identifyScreenPoint = nil + self.popup = try? identifyResult.get().first?.popups.first + self.showPopup = self.popup != nil + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep6.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep6.swift new file mode 100644 index 000000000..14ad6c0f3 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/PopupView/PopupViewStep6.swift @@ -0,0 +1,65 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct PopupExampleView: View { + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("9f3a674e998f461580006e626611f9ad")! + ) + return Map(item: portalItem) + } + + @StateObject private var dataModel = MapDataModel( + map: makeMap() + ) + + @State private var identifyScreenPoint: CGPoint? + + @State private var popup: Popup? + + @State private var showPopup = false + + @State private var floatingPanelDetent: FloatingPanelDetent = .full + + var body: some View { + MapViewReader { proxy in + VStack { + MapView(map: dataModel.map) + .onSingleTapGesture { screenPoint, _ in + identifyScreenPoint = screenPoint + } + .task(id: identifyScreenPoint) { + guard let identifyScreenPoint = identifyScreenPoint, + let identifyResult = await Result(awaiting: { + try await proxy.identifyLayers( + screenPoint: identifyScreenPoint, + tolerance: 10, + returnPopupsOnly: true + ) + }) + .cancellationToNil() + else { + return + } + + self.identifyScreenPoint = nil + self.popup = try? identifyResult.get().first?.popups.first + self.showPopup = self.popup != nil + } + .floatingPanel( + selectedDetent: $floatingPanelDetent, + horizontalAlignment: .leading, + isPresented: $showPopup + ) { + if let popup = popup { + PopupView(popup: popup, isPresented: $showPopup) + .showCloseButton(true) + .padding() + } + } + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/Scalebar.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/Scalebar.png new file mode 100644 index 000000000..98c5c8286 Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/Scalebar.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep1.swift new file mode 100644 index 000000000..c669f06b0 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep1.swift @@ -0,0 +1,11 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct ScalebarExampleView: View { + @State private var map = Map(basemapStyle: .arcGISTopographic) + + var body: some View { + MapView(map: map) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep2.swift new file mode 100644 index 000000000..31fae7022 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep2.swift @@ -0,0 +1,20 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct ScalebarExampleView: View { + @State private var spatialReference: SpatialReference? + + @State private var unitsPerPoint: Double? + + @State private var viewpoint: Viewpoint? + + @State private var map = Map(basemapStyle: .arcGISTopographic) + + var body: some View { + MapView(map: map) + .onSpatialReferenceChanged { spatialReference = $0 } + .onUnitsPerPointChanged { unitsPerPoint = $0 } + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep3.swift new file mode 100644 index 000000000..e3d455974 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep3.swift @@ -0,0 +1,24 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct ScalebarExampleView: View { + @State private var spatialReference: SpatialReference? + + @State private var unitsPerPoint: Double? + + @State private var viewpoint: Viewpoint? + + private let alignment: Alignment = .bottomLeading + + @State private var map = Map(basemapStyle: .arcGISTopographic) + + private let maxWidth: Double = 175.0 + + var body: some View { + MapView(map: map) + .onSpatialReferenceChanged { spatialReference = $0 } + .onUnitsPerPointChanged { unitsPerPoint = $0 } + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep4.swift new file mode 100644 index 000000000..893ac549a --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/Scalebar/ScalebarStep4.swift @@ -0,0 +1,33 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct ScalebarExampleView: View { + @State private var spatialReference: SpatialReference? + + @State private var unitsPerPoint: Double? + + @State private var viewpoint: Viewpoint? + + private let alignment: Alignment = .bottomLeading + + @State private var map = Map(basemapStyle: .arcGISTopographic) + + private let maxWidth: Double = 175.0 + + var body: some View { + MapView(map: map) + .onSpatialReferenceChanged { spatialReference = $0 } + .onUnitsPerPointChanged { unitsPerPoint = $0 } + .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 } + .overlay(alignment: alignment) { + Scalebar( + maxWidth: maxWidth, + spatialReference: spatialReference, + unitsPerPoint: unitsPerPoint, + viewpoint: viewpoint + ) + .padding(.horizontal, 10) + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchView.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchView.png new file mode 100644 index 000000000..078fb9a4f Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchView.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep1.swift new file mode 100644 index 000000000..c0544043e --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep1.swift @@ -0,0 +1,14 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct SearchExampleView: View { + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + @State private var searchResultViewpoint: Viewpoint? = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1000000 + ) +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep2.swift new file mode 100644 index 000000000..1313a9465 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep2.swift @@ -0,0 +1,20 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct SearchExampleView: View { + let locatorDataSource = SmartLocatorSearchSource( + name: "My locator", + maximumResults: 16, + maximumSuggestions: 16 + ) + + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + @State private var searchResultViewpoint: Viewpoint? = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1000000 + ) +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep3.swift new file mode 100644 index 000000000..b82c26588 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep3.swift @@ -0,0 +1,22 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct SearchExampleView: View { + let locatorDataSource = SmartLocatorSearchSource( + name: "My locator", + maximumResults: 16, + maximumSuggestions: 16 + ) + + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + private let searchResultsOverlay = GraphicsOverlay() + + @State private var searchResultViewpoint: Viewpoint? = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1000000 + ) +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep4.swift new file mode 100644 index 000000000..d5d905a32 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep4.swift @@ -0,0 +1,24 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct SearchExampleView: View { + let locatorDataSource = SmartLocatorSearchSource( + name: "My locator", + maximumResults: 16, + maximumSuggestions: 16 + ) + + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + private let searchResultsOverlay = GraphicsOverlay() + + @State private var searchResultViewpoint: Viewpoint? = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1000000 + ) + + @State private var isGeoViewNavigating = false +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep5.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep5.swift new file mode 100644 index 000000000..c567f5f92 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep5.swift @@ -0,0 +1,30 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct SearchExampleView: View { + let locatorDataSource = SmartLocatorSearchSource( + name: "My locator", + maximumResults: 16, + maximumSuggestions: 16 + ) + + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + private let searchResultsOverlay = GraphicsOverlay() + + @State private var searchResultViewpoint: Viewpoint? = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1000000 + ) + + @State private var isGeoViewNavigating = false + + @State private var geoViewExtent: Envelope? + + @State private var queryArea: Geometry? + + @State private var queryCenter: Point? +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep6.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep6.swift new file mode 100644 index 000000000..f7688ffeb --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep6.swift @@ -0,0 +1,47 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct SearchExampleView: View { + let locatorDataSource = SmartLocatorSearchSource( + name: "My locator", + maximumResults: 16, + maximumSuggestions: 16 + ) + + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + private let searchResultsOverlay = GraphicsOverlay() + + @State private var searchResultViewpoint: Viewpoint? = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1000000 + ) + + @State private var isGeoViewNavigating = false + + @State private var geoViewExtent: Envelope? + + @State private var queryArea: Geometry? + + @State private var queryCenter: Point? + + var body: some View { + MapViewReader { mapViewProxy in + MapView( + map: dataModel.map, + viewpoint: searchResultViewpoint, + graphicsOverlays: [searchResultsOverlay] + ) + .overlay { + SearchView( + sources: [locatorDataSource], + viewpoint: $searchResultViewpoint, + geoViewProxy: mapViewProxy + ) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep7.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep7.swift new file mode 100644 index 000000000..30082b950 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep7.swift @@ -0,0 +1,52 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct SearchExampleView: View { + let locatorDataSource = SmartLocatorSearchSource( + name: "My locator", + maximumResults: 16, + maximumSuggestions: 16 + ) + + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + private let searchResultsOverlay = GraphicsOverlay() + + @State private var searchResultViewpoint: Viewpoint? = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1000000 + ) + + @State private var isGeoViewNavigating = false + + @State private var geoViewExtent: Envelope? + + @State private var queryArea: Geometry? + + @State private var queryCenter: Point? + + var body: some View { + MapViewReader { mapViewProxy in + MapView( + map: dataModel.map, + viewpoint: searchResultViewpoint, + graphicsOverlays: [searchResultsOverlay] + ) + .overlay { + SearchView( + sources: [locatorDataSource], + viewpoint: $searchResultViewpoint, + geoViewProxy: mapViewProxy + ) + .resultsOverlay(searchResultsOverlay) + .queryCenter($queryCenter) + .geoViewExtent($geoViewExtent) + .isGeoViewNavigating($isGeoViewNavigating) + .padding([.leading, .top, .trailing]) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep8.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep8.swift new file mode 100644 index 000000000..0e4d70195 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/SearchView/SearchViewStep8.swift @@ -0,0 +1,59 @@ +import SwiftUI +import ArcGIS +import ArcGISToolkit + +struct SearchExampleView: View { + let locatorDataSource = SmartLocatorSearchSource( + name: "My locator", + maximumResults: 16, + maximumSuggestions: 16 + ) + + @StateObject private var dataModel = MapDataModel( + map: Map(basemapStyle: .arcGISImagery) + ) + + private let searchResultsOverlay = GraphicsOverlay() + + @State private var searchResultViewpoint: Viewpoint? = Viewpoint( + center: Point(x: -93.258133, y: 44.986656, spatialReference: .wgs84), + scale: 1000000 + ) + + @State private var isGeoViewNavigating = false + + @State private var geoViewExtent: Envelope? + + @State private var queryArea: Geometry? + + @State private var queryCenter: Point? + + var body: some View { + MapViewReader { mapViewProxy in + MapView( + map: dataModel.map, + viewpoint: searchResultViewpoint, + graphicsOverlays: [searchResultsOverlay] + ) + .onNavigatingChanged { isGeoViewNavigating = $0 } + .onViewpointChanged(kind: .centerAndScale) { + queryCenter = $0.targetGeometry as? Point + } + .onVisibleAreaChanged { newValue in + geoViewExtent = newValue.extent + } + .overlay { + SearchView( + sources: [locatorDataSource], + viewpoint: $searchResultViewpoint, + geoViewProxy: mapViewProxy + ) + .resultsOverlay(searchResultsOverlay) + .queryCenter($queryCenter) + .geoViewExtent($geoViewExtent) + .isGeoViewNavigating($isGeoViewNavigating) + .padding([.leading, .top, .trailing]) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTrace.png b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTrace.png new file mode 100644 index 000000000..2a7b8443a Binary files /dev/null and b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTrace.png differ diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep1.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep1.swift new file mode 100644 index 000000000..93de75af4 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep1.swift @@ -0,0 +1,23 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct UtilityNetworkTraceExampleView: View { + @State private var map = makeMap() + + @State var mapPoint: Point? + + @State var screenPoint: CGPoint? + + @State var resultGraphicsOverlay = GraphicsOverlay() + + @State var viewpoint: Viewpoint? + + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID(rawValue: "471eb0bf37074b1fbb972b1da70fb310")! + ) + return Map(item: portalItem) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep2.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep2.swift new file mode 100644 index 000000000..8210e28e1 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep2.swift @@ -0,0 +1,37 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct UtilityNetworkTraceExampleView: View { + @State private var map = makeMap() + + @State var mapPoint: Point? + + @State var screenPoint: CGPoint? + + @State var resultGraphicsOverlay = GraphicsOverlay() + + @State var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { mapViewProxy in + MapView( + map: map, + viewpoint: viewpoint, + graphicsOverlays: [resultGraphicsOverlay] + ) + .task { + let publicSample = try? await ArcGISCredential.publicSample + ArcGISEnvironment.authenticationManager.arcGISCredentialStore.add(publicSample!) + } + } + } + + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID(rawValue: "471eb0bf37074b1fbb972b1da70fb310")! + ) + return Map(item: portalItem) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep3.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep3.swift new file mode 100644 index 000000000..885fe82a1 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep3.swift @@ -0,0 +1,40 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct UtilityNetworkTraceExampleView: View { + @State private var map = makeMap() + + @State var mapPoint: Point? + + @State var screenPoint: CGPoint? + + @State var resultGraphicsOverlay = GraphicsOverlay() + + @State var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { mapViewProxy in + MapView( + map: map, + viewpoint: viewpoint, + graphicsOverlays: [resultGraphicsOverlay] + ) + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + .task { + let publicSample = try? await ArcGISCredential.publicSample + ArcGISEnvironment.authenticationManager.arcGISCredentialStore.add(publicSample!) + } + } + } + + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID(rawValue: "471eb0bf37074b1fbb972b1da70fb310")! + ) + return Map(item: portalItem) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep4.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep4.swift new file mode 100644 index 000000000..8d577e595 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep4.swift @@ -0,0 +1,44 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct UtilityNetworkTraceExampleView: View { + @State private var map = makeMap() + + @State var mapPoint: Point? + + @State var screenPoint: CGPoint? + + @State var resultGraphicsOverlay = GraphicsOverlay() + + @State var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { mapViewProxy in + MapView( + map: map, + viewpoint: viewpoint, + graphicsOverlays: [resultGraphicsOverlay] + ) + .onSingleTapGesture { screenPoint, mapPoint in + self.screenPoint = screenPoint + self.mapPoint = mapPoint + } + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + .task { + let publicSample = try? await ArcGISCredential.publicSample + ArcGISEnvironment.authenticationManager.arcGISCredentialStore.add(publicSample!) + } + } + } + + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID(rawValue: "471eb0bf37074b1fbb972b1da70fb310")! + ) + return Map(item: portalItem) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep5.swift b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep5.swift new file mode 100644 index 000000000..9482f7517 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Resources/UtilityNetworkTrace/UtilityNetworkTraceStep5.swift @@ -0,0 +1,62 @@ +import ArcGIS +import ArcGISToolkit +import SwiftUI + +struct UtilityNetworkTraceExampleView: View { + @State private var map = makeMap() + + @State var activeDetent: FloatingPanelDetent = .half + + @State var mapPoint: Point? + + @State var screenPoint: CGPoint? + + @State var resultGraphicsOverlay = GraphicsOverlay() + + @State var viewpoint: Viewpoint? + + var body: some View { + MapViewReader { mapViewProxy in + MapView( + map: map, + viewpoint: viewpoint, + graphicsOverlays: [resultGraphicsOverlay] + ) + .onSingleTapGesture { screenPoint, mapPoint in + self.screenPoint = screenPoint + self.mapPoint = mapPoint + } + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + .task { + let publicSample = try? await ArcGISCredential.publicSample + ArcGISEnvironment.authenticationManager.arcGISCredentialStore.add(publicSample!) + } + .floatingPanel( + backgroundColor: Color(uiColor: .systemGroupedBackground), + selectedDetent: $activeDetent, + horizontalAlignment: .trailing, + isPresented: .constant(true) + ) { + UtilityNetworkTrace( + graphicsOverlay: $resultGraphicsOverlay, + map: map, + mapPoint: $mapPoint, + screenPoint: $screenPoint, + mapViewProxy: mapViewProxy, + viewpoint: $viewpoint + ) + .floatingPanelDetent($activeDetent) + } + } + } + + static func makeMap() -> Map { + let portalItem = PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID(rawValue: "471eb0bf37074b1fbb972b1da70fb310")! + ) + return Map(item: portalItem) + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/AuthenticatorTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/AuthenticatorTutorial.tutorial new file mode 100644 index 000000000..ce0b42f6d --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/AuthenticatorTutorial.tutorial @@ -0,0 +1,40 @@ +@Tutorial(time: 10) { + @Intro(title: "Authenticator Tutorial") { + The `Authenticator` is a configurable object that handles authentication challenges. It will + display a user interface when network and ArcGIS authentication challenges occur. + @Image(source: Authenticator, alt: "An image of the Authenticator component.") + } + + @Section(title: "Using the Authenticator") { + @ContentAndMedia { + @Image(source: Authenticator, alt: "An image of the Authenticator component.") + } + + @Steps { + @Step { + Declare and initialize an `Authenticator` member in your app. + @Code(name: "ExampleApp.swift", file: AuthenticatorStep1) + } + + @Step { + Set the `Authenticator` to handle challenges. + @Code(name: "ExampleApp.swift", file: AuthenticatorStep2) + } + + @Step { + Declare the app's body, placing the `Authenticator` into the hierarchy. + @Code(name: "ExampleApp.swift", file: AuthenticatorStep3) + } + + @Step { + Setup credential persistence. + @Code(name: "ExampleApp.swift", file: AuthenticatorStep4) + } + + @Step { + During sign-out, use these `AuthenticationManager` methods. + @Code(name: "SignOutView.swift", file: AuthenticatorStep5) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/BasemapGalleryTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/BasemapGalleryTutorial.tutorial new file mode 100644 index 000000000..a8ad031d2 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/BasemapGalleryTutorial.tutorial @@ -0,0 +1,42 @@ +@Tutorial(time: 10) { + @Intro(title: "BasemapGallery Tutorial") { + The `BasemapGallery` displays a collection of basemaps from ArcGIS Online, a user-defined + portal, or an array of `BasemapGalleryItem`s. + @Image(source: BasemapGallery, alt: "An image of the BasemapGallery component.") + } + + @Section(title: "Using the BasemapGallery") { + @ContentAndMedia { + @Image(source: BasemapGallery, alt: "An image of the BasemapGallery component.") + } + + @Steps { + @Step { + To begin, set up the parent view. + Initialize a map with a basemap style, create member variable to control when the gallery is shown or hidden and pick an initial viewpoint. + Finally, add a `MapView` to the body of view, passing in the map and initial viewpoint. + @Code(name: "BasemapGalleryExampleView.swift", file: BasemapGalleryStep1) + } + + @Step { + Create a list of items to be shown in the gallery. + @Code(name: "BasemapGalleryExampleView.swift", file: BasemapGalleryStep2) + } + + @Step { + Host the BasemapGallery on top of the `MapView` in a sheet, passing in the list of basemap gallery items and the map. + @Code(name: "BasemapGalleryExampleView.swift", file: BasemapGalleryStep3) + } + + @Step { + Place a button in the navigation bar to open the gallery. + @Code(name: "BasemapGalleryExampleView.swift", file: BasemapGalleryStep4) + } + + @Step { + Place a done button in the sheet to close the gallery. + @Code(name: "BasemapGalleryExampleView.swift", file: BasemapGalleryStep5) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/BookmarksTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/BookmarksTutorial.tutorial new file mode 100644 index 000000000..9f7f44029 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/BookmarksTutorial.tutorial @@ -0,0 +1,42 @@ +@Tutorial(time: 10) { + @Intro(title: "Bookmarks Tutorial") { + The `Bookmarks` component will display a list of bookmarks and allow the user to select a + bookmark and perform some action. + @Image(source: Bookmarks, alt: "An image of the Bookmarks component.") + } + + @Section(title: "Using the Bookmarks component") { + @ContentAndMedia { + @Image(source: Bookmarks, alt: "An image of the Bookmarks component.") + } + + @Steps { + @Step { + To begin, set up the parent view. + Initialize a map that contains preset bookmarks and add a member to track viewpoint changes. + Add a `MapView` to the body of the view, passing in the map and viewpoint. Track viewpoint changes with the `onViewpointChanged` modifier. Wrap the `MapView` in a `MapViewReader` to allow for programatic animated viewpoint changes. + @Code(name: "BookmarksExampleView.swift", file: BookmarksStep1) + } + + @Step { + Add a toolbar button to open the bookmark selector. The property `showingBookmarks` will control when the gallery is shown or hidden. + @Code(name: "BookmarksExampleView.swift", file: BookmarksStep2) + } + + @Step { + Host the bookmark selector inside of a popover. + @Code(name: "BookmarksExampleView.swift", file: BookmarksStep3) + } + + @Step { + Use the `onSelectionChanged` modifier to track when a bookmark selection is made. + @Code(name: "BookmarksExampleView.swift", file: BookmarksStep4) + } + + @Step { + Use a task to animate viewpoint changes when the selected bookmark is changed. + @Code(name: "BookmarksExampleView.swift", file: BookmarksStep5) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/CompassTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/CompassTutorial.tutorial new file mode 100644 index 000000000..034d8ad73 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/CompassTutorial.tutorial @@ -0,0 +1,44 @@ +@Tutorial(time: 10) { + @Intro(title: "Compass Tutorial") { + A `Compass` (alias North arrow) shows where north is in a `MapView` or `SceneView`. + @Image(source: Compass, alt: "The Compass component") + } + + @Section(title: "Using the Compass") { + @ContentAndMedia { + @Image(source: Compass, alt: "An image of the Compass component.") + } + + @Steps { + @Step { + Create a new view with a `MapViewReader` and `MapView`. + @Code(name: "CompassExampleView.swift", file: CompassStep1) + } + + @Step { + Add a property to track viewpoint changes with `.onViewpointChanged(kind:)` + @Code(name: "CompassExampleView.swift", file: CompassStep2) + } + + @Step { + Add an overlay to the `MapView` with `Compass` as the content. + @Code(name: "CompassExampleView.swift", file: CompassStep3) { + @Image( + source: CompassComplete, + alt: "An image of the compass component overlaid on a map view." + ) + } + } + + @Step { + Optionally specify a size and padding. + @Code(name: "CompassExampleView.swift", file: CompassStep4) + } + + @Step { + Optionally prevent the compass from hiding. + @Code(name: "CompassExampleView.swift", file: CompassStep5) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/FloatingPanelTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/FloatingPanelTutorial.tutorial new file mode 100644 index 000000000..fa56ba6ab --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/FloatingPanelTutorial.tutorial @@ -0,0 +1,38 @@ +@Tutorial(time: 10) { + @Intro(title: "FloatingPanel Tutorial") { + A `FloatingPanel` is a view that overlays a view and supplies view-related content. + @Image(source: FloatingPanel, alt: "An image of the FloatingPanel component.") + } + + @Section(title: "Using the FloatingPanel") { + @ContentAndMedia { + @Image(source: FloatingPanel, alt: "An image of the FloatingPanel component.") + } + + @Steps { + @Step { + To begin, set up the parent view. + A floating panel can host content over a `MapView`, `SceneView`, or some other view. + Add a simple gradient to the body of the view. + @Code(name: "FloatingPanelExampleView.swift", file: FloatingPanelStep1) + } + + @Step { + Add a `FloatingPanelDetent` property to control the height of the panel. + In this case we will use a named value (`.half`), but a custom value can also be used. + Add a Boolean property to control when the panel is shown or hidden. + @Code(name: "FloatingPanelExampleView.swift", file: FloatingPanelStep2) + } + + @Step { + Add the Floating Panel into the hierarchy, passing in values for `selectedDetent` and `isPresented`. + @Code(name: "FloatingPanelExampleView.swift", file: FloatingPanelStep3) + } + + @Step { + Add the desired content to the body of the Floating Panel. + @Code(name: "FloatingPanelExampleView.swift", file: FloatingPanelStep4) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/FloorFilterTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/FloorFilterTutorial.tutorial new file mode 100644 index 000000000..accc0969d --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/FloorFilterTutorial.tutorial @@ -0,0 +1,43 @@ +@Tutorial(time: 10) { + @Intro(title: "FloorFilter Tutorial") { + The `FloorFilter` component simplifies visualization of GIS data for a specific floor of a + building in your application. + @Image(source: FloorFilter, alt: "An image of the FloorFilter component.") + } + + @Section(title: "Using the FloorFilter") { + @ContentAndMedia { + @Image(source: FloorFilter, alt: "An image of the FloorFilter component.") + } + + @Steps { + @Step { + To begin, set up the parent view. + Add a map with floor aware data, a property to track the current viewpoint and place + a `MapView` into the body using these properties. + @Code(name: "FloorFilterExampleView.swift", file: FloorFilterStep1) + } + + @Step { + Add an alignment property so that the Floor Filter can properly display its internal views. + @Code(name: "FloorFilterExampleView.swift", file: FloorFilterStep2) + } + + @Step { + Add logic to load the map and handle any resulting errors when the view loads. + @Code(name: "FloorFilterExampleView.swift", file: FloorFilterStep3) + } + + @Step { + Track when the user is navigating the map with `.onNavigatingChanged` and also keep + track of the latest viewpoint with `onViewpointChanged`. + @Code(name: "FloorFilterExampleView.swift", file: FloorFilterStep4) + } + + @Step { + Overlay the map with the Floor Filter, optionally adding UI to handle any loading errors. + @Code(name: "FloorFilterExampleView.swift", file: FloorFilterStep5) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/OverviewMapTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/OverviewMapTutorial.tutorial new file mode 100644 index 000000000..856944d80 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/OverviewMapTutorial.tutorial @@ -0,0 +1,56 @@ +@Tutorial(time: 10) { + @Intro(title: "OverviewMap Tutorial") { + `OverviewMap` is a small, secondary `MapView` (sometimes called an "inset map"), + superimposed on an existing `GeoView`, which shows a representation of the current + visible area (for a `MapView`) or viewpoint (for a `SceneView`). + @Image(source: OverviewMap, alt: "An image of the OverviewMap component.") + } + + @Section(title: "Using the OverviewMap") { + @ContentAndMedia { + @Image(source: OverviewMap, alt: "An image of the Overview Map component.") + } + + @Steps { + @Step { + To begin, set up the parent view. + Initialize a map and pass it to a `MapView` in the view's body. + @Code(name: "OverviewMapForMapView.swift", file: OverviewMapForMapStep1) + } + + @Step { + When using an overview map with a map we need to track the current viewpoint and visible area. + @Code(name: "OverviewMapForMapView.swift", file: OverviewMapForMapStep2) + } + + @Step { + Overlay the `OverviewMap` using ``OverviewMap/forMapView(with:visibleArea:map:)``. + @Code(name: "OverviewMapForMapView.swift", file: OverviewMapForMapStep3) + } + } + } + + @Section(title: "Add an Overview Map to a scene") { + @ContentAndMedia { + @Image(source: OverviewMap, alt: "An image of the Overview Map component.") + } + + @Steps { + @Step { + To begin, set up the parent view. + Initialize a scene and pass it to a `SceneView` in the view's body. + @Code(name: "OverviewMapForSceneView.swift", file: OverviewMapForSceneStep1) + } + + @Step { + When using an overview map with a scene we need to track the current viewpoint. + @Code(name: "OverviewMapForSceneView.swift", file: OverviewMapForSceneStep2) + } + + @Step { + Overlay the `OverviewMap` using ``OverviewMap/forSceneView(with:map:)``. + @Code(name: "OverviewMapForSceneView.swift", file: OverviewMapForSceneStep3) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/PopupViewTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/PopupViewTutorial.tutorial new file mode 100644 index 000000000..67e783e90 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/PopupViewTutorial.tutorial @@ -0,0 +1,47 @@ +@Tutorial(time: 10) { + @Intro(title: "PopupView Tutorial") { + The `PopupView` component will display a popup for an individual feature. This includes + showing the feature's title, attributes, custom description, media, and attachments. + @Image(source: PopupView, alt: "An image of the PopupView component.") + } + + @Section(title: "Using the PopupView") { + @ContentAndMedia { + @Image(source: PopupView, alt: "An image of the PopupView component.") + } + + @Steps { + @Step { + Initialize data containing popups. + @Code(name: "PopupExampleView.swift", file: PopupViewStep1) + } + + @Step { + Track important information like the last selected screen point and the current + `Popup`. + @Code(name: "PopupExampleView.swift", file: PopupViewStep2) + } + + @Step { + Add the `MapView` and with the `Popup` data. + @Code(name: "PopupExampleView.swift", file: PopupViewStep3) + } + + @Step { + Track any tap gestures on the `MapView`. + @Code(name: "PopupExampleView.swift", file: PopupViewStep4) + } + + @Step { + Perform an identify operation whenever the map is tapped and grab the `Popup` data + if present. + @Code(name: "PopupExampleView.swift", file: PopupViewStep5) + } + + @Step { + Show the `PopupView` in a `FloatingPanel`. + @Code(name: "PopupExampleView.swift", file: PopupViewStep6) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/ScalebarTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/ScalebarTutorial.tutorial new file mode 100644 index 000000000..5d990e3aa --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/ScalebarTutorial.tutorial @@ -0,0 +1,38 @@ +@Tutorial(time: 10) { + @Intro(title: "Scalebar Tutorial") { + A `Scalebar` displays the representation of an accurate linear measurement on the map. It + provides a visual indication through which users can determine the size of features or the + distance between features on a map. + @Image(source: Scalebar, alt: "An image of the Scalebar component.") + } + + @Section(title: "Using the Scalebar") { + @ContentAndMedia { + @Image(source: Scalebar, alt: "An image of the Scalebar component.") + } + + @Steps { + @Step { + To begin, set up the parent view. + Initialize a map property and pass it to a `MapView` in the view's body. + @Code(name: "ScalebarExampleView.swift", file: ScalebarStep1) + } + + @Step { + The Scalebar needs three pieces of information to determine scale: a spatial reference, the number of units per point, and the viewpoint. Set these values and track their changes with their respective modifiers. + @Code(name: "ScalebarExampleView.swift", file: ScalebarStep2) + } + + @Step { + Choose an alignment for the Scalebar, such as `bottomLeading`, and determine + the maximum amount of screen width the Scalebar should consume. + @Code(name: "ScalebarExampleView.swift", file: ScalebarStep3) + } + + @Step { + Overlay the `Scalebar` on the `MapView`, passing in all of the previously created properties. + @Code(name: "ScalebarExampleView.swift", file: ScalebarStep4) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/SearchViewTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/SearchViewTutorial.tutorial new file mode 100644 index 000000000..fbdd6d4b4 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/SearchViewTutorial.tutorial @@ -0,0 +1,59 @@ +@Tutorial(time: 10) { + @Intro(title: "SearchView Tutorial") { + `SearchView` enables searching using one or more locators, with support for suggestions, + automatic zooming, and custom search sources. + @Image(source: SearchView, alt: "An image of the SearchView component.") + } + + @Section(title: "Using the SearchView") { + @ContentAndMedia { + @Image(source: SearchView, alt: "An image of the SearchView component.") + } + + @Steps { + @Step { + Add a data model containing the `Map` displayed in the `MapView`. + + Also create a map viewpoint used by the `SearchView` to pan/zoom the map to the + extent of the search results. + @Code(name: "SearchExampleView.swift", file: SearchViewStep1) + } + + @Step { + Provide a search source which also allows search behavior customization. + @Code(name: "SearchExampleView.swift", file: SearchViewStep2) + } + + @Step { + Add a `GraphicsOverlay` to display search results on the map. + @Code(name: "SearchExampleView.swift", file: SearchViewStep3) + } + + @Step { + Add a property to detect when the viewpoint is changing. This will help to implement + repeat search behavior. + @Code(name: "SearchExampleView.swift", file: SearchViewStep4) + } + + @Step { + Add properties to track the extent and center of the visible region. + @Code(name: "SearchExampleView.swift", file: SearchViewStep5) + } + + @Step { + Add a `MapView` and overlay the `SearchView`. + @Code(name: "SearchExampleView.swift", file: SearchViewStep6) + } + + @Step { + Add additional modifiers, further interconnecting the `SearchView` and `MapView`. + @Code(name: "SearchExampleView.swift", file: SearchViewStep7) + } + + @Step { + Use `MapView` modifiers to track stateful information important to the `SearchView`. + @Code(name: "SearchExampleView.swift", file: SearchViewStep8) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/ToolkitTutorials.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/ToolkitTutorials.tutorial new file mode 100644 index 000000000..1bc09a272 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/ToolkitTutorials.tutorial @@ -0,0 +1,83 @@ +@Tutorials(name: "Using the ArcGIS Maps SDK for Swift Toolkit") { + @Intro(title: "Toolkit Tutorials") { + The ArcGIS Maps SDK for Swift Toolkit contains components that will simplify your Swift app + development. It is built off of the new ArcGIS Maps SDK for Swift. + } + + @Chapter(name: "Authenticator") { + The `Authenticator` is a configurable object that handles authentication challenges. It will + display a user interface when network and ArcGIS authentication challenges occur. + @Image(source: Authenticator, alt: "An image of the Authenticator component.") + @TutorialReference(tutorial: "doc:AuthenticatorTutorial") + } + + @Chapter(name: "BasemapGallery") { + The `BasemapGallery` displays a collection of basemaps from ArcGIS Online, a user-defined + portal, or an array of `BasemapGalleryItem` objects. + @Image(source: BasemapGallery, alt: "An image of the BasemapGallery component.") + @TutorialReference(tutorial: "doc:BasemapGalleryTutorial") + } + + @Chapter(name: "Bookmarks") { + The `Bookmarks` component will display a list of bookmarks and allow the user to select a + bookmark and perform some action. + @Image(source: Bookmarks, alt: "An image of the Bookmarks component.") + @TutorialReference(tutorial: "doc:BookmarksTutorial") + } + + @Chapter(name: "Compass") { + A `Compass` (alias North arrow) shows where north is in a MapView. + @Image(source: Compass, alt: "An image of the Compass component.") + @TutorialReference(tutorial: "doc:CompassTutorial") + } + + @Chapter(name: "FloatingPanel") { + A `FloatingPanel` is a view that overlays a view and supplies view-related content. + @Image(source: FloatingPanel, alt: "An image of the FloatingPanel component.") + @TutorialReference(tutorial: "doc:FloatingPanelTutorial") + } + + @Chapter(name: "FloorFilter") { + The `FloorFilter` component simplifies visualization of GIS data for a specific floor of a + building in your application. + @Image(source: FloorFilter, alt: "An image of the FloorFilter component.") + @TutorialReference(tutorial: "doc:FloorFilterTutorial") + } + + @Chapter(name: "OverviewMap") { + `OverviewMap` is a small, secondary `MapView` (sometimes called an "inset map") + superimposed on an existing `GeoView`, which shows a representation of the current + visible area (for a `MapView`) or viewpoint (for a `SceneView`). + @Image(source: OverviewMap, alt: "An image of the OverviewMap component.") + @TutorialReference(tutorial: "doc:OverviewMapTutorial") + } + + @Chapter(name: "PopupView") { + The `PopupView` component will display a popup for an individual feature. This includes + showing the feature's title, attributes, custom description, media, and attachments. + @Image(source: PopupView, alt: "An image of the PopupView component.") + @TutorialReference(tutorial: "doc:PopupViewTutorial") + } + + @Chapter(name: "Scalebar") { + A scalebar displays the representation of an accurate linear measurement on the map. It + provides a visual indication through which users can determine the size of features or the + distance between features on a map. + @Image(source: Scalebar, alt: "An image of the Scalebar component.") + @TutorialReference(tutorial: "doc:ScalebarTutorial") + } + + @Chapter(name: "SearchView") { + `SearchView` enables searching using one or more locators, with support for suggestions, + automatic zooming, and custom search sources. + @Image(source: SearchView, alt: "An image of the SearchView component.") + @TutorialReference(tutorial: "doc:SearchViewTutorial") + } + + @Chapter(name: "UtilityNetworkTrace") { + `UtilityNetworkTrace` runs traces on a webmap published with a utility network and trace + configurations. + @Image(source: UtilityNetworkTrace, alt: "An image of the UtilityNetworkTrace component.") + @TutorialReference(tutorial: "doc:UtilityNetworkTraceTutorial") + } +} diff --git a/Sources/ArcGISToolkit/Documentation.docc/Tutorials/UtilityNetworkTraceTutorial.tutorial b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/UtilityNetworkTraceTutorial.tutorial new file mode 100644 index 000000000..1f5142ac3 --- /dev/null +++ b/Sources/ArcGISToolkit/Documentation.docc/Tutorials/UtilityNetworkTraceTutorial.tutorial @@ -0,0 +1,43 @@ +@Tutorial(time: 10) { + @Intro(title: "UtilityNetworkTrace Tutorial") { + `UtilityNetworkTrace` runs traces on a webmap published with a utility network and trace + configurations. + } + + @Section(title: "Using the UtilityNetworkTrace component") { + @ContentAndMedia { + @Image(source: UtilityNetworkTrace, alt: "An image of the UtilityNetworkTrace component.") + } + + @Steps { + @Step { + Add important stateful properties. A map containing at least one utility network is + required. A `Point` and `CGPoint` will help allow the component to create trace + starting points at tap locations. A `GraphicsOverlay` will allow the component to + visually display trace results. + @Code(name: "UtilityNetworkTraceExampleView.swift", file: UtilityNetworkTraceStep1) + } + + @Step { + Add a `MapView`, optionally setting credentials if needed within a task. + @Code(name: "UtilityNetworkTraceExampleView.swift", file: UtilityNetworkTraceStep2) + } + + @Step { + Track viewpoint changes. + @Code(name: "UtilityNetworkTraceExampleView.swift", file: UtilityNetworkTraceStep3) + } + + @Step { + Track any tap gestures on the map view. When the user is adding starting points, + the trace tool needs to know the tap locations to detect the desired network element. + @Code(name: "UtilityNetworkTraceExampleView.swift", file: UtilityNetworkTraceStep4) + } + + @Step { + Overlay the `UtilityNetworkTrace` on the `MapView` with a `FloatingPanel`. + @Code(name: "UtilityNetworkTraceExampleView.swift", file: UtilityNetworkTraceStep5) + } + } + } +} diff --git a/Sources/ArcGISToolkit/Extensions/Bundle.swift b/Sources/ArcGISToolkit/Extensions/Bundle.swift new file mode 100644 index 000000000..cd531bbf3 --- /dev/null +++ b/Sources/ArcGISToolkit/Extensions/Bundle.swift @@ -0,0 +1,23 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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 Foundation + +extension Bundle { + /// The identifier for the ArcGISToolkit module. + static var toolkitIdentifier: String { "com.esri.ArcGISToolkit" } + + /// The toolkit module, which is either the resource bundle or the + /// ArcGISToolkit framework, depending on how the toolkit was built. + static let toolkitModule = Bundle(identifier: toolkitIdentifier) ?? .module +} diff --git a/Sources/ArcGISToolkit/Resources/ar.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/ar.lproj/Localizable.strings new file mode 100755 index 000000000..4a4891cc3 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/ar.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "حذف"; diff --git a/Sources/ArcGISToolkit/Resources/ca.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/ca.lproj/Localizable.strings new file mode 100644 index 000000000..0eafc4154 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/ca.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Suprimeix"; diff --git a/Sources/ArcGISToolkit/Resources/cs.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/cs.lproj/Localizable.strings new file mode 100755 index 000000000..8e11accbf --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/cs.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Smazat"; diff --git a/Sources/ArcGISToolkit/Resources/da.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/da.lproj/Localizable.strings new file mode 100755 index 000000000..72262db89 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/da.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Slet"; diff --git a/Sources/ArcGISToolkit/Resources/de.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/de.lproj/Localizable.strings new file mode 100755 index 000000000..3ae6cb50b --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/de.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Löschen"; diff --git a/Sources/ArcGISToolkit/Resources/el.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/el.lproj/Localizable.strings new file mode 100755 index 000000000..d43bf6d48 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/el.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Διαγραφή"; diff --git a/Sources/ArcGISToolkit/Resources/en.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/en.lproj/Localizable.strings index 9c5c553bb..4c776cf67 100644 --- a/Sources/ArcGISToolkit/Resources/en.lproj/Localizable.strings +++ b/Sources/ArcGISToolkit/Resources/en.lproj/Localizable.strings @@ -1,7 +1,281 @@ +/* A label declaring the number of starting points selected for a utility network trace. */ +"%lld selected" = "%lld selected"; + +/* An alert message indicating that a certificate is required to access +content on a remote host. The variable is the host that prompted the challenge. */ +"A certificate is required to access content on %@." = "A certificate is required to access content on %@."; + +/* A button to add new utility trace starting points. */ +"Add new" = "Add new"; + +/* A section header for advanced options. */ +"Advanced Options" = "Advanced Options"; + +/* A button allowing users to view a list of all sites defined in a floor aware map. */ +"All sites" = "All sites"; + +/* No comment provided by engineer. */ +"All Sites" = "All Sites"; + +/* A message describing the outcome of clearing all utility network trace results. */ +"All the trace inputs and results will be lost." = "All the trace inputs and results will be lost."; + +/* A button indicating the user accepts a potentially dangerous action. */ +"Allow" = "Allow"; + +/* No comment provided by engineer. */ +"An error occurred loading the image: %@." = "An error occurred loading the image: %@."; + +/* No comment provided by engineer. */ +"Attachments" = "Attachments"; + +/* No comment provided by engineer. */ +"Attributes" = "Attributes"; + +/* No comment provided by engineer. */ +"Authentication Required" = "Authentication Required"; + +/* No comment provided by engineer. */ +"Bookmarks" = "Bookmarks"; + +/* No comment provided by engineer. */ +"Browse For Certificate" = "Browse For Certificate"; + +/* No comment provided by engineer. */ +"Cancel" = "Cancel"; + +/* No comment provided by engineer. */ +"Cancel starting point selection" = "Cancel starting point selection"; + +/* No comment provided by engineer. */ +"Certificate Required" = "Certificate Required"; + +/* A label indicating that the remote host's certificate is not trusted. */ +"Certificate Trust Warning" = "Certificate Trust Warning"; + +/* No comment provided by engineer. */ +"Clear All Results" = "Clear All Results"; + +/* No comment provided by engineer. */ +"Clear all results?" = "Clear all results?"; + +/* No comment provided by engineer. */ +"Color" = "Color"; + +/* An compass description to be read by a screen reader describing the +current heading. The first variable being a degree value and the +second being a corresponding cardinal direction (north, northeast, +east, etc.). */ +"Compass, heading %lld degrees %@" = "Compass, heading %lld degrees %@"; + +/* No comment provided by engineer. */ +"Continue" = "Continue"; + +/* No comment provided by engineer. */ "Could not access the certificate file." = "Could not access the certificate file."; +/* A warning that the host service (challenge.host) is providing a potentially unsafe certificate. */ +"Dangerous: The certificate provided by '%@' is not signed by a trusted authority." = "Dangerous: The certificate provided by '%@' is not signed by a trusted authority."; + +/* No comment provided by engineer. */ +"Delete" = "Delete"; + +/* A button to close the bookmark selection menu. */ +"Done" = "Done"; + +/* No comment provided by engineer. */ +"Duplicate starting points cannot be added." = "Duplicate starting points cannot be added."; + +/* No comment provided by engineer. */ +"Element could not be identified." = "Element could not be identified."; + +/* No comment provided by engineer. */ +"Error" = "Error"; + +/* No comment provided by engineer. */ +"Error importing certificate" = "Error importing certificate"; + +/* An error to be displayed when a basemap chosen from the basemap gallery fails to load. */ +"Error loading basemap." = "Error loading basemap."; + +/* No comment provided by engineer. */ +"Evaluating popup expressions…" = "Evaluating popup expressions…"; + +/* No comment provided by engineer. */ +"Failed to set starting point" = "Failed to set starting point"; + +/* No comment provided by engineer. */ +"Feature Results" = "Feature Results"; + +/* No comment provided by engineer. */ +"Field" = "Field"; + +/* No comment provided by engineer. */ +"Fields" = "Fields"; + +/* A search field allowing user to filter a list of facilities by name. */ +"Filter facilities" = "Filter facilities"; + +/* A search field allowing user to filter a list of sites by name. */ +"Filter sites" = "Filter sites"; + +/* No comment provided by engineer. */ +"Find a place or address" = "Find a place or address"; + +/* No comment provided by engineer. */ +"Fraction Along Edge" = "Fraction Along Edge"; + +/* No comment provided by engineer. */ +"Function Results" = "Function Results"; + +/* No comment provided by engineer. */ +"Media" = "Media"; + +/* No comment provided by engineer. */ +"Mode" = "Mode"; + +/* No comment provided by engineer. */ +"Name" = "Name"; + +/* No comment provided by engineer. */ +"Network" = "Network"; + +/* No comment provided by engineer. */ +"New trace" = "New trace"; + +/* No comment provided by engineer. */ +"No bookmarks" = "No bookmarks"; + +/* No comment provided by engineer. */ +"No configurations available" = "No configurations available"; + +/* No comment provided by engineer. */ +"No matches found" = "No matches found"; + +/* A message to show when there are no results or suggestions. */ +"No results found" = "No results found"; + +/* No comment provided by engineer. */ +"No trace types found." = "No trace types found."; + +/* No comment provided by engineer. */ +"No utility networks found." = "No utility networks found."; + +/* No comment provided by engineer. */ +"None selected" = "None selected"; + +/* A trace function output result is not available. */ +"Not Available" = "Not Available"; + +/* A string identifying a utility network object. */ +"Object ID: %@" = "Object ID: %@"; + +/* No comment provided by engineer. */ +"OK" = "OK"; + +/* No comment provided by engineer. */ +"Password" = "Password"; + +/* No comment provided by engineer. */ +"Password Required" = "Password Required"; + +/* No comment provided by engineer. */ +"Please enter a password for the chosen certificate." = "Please enter a password for the chosen certificate."; + +/* No comment provided by engineer. */ +"Please set at least 1 starting location." = "Please set at least 1 starting location."; + +/* No comment provided by engineer. */ +"Please set at least 2 starting locations." = "Please set at least 2 starting locations."; + +/* An error message shown when a popup cannot be displayed. The +variable provides additional data. */ +"Popup evaluation failed: %@" = "Popup evaluation failed: %@"; + +/* A button to show when a user has panned the map away from the +original search location. */ +"Repeat Search Here" = "Repeat Search Here"; + +/* No comment provided by engineer. */ +"Results" = "Results"; + +/* No comment provided by engineer. */ +"Search Query" = "Search Query"; + +/* No comment provided by engineer. */ +"Select a bookmark" = "Select a bookmark"; + +/* No comment provided by engineer. */ +"Select a facility" = "Select a facility"; + +/* No comment provided by engineer. */ +"Sites" = "Sites"; + +/* No comment provided by engineer. */ +"Spatial reference mismatch." = "Spatial reference mismatch."; + +/* No comment provided by engineer. */ +"Starting Points" = "Starting Points"; + +/* No comment provided by engineer. */ +"Tap on the image for more information." = "Tap on the image for more information."; + +/* No comment provided by engineer. */ +"Terminal Configuration" = "Terminal Configuration"; + +/* No comment provided by engineer. */ +"The basemap does not have a spatial reference." = "The basemap does not have a spatial reference."; + +/* An error to be displayed when a basemap chosen from the basemap gallery fails to +load for an unknown reason. */ +"The basemap failed to load for an unknown reason." = "The basemap failed to load for an unknown reason."; + +/* No comment provided by engineer. */ +"The basemap has a spatial reference that is incompatible with the map." = "The basemap has a spatial reference that is incompatible with the map."; + +/* No comment provided by engineer. */ +"The certificate file or password was invalid." = "The certificate file or password was invalid."; + +/* No comment provided by engineer. */ "The certificate file was invalid." = "The certificate file was invalid."; +/* No comment provided by engineer. */ +"The map does not have a spatial reference." = "The map does not have a spatial reference."; + +/* No comment provided by engineer. */ "The password was invalid." = "The password was invalid."; -"The certificate file or password was invalid." = "The certificate file or password was invalid."; +/* Description of error thrown when a remote image could not be loaded. */ +"The URL could not be reached or did not contain image data" = "The URL could not be reached or did not contain image data"; + +/* No comment provided by engineer. */ +"Trace" = "Trace"; + +/* A label indicating the index of the trace being viewed out of the total number of traces completed. */ +"Trace %lld of %lld" = "Trace %lld of %lld"; + +/* No comment provided by engineer. */ +"Trace Configuration" = "Trace Configuration"; + +/* No comment provided by engineer. */ +"Try Again" = "Try Again"; + +/* A label to use in place of a utility element asset type name. */ +"Unnamed Asset Type" = "Unnamed Asset Type"; + +/* No comment provided by engineer. */ +"Username" = "Username"; + +/* No comment provided by engineer. */ +"Value" = "Value"; + +/* A label explaining that credentials are required to authenticate with specified host. +The host is indicated in the variable. */ +"You must sign in to access '%@'" = "You must sign in to access '%@'"; + +/* A button to change the map to the extent of the selected trace. */ +"Zoom To" = "Zoom To"; + +/* A user option specifying that a map should automatically change to show completed trace results. */ +"Zoom to result" = "Zoom to result"; + diff --git a/Sources/ArcGISToolkit/Resources/es.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/es.lproj/Localizable.strings new file mode 100644 index 000000000..dc7897dac --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/es.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Eliminar"; diff --git a/Sources/ArcGISToolkit/Resources/fi.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/fi.lproj/Localizable.strings new file mode 100755 index 000000000..b658f6bdb --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/fi.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Poista"; diff --git a/Sources/ArcGISToolkit/Resources/fr.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/fr.lproj/Localizable.strings new file mode 100755 index 000000000..9fdf5c26f --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/fr.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Supprimer"; diff --git a/Sources/ArcGISToolkit/Resources/he.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/he.lproj/Localizable.strings new file mode 100755 index 000000000..060ee9c3e --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/he.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "מחיקה"; diff --git a/Sources/ArcGISToolkit/Resources/hr.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/hr.lproj/Localizable.strings new file mode 100755 index 000000000..35e05165f --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/hr.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Izbriši"; diff --git a/Sources/ArcGISToolkit/Resources/hu.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/hu.lproj/Localizable.strings new file mode 100644 index 000000000..aa8c01a28 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/hu.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Törlés"; diff --git a/Sources/ArcGISToolkit/Resources/id.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/id.lproj/Localizable.strings new file mode 100755 index 000000000..f2137c9eb --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/id.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Hapus"; diff --git a/Sources/ArcGISToolkit/Resources/it.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/it.lproj/Localizable.strings new file mode 100755 index 000000000..653846930 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/it.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Elimina"; diff --git a/Sources/ArcGISToolkit/Resources/ja.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/ja.lproj/Localizable.strings new file mode 100755 index 000000000..c183d2159 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/ja.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "削除"; diff --git a/Sources/ArcGISToolkit/Resources/ko.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/ko.lproj/Localizable.strings new file mode 100755 index 000000000..c2e78078a --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/ko.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "삭제"; diff --git a/Sources/ArcGISToolkit/Resources/nb.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/nb.lproj/Localizable.strings new file mode 100755 index 000000000..d999533e9 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/nb.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Slett"; diff --git a/Sources/ArcGISToolkit/Resources/nl.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/nl.lproj/Localizable.strings new file mode 100755 index 000000000..7836c8f0c --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/nl.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Verwijderen"; diff --git a/Sources/ArcGISToolkit/Resources/pl.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/pl.lproj/Localizable.strings new file mode 100755 index 000000000..b07302d6e --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/pl.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Usuń"; diff --git a/Sources/ArcGISToolkit/Resources/pt-BR.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/pt-BR.lproj/Localizable.strings new file mode 100755 index 000000000..96f80fa44 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/pt-BR.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Excluir"; diff --git a/Sources/ArcGISToolkit/Resources/pt-PT.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/pt-PT.lproj/Localizable.strings new file mode 100755 index 000000000..dc7897dac --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/pt-PT.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Eliminar"; diff --git a/Sources/ArcGISToolkit/Resources/ro.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/ro.lproj/Localizable.strings new file mode 100755 index 000000000..dd4c88c6b --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/ro.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Ştergere"; diff --git a/Sources/ArcGISToolkit/Resources/ru.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/ru.lproj/Localizable.strings new file mode 100755 index 000000000..771988fb9 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Удалить"; diff --git a/Sources/ArcGISToolkit/Resources/sk.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/sk.lproj/Localizable.strings new file mode 100644 index 000000000..d467703e3 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/sk.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Zmazať"; diff --git a/Sources/ArcGISToolkit/Resources/sv.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/sv.lproj/Localizable.strings new file mode 100755 index 000000000..9f5ce0d25 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/sv.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Ta bort"; diff --git a/Sources/ArcGISToolkit/Resources/th.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/th.lproj/Localizable.strings new file mode 100755 index 000000000..b3b49f601 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/th.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "ลบ"; diff --git a/Sources/ArcGISToolkit/Resources/tr.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/tr.lproj/Localizable.strings new file mode 100755 index 000000000..84c160749 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/tr.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Sil"; diff --git a/Sources/ArcGISToolkit/Resources/uk.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/uk.lproj/Localizable.strings new file mode 100644 index 000000000..fca5dd76d --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/uk.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Видалення"; diff --git a/Sources/ArcGISToolkit/Resources/vi.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/vi.lproj/Localizable.strings new file mode 100755 index 000000000..adbfff45a --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/vi.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "Xóa"; diff --git a/Sources/ArcGISToolkit/Resources/zh-Hans-CN.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/zh-Hans-CN.lproj/Localizable.strings new file mode 100755 index 000000000..64d9dea52 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/zh-Hans-CN.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "删除"; diff --git a/Sources/ArcGISToolkit/Resources/zh-Hans.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/zh-Hans.lproj/Localizable.strings new file mode 100755 index 000000000..64d9dea52 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "删除"; diff --git a/Sources/ArcGISToolkit/Resources/zh-Hant-HK.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/zh-Hant-HK.lproj/Localizable.strings new file mode 100755 index 000000000..64d9dea52 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/zh-Hant-HK.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "删除"; diff --git a/Sources/ArcGISToolkit/Resources/zh-Hant-TW.lproj/Localizable.strings b/Sources/ArcGISToolkit/Resources/zh-Hant-TW.lproj/Localizable.strings new file mode 100755 index 000000000..64d9dea52 --- /dev/null +++ b/Sources/ArcGISToolkit/Resources/zh-Hant-TW.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* No comment provided by engineer. */ +"Delete" = "删除"; diff --git a/Sources/ArcGISToolkit/Utility/CredentialInputView.swift b/Sources/ArcGISToolkit/Utility/CredentialInputView.swift index 480e51578..fb9158d3b 100644 --- a/Sources/ArcGISToolkit/Utility/CredentialInputView.swift +++ b/Sources/ArcGISToolkit/Utility/CredentialInputView.swift @@ -118,7 +118,7 @@ struct CredentialInputView: UIViewControllerRepresentable { ) textField.autocapitalizationType = .none textField.autocorrectionType = .no - textField.placeholder = "Username" + textField.placeholder = String(localized: "Username", bundle: .toolkitModule) textField.returnKeyType = .next textField.textContentType = .username } @@ -140,7 +140,7 @@ struct CredentialInputView: UIViewControllerRepresentable { textField.delegate = context.coordinator textField.isSecureTextEntry = true - textField.placeholder = "Password" + textField.placeholder = String(localized: "Password", bundle: .toolkitModule) textField.returnKeyType = .go textField.textContentType = .password } @@ -150,6 +150,7 @@ struct CredentialInputView: UIViewControllerRepresentable { uiAlertController.addAction(cancelUIAlertAction) uiAlertController.addAction(continueUIAlertAction) + uiAlertController.preferredAction = continueUIAlertAction return uiAlertController } diff --git a/Sources/ArcGISToolkit/Utility/SearchField.swift b/Sources/ArcGISToolkit/Utility/SearchField.swift index 9ee4ca289..5130a0290 100644 --- a/Sources/ArcGISToolkit/Utility/SearchField.swift +++ b/Sources/ArcGISToolkit/Utility/SearchField.swift @@ -20,16 +20,19 @@ public struct SearchField: View { /// - Parameters: /// - query: The current search query. /// - prompt: The default placeholder displayed when `currentQuery` is empty. + /// - isFocused: A Boolean value indicating whether the text field is focused or not. /// - isResultsButtonHidden: The visibility of the button used to toggle visibility of the results list. /// - isResultListHidden: Binding allowing the user to toggle the visibility of the results list. public init( query: Binding, prompt: String = "", + isFocused: FocusState.Binding, isResultsButtonHidden: Bool = false, isResultListHidden: Binding? = nil ) { self.query = query self.prompt = prompt + self.isFocused = isFocused self.isResultsButtonHidden = isResultsButtonHidden self.isResultListHidden = isResultListHidden } @@ -40,6 +43,9 @@ public struct SearchField: View { /// The default placeholder displayed when `currentQuery` is empty. private let prompt: String + /// A Boolean value indicating whether the text field is focused or not. + private var isFocused: FocusState.Binding + /// The visibility of the button used to toggle visibility of the results list. private let isResultsButtonHidden: Bool @@ -58,6 +64,7 @@ public struct SearchField: View { text: query, prompt: Text(prompt) ) + .focused(isFocused) // Delete text button if !query.wrappedValue.isEmpty { diff --git a/Tests/ArcGISToolkitTests/AngleTests.swift b/Tests/ArcGISToolkitTests/AngleTests.swift index 1a1863575..f0135b669 100644 --- a/Tests/ArcGISToolkitTests/AngleTests.swift +++ b/Tests/ArcGISToolkitTests/AngleTests.swift @@ -16,7 +16,7 @@ import XCTest @testable import ArcGISToolkit class AngleTests: XCTestCase { - /// Tests the behvaior of `Angle`'s normalized member. + /// Tests the behavior of `Angle`'s normalized member. func testNormalizedAngle() { XCTAssertEqual(Angle(degrees: -361.0).normalizedDegrees, 359) XCTAssertEqual(Angle(degrees: -360.0).normalizedDegrees, 0) diff --git a/Tests/ArcGISToolkitTests/BasemapGalleryViewModelTests.swift b/Tests/ArcGISToolkitTests/BasemapGalleryViewModelTests.swift index 156720849..dd3afb34b 100644 --- a/Tests/ArcGISToolkitTests/BasemapGalleryViewModelTests.swift +++ b/Tests/ArcGISToolkitTests/BasemapGalleryViewModelTests.swift @@ -72,7 +72,7 @@ class BasemapGalleryViewModelTests: XCTestCase { // GeoModel should be loaded. XCTAssertEqual(geoModel.loadStatus, .loaded) - XCTAssertIdentical(geoModelViewModel.currentItem?.basemap, geoModel.basemap) + XCTAssertEqual(geoModelViewModel.currentItem?.basemap.name, geoModel.basemap?.name) // Save the array of developer basemap items from AGOL. let developerBasemapItems = basemapGalleryItems diff --git a/Tests/ArcGISToolkitTests/BookmarksTests.swift b/Tests/ArcGISToolkitTests/BookmarksTests.swift index fe403ca09..67e300952 100644 --- a/Tests/ArcGISToolkitTests/BookmarksTests.swift +++ b/Tests/ArcGISToolkitTests/BookmarksTests.swift @@ -45,9 +45,6 @@ final class BookmarksTests: XCTestCase { /// Asserts that the list properly handles a selection when provided a modifier and web map. func testSelectBookmarkWithModifierAndMap() async throws { - let expectation = XCTestExpectation( - description: "Modifier action was performed" - ) let map = Map.portlandTreeSurvey do { try await map.load() @@ -59,20 +56,15 @@ final class BookmarksTests: XCTestCase { get: { _isPresented }, set: {_isPresented = $0 } ) - let action: ((Bookmark) -> Void) = { - expectation.fulfill() - XCTAssertEqual($0.viewpoint, map.bookmarks.first?.viewpoint) - } - var bookmarks = Bookmarks( - isPresented: isPresented, - geoModel: map - ) - bookmarks.selectionChangedAction = action + + var selectedBookmark: Bookmark? + let bookmarks = Bookmarks(isPresented: isPresented,geoModel: map) + .onSelectionChanged { selectedBookmark = $0 } XCTAssertTrue(_isPresented) let firstBookmark = try XCTUnwrap(map.bookmarks.first) bookmarks.selectBookmark(firstBookmark) XCTAssertFalse(_isPresented) - wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(selectedBookmark, map.bookmarks.first) } /// Asserts that the list properly handles a selection when provided a viewpoint. diff --git a/Tests/ArcGISToolkitTests/LoginViewModelTests.swift b/Tests/ArcGISToolkitTests/LoginViewModifierTests.swift similarity index 53% rename from Tests/ArcGISToolkitTests/LoginViewModelTests.swift rename to Tests/ArcGISToolkitTests/LoginViewModifierTests.swift index bd05f3a2e..7902d5443 100644 --- a/Tests/ArcGISToolkitTests/LoginViewModelTests.swift +++ b/Tests/ArcGISToolkitTests/LoginViewModifierTests.swift @@ -14,19 +14,12 @@ import XCTest @testable import ArcGISToolkit -@MainActor final class LoginViewModelTests: XCTestCase { - func testViewModel() { +final class LoginViewModifierTests: XCTestCase { + func testMemberwiseInit() { var signInCalled = false - func signIn(username: String, password: String) { - signInCalled = true - } - var cancelCalled = false - func cancel() { - cancelCalled = true - } - let model = LoginViewModel( + let modifier = LoginViewModifier( challengingHost: "host.com", onSignIn: { _ in signInCalled = true @@ -36,32 +29,17 @@ import XCTest } ) - XCTAssertEqual(model.challengingHost, "host.com") - XCTAssertNotNil(model.signInAction) - XCTAssertNotNil(model.cancelAction) - XCTAssertFalse(model.signInButtonEnabled) - XCTAssertTrue(model.username.isEmpty) - XCTAssertTrue(model.password.isEmpty) + XCTAssertEqual(modifier.challengingHost, "host.com") + XCTAssertNotNil(modifier.onSignIn) + XCTAssertNotNil(modifier.onCancel) XCTAssertFalse(signInCalled) XCTAssertFalse(cancelCalled) - model.username = "abc" - XCTAssertFalse(model.signInButtonEnabled) - - model.password = "123" - XCTAssertTrue(model.signInButtonEnabled) - - model.password = "" - XCTAssertFalse(model.signInButtonEnabled) - - model.password = "123" - XCTAssertTrue(model.signInButtonEnabled) - - model.signIn() + modifier.onSignIn(LoginCredential(username: "", password: "")) XCTAssertTrue(signInCalled) XCTAssertFalse(cancelCalled) - model.cancel() + modifier.onCancel() XCTAssertTrue(cancelCalled) } } diff --git a/Tests/ArcGISToolkitTests/ScalebarTests.swift b/Tests/ArcGISToolkitTests/ScalebarTests.swift index 4b2cdc0c7..832eb7be0 100644 --- a/Tests/ArcGISToolkitTests/ScalebarTests.swift +++ b/Tests/ArcGISToolkitTests/ScalebarTests.swift @@ -27,42 +27,42 @@ class ScalebarTests: XCTestCase { let maxWidth: Double let units: ScalebarUnits let scale: Double - let upp: Double - var useGedeticCalculations: Bool = true - let dL: Double + let unitsPerPoint: Double + var useGeodeticCalculations: Bool = true + let displayLength: Double let labels: [String] } var testCases: [ScalebarTestCase] {[ // Test metric vs imperial units - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 10_000_000, upp: 2645.833333330476, dL: 137, labels: ["0", "100", "200", "300 km"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .imperial, scale: 10_000_000, upp: 2645.833333330476, dL: 147, labels: ["0", "50", "100", "150", "200 mi"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 137, labels: ["0", "100", "200", "300 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .imperial, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 147, labels: ["0", "50", "100", "150", "200 mi"]), // Disable geodetic calculations - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 10_000_000, upp: 2645.833333330476, useGedeticCalculations: false, dL: 151, labels: ["0", "100", "200", "300", "400 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, useGeodeticCalculations: false, displayLength: 151, labels: ["0", "100", "200", "300", "400 km"]), // Test all styles - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .bar, maxWidth: 175, units: .metric, scale: 10_000_000, upp: 2645.833333330476, dL: 171, labels: ["375 km"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .dualUnitLine, maxWidth: 175, units: .metric, scale: 10_000_000, upp: 2645.833333330476, dL: 137, labels: ["0", "100", "200", "300 km"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .graduatedLine, maxWidth: 175, units: .metric, scale: 10_000_000, upp: 2645.833333330476, dL: 137, labels: ["0", "100", "200", "300 km"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .line, maxWidth: 175, units: .metric, scale: 10_000_000, upp: 2645.833333330476, dL: 171, labels: ["375 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .bar, maxWidth: 175, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 171, labels: ["375 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .dualUnitLine, maxWidth: 175, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 137, labels: ["0", "100", "200", "300 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .graduatedLine, maxWidth: 175, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 137, labels: ["0", "100", "200", "300 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .line, maxWidth: 175, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 171, labels: ["375 km"]), // Test alternate widths - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 100, units: .metric, scale: 10_000_000, upp: 2645.833333330476, dL: 80, labels: ["0", "87.5", "175 km"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 300, units: .metric, scale: 10_000_000, upp: 2645.833333330476, dL: 273, labels: ["0", "200", "400", "600 km"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 500, units: .metric, scale: 10_000_000, upp: 2645.833333330476, dL: 456, labels: ["0", "250", "500", "750", "1,000 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 100, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 80, labels: ["0", "87.5", "175 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 300, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 273, labels: ["0", "200", "400", "600 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 500, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 456, labels: ["0", "250", "500", "750", "1,000 km"]), // Test alternate points - ScalebarTestCase(x: -24752697, y: 15406913, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 10_000_000, upp: 2645.833333330476, dL: 128, labels: ["0", "20", "40", "60 km"]), // Artic ocean - ScalebarTestCase(x: -35729271, y: -13943757, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 10_000_000, upp: 2645.833333330476, dL: 153, labels: ["0", "30", "60", "90 km"]), // Near Antartica + ScalebarTestCase(x: -24752697, y: 15406913, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 128, labels: ["0", "20", "40", "60 km"]), // Arctic ocean + ScalebarTestCase(x: -35729271, y: -13943757, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 10_000_000, unitsPerPoint: 2645.833333330476, displayLength: 153, labels: ["0", "30", "60", "90 km"]), // Near Antartica // Test different scales - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 100, upp: 0.02645833333330476, dL: 137, labels: ["0", "1", "2", "3 m"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 1_000, upp: 0.26458333333304757, dL: 137, labels: ["0", "10", "20", "30 m"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 10_000, upp: 2.6458333333304758, dL: 137, labels: ["0", "100", "200", "300 m"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 100_000, upp: 26.458333333304758, dL: 137, labels: ["0", "1", "2", "3 km"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 1_000_000, upp: 264.58333333304756, dL: 137, labels: ["0", "10", "20", "30 km"]), - ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 80_000_000, upp: 21166.666666643807, dL: 143, labels: ["0", "1,250", "2,500 km"]) + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 100, unitsPerPoint: 0.02645833333330476, displayLength: 137, labels: ["0", "1", "2", "3 m"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 1_000, unitsPerPoint: 0.26458333333304757, displayLength: 137, labels: ["0", "10", "20", "30 m"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 10_000, unitsPerPoint: 2.6458333333304758, displayLength: 137, labels: ["0", "100", "200", "300 m"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 100_000, unitsPerPoint: 26.458333333304758, displayLength: 137, labels: ["0", "1", "2", "3 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 1_000_000, unitsPerPoint: 264.58333333304756, displayLength: 137, labels: ["0", "10", "20", "30 km"]), + ScalebarTestCase(x: esriRedlands.x, y: esriRedlands.y, style: .alternatingBar, maxWidth: 175, units: .metric, scale: 80_000_000, unitsPerPoint: 21166.666666643807, displayLength: 143, labels: ["0", "1,250", "2,500 km"]) ]} func testAllCases() { @@ -75,18 +75,18 @@ class ScalebarTests: XCTestCase { ), scale: test.scale ) - let unitsPerPoint = unitsPerPointBinding(test.upp) let viewModel = ScalebarViewModel( test.maxWidth, 0, - spatialReferenceBinding(test.spatialReference), test.style, test.units, - unitsPerPoint, - test.useGedeticCalculations, - viewpoint + test.useGeodeticCalculations ) - XCTAssertEqual(viewModel.displayLength.rounded(), test.dL) + viewModel.update(test.spatialReference) + viewModel.update(test.unitsPerPoint) + viewModel.update(viewpoint) + viewModel.updateScale() + XCTAssertEqual(viewModel.displayLength.rounded(), test.displayLength) XCTAssertEqual(viewModel.labels.count, test.labels.count) for i in 0.. Binding { - var _value = value - return Binding( - get: { _value }, - set: { _value = $0 ?? .zero } - ) - } - - /// Generates a binding to a provided units per point value. - func spatialReferenceBinding(_ value: SpatialReference) -> Binding { - var _value = value - return Binding( - get: { _value }, - set: { - if let newValue = $0 { - _value = newValue - } - } - ) - } } diff --git a/UI Test Runner/UI Test Runner.xcodeproj/project.pbxproj b/UI Test Runner/UI Test Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..3389abff7 --- /dev/null +++ b/UI Test Runner/UI Test Runner.xcodeproj/project.pbxproj @@ -0,0 +1,525 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 7552A7542A573CBB0023DA5A /* UITestRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7552A7532A573CBB0023DA5A /* UITestRunner.swift */; }; + 7552A7562A573CBB0023DA5A /* Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7552A7552A573CBB0023DA5A /* Tests.swift */; }; + 7552A7582A573CBD0023DA5A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7552A7572A573CBD0023DA5A /* Assets.xcassets */; }; + 7552A75B2A573CBD0023DA5A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7552A75A2A573CBD0023DA5A /* Preview Assets.xcassets */; }; + 7552A7622A573D810023DA5A /* BookmarksTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7552A7612A573D810023DA5A /* BookmarksTestView.swift */; }; + 7552A76C2A573DFB0023DA5A /* BookmarksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7552A76B2A573DFB0023DA5A /* BookmarksTests.swift */; }; + 7552A7762A573E900023DA5A /* ArcGISToolkit in Frameworks */ = {isa = PBXBuildFile; productRef = 7552A7752A573E900023DA5A /* ArcGISToolkit */; }; + 75B236692A5CB49B00AEFACE /* BasemapGalleryTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B236682A5CB49B00AEFACE /* BasemapGalleryTestView.swift */; }; + 75B2366B2A5CB8CE00AEFACE /* BasemapGalleryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B2366A2A5CB8CE00AEFACE /* BasemapGalleryTests.swift */; }; + 75B2366D2A5CC78C00AEFACE /* FloorFilterTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B2366C2A5CC78C00AEFACE /* FloorFilterTestView.swift */; }; + 75B2366F2A5CCADC00AEFACE /* FloorFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B2366E2A5CCADC00AEFACE /* FloorFilterTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 7552A76F2A573DFB0023DA5A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7552A7482A573CBB0023DA5A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7552A74F2A573CBB0023DA5A; + remoteInfo = "UI Test Runner"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 7552A7502A573CBB0023DA5A /* UI Test Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "UI Test Runner.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7552A7532A573CBB0023DA5A /* UITestRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRunner.swift; sourceTree = ""; }; + 7552A7552A573CBB0023DA5A /* Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests.swift; sourceTree = ""; }; + 7552A7572A573CBD0023DA5A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 7552A75A2A573CBD0023DA5A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 7552A7612A573D810023DA5A /* BookmarksTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksTestView.swift; sourceTree = ""; }; + 7552A7642A573DA00023DA5A /* arcgis-maps-sdk-swift-toolkit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "arcgis-maps-sdk-swift-toolkit"; path = ..; sourceTree = ""; }; + 7552A7692A573DFB0023DA5A /* UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7552A76B2A573DFB0023DA5A /* BookmarksTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksTests.swift; sourceTree = ""; }; + 75B236682A5CB49B00AEFACE /* BasemapGalleryTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasemapGalleryTestView.swift; sourceTree = ""; }; + 75B2366A2A5CB8CE00AEFACE /* BasemapGalleryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasemapGalleryTests.swift; sourceTree = ""; }; + 75B2366C2A5CC78C00AEFACE /* FloorFilterTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloorFilterTestView.swift; sourceTree = ""; }; + 75B2366E2A5CCADC00AEFACE /* FloorFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloorFilterTests.swift; sourceTree = ""; }; + 75CCCD612A5F691E0098B059 /* UI Test Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "UI Test Runner.entitlements"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7552A74D2A573CBB0023DA5A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7552A7762A573E900023DA5A /* ArcGISToolkit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7552A7662A573DFB0023DA5A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7552A7472A573CBB0023DA5A = { + isa = PBXGroup; + children = ( + 7552A7632A573DA00023DA5A /* Packages */, + 7552A7522A573CBB0023DA5A /* UI Test Runner */, + 7552A76A2A573DFB0023DA5A /* UITests */, + 7552A7512A573CBB0023DA5A /* Products */, + ); + sourceTree = ""; + }; + 7552A7512A573CBB0023DA5A /* Products */ = { + isa = PBXGroup; + children = ( + 7552A7502A573CBB0023DA5A /* UI Test Runner.app */, + 7552A7692A573DFB0023DA5A /* UITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 7552A7522A573CBB0023DA5A /* UI Test Runner */ = { + isa = PBXGroup; + children = ( + 75CCCD612A5F691E0098B059 /* UI Test Runner.entitlements */, + 7552A7532A573CBB0023DA5A /* UITestRunner.swift */, + 7552A7552A573CBB0023DA5A /* Tests.swift */, + 7552A7572A573CBD0023DA5A /* Assets.xcassets */, + 7552A7592A573CBD0023DA5A /* Preview Content */, + 75B236672A5CB32700AEFACE /* TestViews */, + ); + path = "UI Test Runner"; + sourceTree = ""; + }; + 7552A7592A573CBD0023DA5A /* Preview Content */ = { + isa = PBXGroup; + children = ( + 7552A75A2A573CBD0023DA5A /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 7552A7632A573DA00023DA5A /* Packages */ = { + isa = PBXGroup; + children = ( + 7552A7642A573DA00023DA5A /* arcgis-maps-sdk-swift-toolkit */, + ); + name = Packages; + sourceTree = ""; + }; + 7552A76A2A573DFB0023DA5A /* UITests */ = { + isa = PBXGroup; + children = ( + 75B2366A2A5CB8CE00AEFACE /* BasemapGalleryTests.swift */, + 7552A76B2A573DFB0023DA5A /* BookmarksTests.swift */, + 75B2366E2A5CCADC00AEFACE /* FloorFilterTests.swift */, + ); + path = UITests; + sourceTree = ""; + }; + 75B236672A5CB32700AEFACE /* TestViews */ = { + isa = PBXGroup; + children = ( + 75B236682A5CB49B00AEFACE /* BasemapGalleryTestView.swift */, + 7552A7612A573D810023DA5A /* BookmarksTestView.swift */, + 75B2366C2A5CC78C00AEFACE /* FloorFilterTestView.swift */, + ); + path = TestViews; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7552A74F2A573CBB0023DA5A /* UI Test Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7552A75E2A573CBD0023DA5A /* Build configuration list for PBXNativeTarget "UI Test Runner" */; + buildPhases = ( + 7552A74C2A573CBB0023DA5A /* Sources */, + 7552A74D2A573CBB0023DA5A /* Frameworks */, + 7552A74E2A573CBB0023DA5A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "UI Test Runner"; + packageProductDependencies = ( + 7552A7752A573E900023DA5A /* ArcGISToolkit */, + ); + productName = "UI Test Runner"; + productReference = 7552A7502A573CBB0023DA5A /* UI Test Runner.app */; + productType = "com.apple.product-type.application"; + }; + 7552A7682A573DFB0023DA5A /* UITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7552A7712A573DFB0023DA5A /* Build configuration list for PBXNativeTarget "UITests" */; + buildPhases = ( + 7552A7652A573DFB0023DA5A /* Sources */, + 7552A7662A573DFB0023DA5A /* Frameworks */, + 7552A7672A573DFB0023DA5A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7552A7702A573DFB0023DA5A /* PBXTargetDependency */, + ); + name = UITests; + productName = UITests; + productReference = 7552A7692A573DFB0023DA5A /* UITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7552A7482A573CBB0023DA5A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1400; + LastUpgradeCheck = 1400; + TargetAttributes = { + 7552A74F2A573CBB0023DA5A = { + CreatedOnToolsVersion = 14.0; + }; + 7552A7682A573DFB0023DA5A = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 7552A74F2A573CBB0023DA5A; + }; + }; + }; + buildConfigurationList = 7552A74B2A573CBB0023DA5A /* Build configuration list for PBXProject "UI Test Runner" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7552A7472A573CBB0023DA5A; + productRefGroup = 7552A7512A573CBB0023DA5A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7552A74F2A573CBB0023DA5A /* UI Test Runner */, + 7552A7682A573DFB0023DA5A /* UITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7552A74E2A573CBB0023DA5A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7552A75B2A573CBD0023DA5A /* Preview Assets.xcassets in Resources */, + 7552A7582A573CBD0023DA5A /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7552A7672A573DFB0023DA5A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7552A74C2A573CBB0023DA5A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7552A7562A573CBB0023DA5A /* Tests.swift in Sources */, + 7552A7542A573CBB0023DA5A /* UITestRunner.swift in Sources */, + 75B2366D2A5CC78C00AEFACE /* FloorFilterTestView.swift in Sources */, + 7552A7622A573D810023DA5A /* BookmarksTestView.swift in Sources */, + 75B236692A5CB49B00AEFACE /* BasemapGalleryTestView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7552A7652A573DFB0023DA5A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 75B2366F2A5CCADC00AEFACE /* FloorFilterTests.swift in Sources */, + 75B2366B2A5CB8CE00AEFACE /* BasemapGalleryTests.swift in Sources */, + 7552A76C2A573DFB0023DA5A /* BookmarksTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 7552A7702A573DFB0023DA5A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7552A74F2A573CBB0023DA5A /* UI Test Runner */; + targetProxy = 7552A76F2A573DFB0023DA5A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 7552A75C2A573CBD0023DA5A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 7552A75D2A573CBD0023DA5A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7552A75F2A573CBD0023DA5A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "UI Test Runner/UI Test Runner.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"UI Test Runner/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 200.2.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.esri.arcgis-swift-sdk-toolkit-ui-test-runner.UI-Test-Runner"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,6"; + }; + name = Debug; + }; + 7552A7602A573CBD0023DA5A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "UI Test Runner/UI Test Runner.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"UI Test Runner/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 200.2.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.esri.arcgis-swift-sdk-toolkit-ui-test-runner.UI-Test-Runner"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,6"; + }; + name = Release; + }; + 7552A7722A573DFB0023DA5A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.esri.arcgis-swift-sdk-toolkit-ui-test-runner.UITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = "UI Test Runner"; + }; + name = Debug; + }; + 7552A7732A573DFB0023DA5A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.esri.arcgis-swift-sdk-toolkit-ui-test-runner.UITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = "UI Test Runner"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7552A74B2A573CBB0023DA5A /* Build configuration list for PBXProject "UI Test Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7552A75C2A573CBD0023DA5A /* Debug */, + 7552A75D2A573CBD0023DA5A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7552A75E2A573CBD0023DA5A /* Build configuration list for PBXNativeTarget "UI Test Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7552A75F2A573CBD0023DA5A /* Debug */, + 7552A7602A573CBD0023DA5A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7552A7712A573DFB0023DA5A /* Build configuration list for PBXNativeTarget "UITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7552A7722A573DFB0023DA5A /* Debug */, + 7552A7732A573DFB0023DA5A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7552A7752A573E900023DA5A /* ArcGISToolkit */ = { + isa = XCSwiftPackageProductDependency; + productName = ArcGISToolkit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 7552A7482A573CBB0023DA5A /* Project object */; +} diff --git a/UI Test Runner/UI Test Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/UI Test Runner/UI Test Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/UI Test Runner/UI Test Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/UI Test Runner/UI Test Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/UI Test Runner/UI Test Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/UI Test Runner/UI Test Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/UI Test Runner/UI Test Runner/Assets.xcassets/AccentColor.colorset/Contents.json b/UI Test Runner/UI Test Runner/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/UI Test Runner/UI Test Runner/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/UI Test Runner/UI Test Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/UI Test Runner/UI Test Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..13613e3ee --- /dev/null +++ b/UI Test Runner/UI Test Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/UI Test Runner/UI Test Runner/Assets.xcassets/Contents.json b/UI Test Runner/UI Test Runner/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/UI Test Runner/UI Test Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/UI Test Runner/UI Test Runner/Preview Content/Preview Assets.xcassets/Contents.json b/UI Test Runner/UI Test Runner/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/UI Test Runner/UI Test Runner/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/UI Test Runner/UI Test Runner/TestViews/BasemapGalleryTestView.swift b/UI Test Runner/UI Test Runner/TestViews/BasemapGalleryTestView.swift new file mode 100644 index 000000000..5584321e9 --- /dev/null +++ b/UI Test Runner/UI Test Runner/TestViews/BasemapGalleryTestView.swift @@ -0,0 +1,44 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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 ArcGIS +import ArcGISToolkit +import SwiftUI + +struct BasemapGalleryTestView: View { + @State private var map = Map(basemapStyle: .arcGISImagery) + + @State private var basemaps = initialBasemaps() + + var body: some View { + MapView(map: map) + .sheet(isPresented: .constant(true)) { + BasemapGallery(items: basemaps, geoModel: map) + .style(.grid(maxItemWidth: 100)) + } + } + + private static func initialBasemaps() -> [BasemapGalleryItem] { + let identifiers = [ + "46a87c20f09e4fc48fa3c38081e0cae6", + "f33a34de3a294590ab48f246e99958c9", + "52bdc7ab7fb044d98add148764eaa30a", // <<== mismatched spatial reference + "3a8d410a4a034a2ba9738bb0860d68c4" // <<== incorrect portal item type + ] + + return identifiers.map { identifier in + let url = URL(string: "https://www.arcgis.com/home/item.html?id=\(identifier)")! + return BasemapGalleryItem(basemap: Basemap(item: PortalItem(url: url)!)) + } + } +} diff --git a/UI Test Runner/UI Test Runner/TestViews/BookmarksTestView.swift b/UI Test Runner/UI Test Runner/TestViews/BookmarksTestView.swift new file mode 100644 index 000000000..3ceaad6c4 --- /dev/null +++ b/UI Test Runner/UI Test Runner/TestViews/BookmarksTestView.swift @@ -0,0 +1,53 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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 ArcGIS +import ArcGISToolkit +import SwiftUI + +struct BookmarksTestView: View { + /// The `Map` with predefined bookmarks. + @State private var map = Map(url: URL(string: "https://www.arcgis.com/home/item.html?id=16f1b8ba37b44dc3884afc8d5f454dd2")!)! + + /// The last selected bookmark. + @State private var selectedBookmark: Bookmark? + + /// Indicates if the `Bookmarks` component is shown or not. + /// - Remark: This allows a developer to control when the `Bookmarks` component is + /// shown/hidden, whether that be in a group of options or a standalone button. + @State private var showingBookmarks = false + + var body: some View { + MapView(map: map, viewpoint: selectedBookmark?.viewpoint) + .sheet(isPresented: $showingBookmarks) { + Bookmarks( + isPresented: $showingBookmarks, + geoModel: map + ) + .onSelectionChanged { selectedBookmark = $0 } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Bookmarks") { + showingBookmarks.toggle() + } + } + + if let selectedBookmark { + ToolbarItem(placement: .bottomBar) { + Text(selectedBookmark.name) + } + } + } + } +} diff --git a/UI Test Runner/UI Test Runner/TestViews/FloorFilterTestView.swift b/UI Test Runner/UI Test Runner/TestViews/FloorFilterTestView.swift new file mode 100644 index 000000000..a722cca55 --- /dev/null +++ b/UI Test Runner/UI Test Runner/TestViews/FloorFilterTestView.swift @@ -0,0 +1,84 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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 ArcGIS +import ArcGISToolkit +import SwiftUI + +struct FloorFilterTestView: View { + /// Determines the arrangement of the inner `FloorFilter` UI components. + private var floorFilterAlignment: Alignment { .bottomLeading } + + /// The height of the map view's attribution bar. + @State private var attributionBarHeight = 0.0 + + /// Determines the appropriate time to initialize the `FloorFilter`. + @State private var isMapLoaded = false + + /// A Boolean value indicating whether the map is currently being navigated. + @State private var isNavigating = false + + /// The `Map` displayed in the `MapView`. + @State private var map = Map( + item: PortalItem( + portal: .arcGISOnline(connection: .anonymous), + id: Item.ID("b4b599a43a474d33946cf0df526426f5")! + ) + ) + + /// The initial viewpoint of the map. + @State private var viewpoint: Viewpoint? = Viewpoint( + center: Point( + x: -117.19496, + y: 34.05713, + spatialReference: .wgs84 + ), + scale: 100_000 + ) + + var body: some View { + MapView(map: map, viewpoint: viewpoint) + .onAttributionBarHeightChanged { newHeight in + withAnimation { attributionBarHeight = newHeight } + } + .onNavigatingChanged { + isNavigating = $0 + } + .onViewpointChanged(kind: .centerAndScale) { + viewpoint = $0 + } + // Preserve the current viewpoint when a keyboard is presented in landscape. + .ignoresSafeArea(.keyboard, edges: .bottom) + .overlay(alignment: floorFilterAlignment) { + if isMapLoaded, + let floorManager = map.floorManager { + FloorFilter( + floorManager: floorManager, + alignment: floorFilterAlignment, + viewpoint: $viewpoint, + isNavigating: $isNavigating + ) + .frame( + maxWidth: 400, + maxHeight: 400 + ) + .padding([.horizontal], 10) + .padding([.vertical], 10 + attributionBarHeight) + } + } + .task { + try? await map.load() + isMapLoaded = map.loadStatus == .loaded + } + } +} diff --git a/UI Test Runner/UI Test Runner/Tests.swift b/UI Test Runner/UI Test Runner/Tests.swift new file mode 100644 index 000000000..92792b4e2 --- /dev/null +++ b/UI Test Runner/UI Test Runner/Tests.swift @@ -0,0 +1,27 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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 SwiftUI + +struct Tests: View { + var body: some View { + NavigationView { + List { + NavigationLink("Basemap Gallery Tests", destination: BasemapGalleryTestView()) + NavigationLink("Bookmarks Tests", destination: BookmarksTestView()) + NavigationLink("Floor Filter Tests", destination: FloorFilterTestView()) + } + } + .navigationViewStyle(.stack) + } +} diff --git a/UI Test Runner/UI Test Runner/UI Test Runner.entitlements b/UI Test Runner/UI Test Runner/UI Test Runner.entitlements new file mode 100644 index 000000000..ee95ab7e5 --- /dev/null +++ b/UI Test Runner/UI Test Runner/UI Test Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/UI Test Runner/UI Test Runner/UITestRunner.swift b/UI Test Runner/UI Test Runner/UITestRunner.swift new file mode 100644 index 000000000..76a604d2b --- /dev/null +++ b/UI Test Runner/UI Test Runner/UITestRunner.swift @@ -0,0 +1,28 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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 ArcGIS +import SwiftUI + +@main +struct UITestRunner: App { + var body: some SwiftUI.Scene { + WindowGroup { + Tests() + } + } + + init() { + ArcGISEnvironment.apiKey = APIKey("<#APIKey#>") + } +} diff --git a/UI Test Runner/UITests/BasemapGalleryTests.swift b/UI Test Runner/UITests/BasemapGalleryTests.swift new file mode 100644 index 000000000..3bf119cf5 --- /dev/null +++ b/UI Test Runner/UITests/BasemapGalleryTests.swift @@ -0,0 +1,75 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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 XCTest + +final class BasemapGalleryTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + /// Test general usage of the Basemap Gallery component. + func testBasemapGallery() throws { + let app = XCUIApplication() + app.launch() + + let basemapGalleryTestsButton = app.buttons["Basemap Gallery Tests"] + let openStreetMapBlueprintButton = app.buttons["OpenStreetMap (Blueprint)"] + let nationalGeographicStyleMapButton = app.buttons["National Geographic Style Map"] + let worldImageryButton = app.buttons["World_Imagery (WGS 84)"] + let spatialReferenceErrorText = app.staticTexts["Spatial reference mismatch."] + let okButton = app.buttons["OK"] + + // Open the Basemap Gallery component test view. + XCTAssertTrue( + basemapGalleryTestsButton.exists, + "The Basemap Gallery Tests button wasn't found." + ) + basemapGalleryTestsButton.tap() + + // Select two basemaps that should open without error. + XCTAssertTrue( + openStreetMapBlueprintButton.waitForExistence(timeout: 2), + "The OpenStreetMap (Blueprint) button wasn't found within 2 seconds." + ) + openStreetMapBlueprintButton.tap() + XCTAssertTrue( + nationalGeographicStyleMapButton.exists, + "The National Geographic Style Map button wasn't found." + ) + nationalGeographicStyleMapButton.tap() + + // Select a basemap that will trigger an error. + XCTAssertTrue( + worldImageryButton.exists, + "The World Imagery button wasn't found." + ) + worldImageryButton.tap() + + // Verify that a spatial reference error was presented after a few moments. + XCTAssertTrue( + spatialReferenceErrorText.waitForExistence(timeout: 2), + "The spatial reference error text wasn't found within 2 seconds." + ) + + // Dismiss the error. + XCTAssertTrue(okButton.exists, "The OK button wasn't found.") + okButton.tap() + + // Verify that a spatial reference error was dismissed. + XCTAssertFalse( + spatialReferenceErrorText.exists, + "The spatial reference error text was unexpectedly found after dismissing the error." + ) + } +} diff --git a/UI Test Runner/UITests/BookmarksTests.swift b/UI Test Runner/UITests/BookmarksTests.swift new file mode 100644 index 000000000..363f8f3c3 --- /dev/null +++ b/UI Test Runner/UITests/BookmarksTests.swift @@ -0,0 +1,75 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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 XCTest + +final class BookmarksTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + /// Test general usage of the Bookmarks component. + func testBookmarks() throws { + let app = XCUIApplication() + app.launch() + + let bookmarksTestsButton = app.buttons["Bookmarks Tests"] + let bookmarksButton = app.buttons["Bookmarks"].firstMatch + let selectABookmarkText = app.staticTexts["Select a bookmark"] + let giantSequoiasButton = app.buttons["Giant Sequoias of Willamette Blvd"].firstMatch + let giantSequoiasLabel = app.staticTexts["Giant Sequoias of Willamette Blvd"] + let historicLaddsButton = app.buttons["Historic Ladd's Addition"].firstMatch + let historicLaddsLabel = app.staticTexts["Historic Ladd's Addition"] + + // Open the Bookmarks component test view. + XCTAssertTrue(bookmarksTestsButton.exists, "The Bookmarks Tests button wasn't found.") + bookmarksTestsButton.tap() + + // Open the bookmark selection view. + XCTAssertTrue(bookmarksButton.exists, "The Bookmarks button wasn't found.") + bookmarksButton.tap() + + // Verify that the directive UI label is present. + XCTAssertTrue(selectABookmarkText.exists, "The Select a bookmark text wasn't found.") + + // Select a bookmark and confirm the component notified the test view of the selection. + XCTAssertTrue(giantSequoiasButton.exists, "The Giant Sequoias button wasn't found.") + giantSequoiasButton.tap() + + // Confirm the selection was made. + XCTAssertTrue( + giantSequoiasLabel.exists, + "The Giant Sequoias label confirming the bookmark selection wasn't found." + ) + + // Verify that the bookmarks selection view is no longer present. + XCTAssertFalse( + selectABookmarkText.exists, + "The Select a bookmark text was unexpectedly found." + ) + + // Re-open the bookmark selection view. + XCTAssertTrue(bookmarksButton.exists, "The Bookmarks button wasn't found.") + bookmarksButton.tap() + + // Select a bookmark and confirm the component notified the test view of the new selection. + XCTAssertTrue(historicLaddsButton.exists, "The Historic Ladd's button wasn't found.") + historicLaddsButton.tap() + + // Confirm the selection was made. + XCTAssertTrue( + historicLaddsLabel.exists, + "The Historic Ladd's label confirming the bookmark selection wasn't found." + ) + } +} diff --git a/UI Test Runner/UITests/FloorFilterTests.swift b/UI Test Runner/UITests/FloorFilterTests.swift new file mode 100644 index 000000000..2c76f7fae --- /dev/null +++ b/UI Test Runner/UITests/FloorFilterTests.swift @@ -0,0 +1,69 @@ +// Copyright 2023 Esri. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// 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 XCTest + +final class FloorFilterTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + /// Test general usage of the Floor Filter component. + func testFloorFilter() throws { + let app = XCUIApplication() + app.launch() + + let filterButton = app.buttons["Business"] + let researchAnnexButton = app.buttons["Research Annex"] + let latticeText = app.staticTexts["Lattice"] + let levelEightText = app.scrollViews.otherElements.staticTexts["8"] + let levelOneText = app.staticTexts["1"] + let collapseButton = app.buttons["Go Down"] + + // Open the Floor Filter component test view. + app.buttons["Floor Filter Tests"].tap() + + // Wait for floor aware data to load and then open the filter. + XCTAssertTrue( + filterButton.waitForExistence(timeout: 5), + "The filter button wasn't found within 5 seconds." + ) + filterButton.tap() + + // Select the site named "Research Annex". + XCTAssertTrue( + researchAnnexButton.waitForExistence(timeout: 2), + "The Research Annex button wasn't found within 2 seconds." + ) + researchAnnexButton.tap() + + // Select the facility named "Lattice". + XCTAssertTrue(latticeText.exists, "The Lattice text wasn't found.") + latticeText.tap() + + // Select the level labeled "8". + XCTAssertTrue(levelEightText.exists, "The level eight text wasn't found.") + levelEightText.tap() + + // Verify that the level selector is not collapsed + // and other levels are available for selection. + XCTAssertTrue(levelOneText.exists, "The level one text wasn't found.") + + // Collapse the level selector. + XCTAssertTrue(collapseButton.exists, "The collapse button wasn't found.") + collapseButton.tap() + + // Verify that the level selector is collapsed. + XCTAssertFalse(levelOneText.exists, "The collapse button was unexpectedly still present.") + } +} diff --git a/t9nmanifest.txt b/t9nmanifest.txt new file mode 100644 index 000000000..83244a1ae --- /dev/null +++ b/t9nmanifest.txt @@ -0,0 +1 @@ +Sources\ArcGISToolkit\Resources\en.lproj\Localizable.strings