Let’s Build with Swift - 4: View Modifier And Reader-Proxy Pattern

629
0
06-01-2023 02:30 PM
Ting
by Esri Contributor
Esri Contributor
2 0 629

arcgis-maps-sdk.png

Modify Views and Use the Reader/Proxy Pattern With ArcGIS Maps SDK for Swift

 

Welcome to the final post in this series! In previous posts, we have covered the basic mapping app structurestate and data flow, and using concurrency in your app. Finally, we will revisit the SwiftUI view to discuss the view modifiers as well as the view reader/proxy pattern. In addition, we will also lightly touch on a few other topics to give you a head start in your journey with the new ArcGIS Maps SDK for Swift.

 

Topics In This Post

 

  • Apply view modifiers to customize the characteristics of a view
  • Explore the commonly used view modifiers in SwiftUI and Swift Maps SDK
  • Access map view operations using the MapViewReader and MapViewProxy pattern
  • Beyond this blog series

 

View Modifiers

 

With UIKit, we used to control a view's appearance, behavior, gesture recognition, and operations, by using a variety of properties and methods. With SwiftUI, we now control this with two different categories of methods. A new genre of declarative methods called view modifiers handle the configuration of a view, and proxy view methods handle the operations performed on the content of a view. We will explore this reader/proxy coding pattern in a later section.

A view modifier takes a view as input, and returns a modified view as output. You can even chain together multiple modifiers to achieve complex effects. There are many types of view modifiers in SwiftUI that can be roughly grouped by their purpose into the following categories:

  • Customize the view's appearance, such as color, style, effect, and shape.
  • Change layout by adding an overlay to a view, or padding between views, for example.
  • Handle events such as, detecting gestures on a view, or responding to the text field submission.
  • Configure the environment by using the environment object modifier to pass a data model to subviews.
  • Miscellaneous modifiers that control the navigation behavior, showing an alert and other supporting views, setting animation on a view, setting disabled or hidden states, and for accessibility, and many more.

In the following sections, we will examine some of the SwiftUI and ArcGIS view modifiers using a code example.

 

View Modifiers in SwiftUI

 

Take another look at the view's body we've written so far.

 

    var body: some View {
        MapView(map: model.map, viewpoint: viewpoint)
            .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
            .locationDisplay(model.locationDisplay)
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    Button("Query City") {
                        Task {
                            try? await model.featureLayer.load()
                            try? await model.queryCity(name: "Los Angeles")
                        }
                    }
                }
            }
            .overlay(alignment: .top) {
                Text(model.currentLocation?.position.xyCoordinates ?? "No location update.")
            }
            .task {
                await model.startLocationDataSource()
                await model.locationDidChange()
            }
            .onDisappear {
                model.stopLocationDataSource()
            }
    }

 

Xcode library showing different types of view modifiersXcode library showing different types of view modifiersIn the code above, a number of SwiftUI view modifiers are used to customize the map view. The .toolbar modifier adds a toolbar control to the bottom on the view, the .task modifier performs some asynchronous operations as the view is created, and the .onDisappear stops the location data source as the view disappears from the interface. In addition, the overlay modifier layers a Text subview in front of the map view.

Here are a few examples of other SwiftUI view modifiers. In terms of the layout, you can also change the frame of a view, aspect ratio, z-index, views alignment, and padding between views. In terms of the map view, the .alert, presentation .sheet, and .popover view modifiers allow you to present UI choices and interactions in your app. They are very versatile when customizing a view and work seamlessly with the Swift Maps SDK view modifiers.

Note: To browse the other view modifiers in SwiftUI, check out the Xcode library by clicking the Library button (+) in Xcode's toolbar on the right.

 

View Modifiers in Swift Maps SDK

 

The Swift Maps SDK also provides view modifiers for various mapping workflows. In the code example above, the .locationDisplay modifier is used to pass a location display instance to the map view. It replaces the mapView.locationDisplay property found in the iOS Runtime SDK. Typically, if a map view property configures the look of the view, or provides information about the view, it will now be exposed as a view modifier that takes the value of the property. For example, you can set the background grid on a map view, the atmosphere effect on a scene view, or a geometry editor instance on a map view using their respective modifiers.

The .onViewpointChanged modifier sets a closure to perform an action when the viewpoint changes. This pattern represents the modifiers that respond to the changes on the map view. Whenever the event generates a new value, it calls the provided closure with the value. The names of these modifiers often starts with "on" and ends with "Changed", to indicate the action will be performed whenever the value-of-interest changes. Similar examples are .onRotationChanged.onVisibleAreaChanged.onDrawStatusChanged, and various gesture modifiers such as .onSingleTapGesture, which will be further demonstrated below.

Note: The callout modifier is a special case that doesn't belong to the 2 patterns above. It resembles a control modifier in SwiftUI, which you can specify its placement and add subviews to it.

For a comprehensive list of the Swift Maps SDK instance methods that you can set as view modifiers, check out the individual API Reference pages for GeoViewMapView, and SceneView.

 

Reader/Proxy Pattern

 

As SwiftUI views, MapView and SceneView do not expose any properties, and all their instance methods are view modifiers. View modifiers allow you to customize a view's appearance or behavior. There are some methods such as identify, set view point with animation, export image and set bookmarks, however, that do not set the view's appearance and behavior.

These types of methods perform a specific function on the view itself and are handled using a proxy, MapViewProxy, and a reader view, MapViewReader. They are used to expose the properties and methods that aren't suitable for an initializer parameter or a modifier.

SwiftUI uses the same reader patterns in the ScrollViewReader and the GeometryReader. These 2 container views provide a content proxy to access the content view's size, coordinate space, read-only properties, and methods that operate on the map or scene view itself.

The most prominent examples of the reader/proxy pattern are the identify and setViewpoint operations. The identify operation allows a user to discover features at a geographic location while the setViewpoint operation allows the map view to change to a viewpoint in an animated fashion.

Let's add these 2 abilities to the code example. First, add 2 new state properties to the ContentView, to hold the tapping points from the gesture.

struct ContentView: View {
+    @State private var identifyPoint: CGPoint?
+    @State private var tapLocation: Point?
    // ... Code below collapsed
}

Then, add the following code to the view's body.

 

+        // 1. Wrap the map view in a `MapViewReader`. 
+        MapViewReader { mapViewProxy in
            MapView(map: model.map, viewpoint: viewpoint)
                .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
                .locationDisplay(model.locationDisplay)
+                // 2. Add a single tap gesture modifier.
+                .onSingleTapGesture { screenPoint, mapPoint in
+                    identifyPoint = screenPoint
+                    tapLocation = mapPoint
+                }
+                // 3. Add a task modifier to run the async operations.
+                .task(id: identifyPoint) {
+                    guard let identifyPoint else { return }
+                    model.featureLayer.clearSelection()
+                    let results = try? await mapViewProxy.identify(
+                        on: model.featureLayer,
+                        screenPoint: identifyPoint,
+                        tolerance: 12,
+                        maximumResults: 1
+                    )
+                    if let feature = results?.geoElements.first as? Feature,
+                       let geometry = feature.geometry {
+                        model.featureLayer.selectFeature(feature)
+                        await mapViewProxy.setViewpointGeometry(geometry)
+                    }
+                }

                // ... Code below collapsed
+        }

 

Feature layer identify result highlightedFeature layer identify result highlightedAt the top of the changes, we wrap the map view in the map view reader at 1. This allows us to get access to the map view through its proxy, to call the MapViewProxy.identify(on:screenPoint:tolerance:returnPopupsOnly:maximumResults:) and MapViewProxy.setViewpointGeometry(_:padding:) methods.

Next, add the Swift Maps SDK .onSingleTapGesture modifier at 2, to receive the tap point and location when the user taps on the screen. These values are stored in the state properties, called identifyPoint and tapLocation, that we created above.

Finally, let's add a .task modifier at 3 that will run when the value of identifyPoint changes. This task block runs the asynchronous operations of identify and set the viewpoint with animation. The identify method on the map view proxy, uses the identifyPoint, and waits for the identify result. If the result is not empty, we then call the setViewpointGeometry method on the proxy to pan the map view to the selected feature with animation.

Build and run the app. Tap on any city feature in the map view. The feature will be selected, and the map view will pan to the feature.

This reader/proxy coding pattern is also adopted by the SceneView. Refer to the MapViewProxy and the SceneViewProxy API Reference documentation for a list of their methods. Once you get familiar with this pattern, you will find it quite intuitive to use, just like calling the methods on the map view itself in the iOS Runtime SDK.

 

Next Steps

 

Woo-Hoo! 🥳 We are at the finish line of the series. View modifiers are an essential part of the whole SwiftUI ecosystem. As you get more familiar with the new SDK, you will get used to the great ease of modifying views using many modifiers together. At Esri, we strive to give you a developer experience that is on par with the first-party native frameworks.

Due to limited space, there are a few things that we didn't discuss in detail in the series. To learn more about the new SDK, there are many resources for you to explore.

Finally, below you will find the full code we've completed through this blog series. It marks the first milestone of your journey with ArcGIS Maps SDK for Swift.With this solid foundation to build upon, we hope to see your powerful and modern mapping apps on App Store soon!:mobile_phone:

The code we've written so far 👇

Spoiler
import SwiftUI
import ArcGIS
import CoreLocation

struct ContentView: View {
    @StateObject private var model = Model()
    
    @State private var viewpoint = Viewpoint(latitude: 34.02700, longitude: -118.80500, scale: 72_000)
    
    @State private var identifyPoint: CGPoint?
    
    @State private var tapLocation: Point?
    
    var body: some View {
        // Creates a map view to display the map.
        MapViewReader { mapViewProxy in
            MapView(map: model.map, viewpoint: viewpoint)
                .locationDisplay(model.locationDisplay)
                .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
                .onSingleTapGesture { screenPoint, mapPoint in
                    identifyPoint = screenPoint
                    tapLocation = mapPoint
                }
                .task(id: identifyPoint) {
                    guard let identifyPoint else { return }
                    model.featureLayer.clearSelection()
                    let results = try? await mapViewProxy.identify(
                        on: model.featureLayer,
                        screenPoint: identifyPoint,
                        tolerance: 12,
                        maximumResults: 1
                    )
                    if let feature = results?.geoElements.first as? Feature,
                       let geometry = feature.geometry {
                        model.featureLayer.selectFeature(feature)
                        await mapViewProxy.setViewpointGeometry(geometry)
                    }
                }
                .toolbar {
                    ToolbarItem(placement: .bottomBar) {
                        Button("Query City") {
                            Task {
                                try? await model.featureLayer.load()
                                try? await model.queryCity(name: "Los Angeles")
                            }
                        }
                    }
                }
                .overlay(alignment: .top) {
                    Text(model.currentLocation?.position.xyCoordinates ?? "No location update.")
                }
                .task {
                    await model.startLocationDataSource()
                    await model.locationDidChange()
                }
                .onDisappear {
                    model.stopLocationDataSource()
                }
        }
    }
}

private extension ContentView {
    /// The view model for this example.
    class Model: ObservableObject {
        /// A topographic map.
        let map: Map
        
        /// A feature layer showing the locations of World Cities.
        let featureLayer: FeatureLayer
        
        /// The location display for the map.
        let locationDisplay: LocationDisplay
        
        /// The latest location update from the location data source.
        @Published var currentLocation: Location?
        
        init() {
            map = Map(basemapStyle: .arcGISTopographic)
            featureLayer = FeatureLayer(item: PortalItem(portal: .arcGISOnline(connection: .anonymous), id: PortalItem.ID("6996f03a1b364dbab4008d99380370ed")!))
            map.addOperationalLayer(featureLayer)
            locationDisplay = LocationDisplay(dataSource: SystemLocationDataSource())
            locationDisplay.autoPanMode = .recenter
        }
        
        /// Starts the location data source.
        func startLocationDataSource() async {
            // Requests location permission.
            let locationManager = CLLocationManager()
            locationManager.requestWhenInUseAuthorization()
            do {
                try await locationDisplay.dataSource.start()
            } catch {
                // Shows the error if starting the data source fails.
                print(error.localizedDescription)
            }
        }
        
        /// Stops the location data source.
        func stopLocationDataSource() {
            Task {
                await locationDisplay.dataSource.stop()
            }
        }
        
        /// Uses `for-await-in` to access location updates produced by the async stream.
        @MainActor
        func locationDidChange() async {
            for await newLocation in locationDisplay.dataSource.locations {
                currentLocation = newLocation
            }
        }
        
        /// Queries a cities feature table with a name.
        func queryCity(name: String) async throws {
            let queryParameters = QueryParameters()
            queryParameters.whereClause = "CITY_NAME = '\(name)'"
            // Queries the feature table dataset.
            if let queryResult = try await featureLayer.featureTable?.queryFeatures(using: queryParameters) {
                featureLayer.selectFeatures(queryResult.features())
            }
        }
    }
}

private extension Point {
    /// The point's decimal-formatted x and y coordinates.
    var xyCoordinates: String {
        String(format: "x: %.5f, y: %.5f", x, y)
    }
}

 

Links

 

 

Credits

 

Last but not least, I would like to express my gratitude to…

  • @MaryHarvey , my co-writer, whose dedication brought this series to life. Her insightful editing and unwavering commitment to the blog series have been invaluable. 🙇‍
  • @MarkDostal , for his thorough technical reviews which enhanced the quality of our code examples. 👨‍🔬
  • @Rachael_Ellen , the developer outreach representative, for her enthusiastic efforts in promoting and advocating the series to our target audience. 📣
  • @SuganyaBaskaran1 , for her coordinating the collaboration progress. ️✍️
  • @DiveshGoyal , for his vision in shaping the direction of the series. 🚀

Together, their efforts have made this series see the light 💡, and I am truly grateful for their contributions. 🎉