Let’s Build with Swift - 2: State And Data Flow

629
0
05-22-2023 12:10 PM
Ting
by Esri Contributor
Esri Contributor
2 0 629

arcgis-maps-sdk.png

 

Manage the States and Data Flow in Your App With ArcGIS Maps SDK for Swift

 

Welcome back to our SwiftUI articles. In the previous blog post we talked about the fundamental structure of a SwiftUI app built with the ArcGIS Maps SDK for SwiftNow, it is time to dive deeper into the app logic. In this post, let's explore how to manage data in the views using common mapping workflows, such as displaying a layer and showing a location display.

 

Topics In This Post

 

  • Why Map needs a @State property wrapper
  • Manage the single source-of-truth data using a data model object instantiated with @StateObject property wrapper
  • Understand how the Viewpoint works as a state property

 

What Is @State and When Do You Use It

 

With our straightforward Mapper app, you've already had a glimpse at the prowess of the new Swift Maps SDK. Before we move on, let's discuss an important concept that wasn't examined in the previous post.

Take another look at ContentView.swift.

 

import ArcGIS

struct ContentView: View {
    @State private var map = Map(basemapStyle: .arcGISTopographic)

    var body: some View {
        MapView(map: map)
    }
}

 

You may wonder, what the mysterious @State property wrapper is that leads the map property? This is due to the nature of a SwiftUI View.

A SwiftUI View is a description of what to display, and it is constantly recomputed as its state properties are updated. But, we don't want the map, which is the "data" behind the map view, to be recreated every time the map view or its super view changes. So, if you apply the @State attribute to the map property, you let SwiftUI know that the map only needs to be created once. That is to say, when SwiftUI updates the view hierarchy, the map instance remains unchanged.

In Swift Maps SDK, you'll most commonly find yourself using @State for types like Map, Scene, Viewpoint, and GraphicsOverlay, for example, to ensure that they are only created once.

When a view needs to manage many pieces of state data, it can be helpful to put those data in a separate view-specific "view model".

 

@StateObject, ObservableObject and View Model

 

Contrary to UIKit based apps where a "Model-View-Controller (MVC)" design pattern was heavily promoted, it's never been officially suggested that SwiftUI apps should use a "Model-View-ViewModel (MVVM)" pattern. Indeed, the simplicity of SwiftUI allows some apps to be flat-designed, without the extra layer of data managing types to coordinate data flows. However, there are situations where the "view model" design is beneficial for your app's data flow. Let's take a look at this more closely.

As described earlier, a SwiftUI view can use the @State property wrapper to directly manage its data. However, if you populate the view with lots of state properties, it can easily get inflated. The data model gives you a much cleaner separation of responsibilities between the view and the data.

As an experienced iOS developer, do you still remember the old days when we needed to refactor a heavily stuffed, multi-thousand-line view controller? 🤬 We should build clean and concise code with SwiftUI from the ground up. To avoid this problem and make a view more readable in SwiftUI, create a data model using the @StateObject property wrapper and move properties and imperative methods outside of the view.

Note: A general rule of thumb - if you find the property that you want to mark as @State more complex than a basic type (such as Bool) or a simple structure (such as Measurement), consider moving it into the data model. Expose its UI-related properties as "published".

Creating a data model is a better option when there are more than a handful of properties or when a property should be the source-of-truth but isn't mutating.

Let's use a very common workflow to demonstrate this concept. A device's location is typically shown with a blue dot. This LocationDisplay on the map view requires a location data source, the location display object itself, and various other properties and functions. As you may tell, jamming all of these properties into a SwiftUI View is not favorable. Instead, if you put them in a data model, then the code is cleaner and it is easier for you to implement unit tests.

Open up the Mapper project from the last post. In ContentView.swift, make the following change.

 

struct ContentView: View {
-   // @State private var map = Map(basemapStyle: .arcGISTopographic)
+   @StateObject private var model = Model()
    
    var body: some View {
        MapView(map: model.map)
    }
}

// Preview code collapsed…

 

Then, create an extension to the ContentView and define a Model class.

 

private extension ContentView {
    /// The 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.
            // Please set the permission in plist in advance.
            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
            }
        }
    }
}

 

 

Location Display and real-time coordinatesLocation Display and real-time coordinates

At first glance, this might look like a wall of code, so let's break it down together.

  • In the model, we create and hold the map as an instance property instead of holding it as a state variable in ContentView.
  • We also create other objects in the model which shouldn't be recomputed, such as the feature layer.
  • A location display object is created together with 3 helper functions to deal with its location data source. Therefore, non-UI data flows are extracted from the view and managed by the data model.
  • It exposes and publishes the currentLocation property that is automatically updated when the location data source emits an update, which we can subsequently access in a view.

Looking at the top of the Model type, you may notice that it conforms to the ObservableObject protocol. In short, that means the Model class, as a reference type, can emit changed values from its @Published properties that drive UI updates. By creating the model object as a state object in the ContentView, it owns its data, and is the single source-of-truth for the view.

Finally, add a few lines of code to allow the view to consume the changes from its model's data.

 

 

import SwiftUI
import ArcGIS
import CoreLocation

struct ContentView: View {
    @StateObject private var model = Model()
    
    var body: some View {
        MapView(map: model.map, viewpoint: viewpoint)
            .locationDisplay(model.locationDisplay)
            .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
            .overlay(alignment: .top) {
                Text(model.currentLocation?.position.xyCoordinates ?? "No location update.")
            }
            .task {
                await model.startLocationDataSource()
                await model.locationDidChange()
            }
            .onDisappear {
                model.stopLocationDataSource()
            }
    }
}

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

 

When the simulated or device location changes, the coordinates are updated constantly in a Text overlay along with the moving blue dot. To achieve this, the location display object is added to the map view via the locationDisplay view modifier. The startLocationDataSource method starts the location display data source and displays the blue dot on the map view. Every time the locationDidChange method is invoked, the currentLocation property is updated and the Text view redraws and displays the latest coordinates. With this example, we hope you'll have a better understanding of how to use a "view model" to simplify a view.

Note: Before running the app, you'll need to set the NSLocationWhenInUseUsageDescription in the Info.plist.

To learn more about how to pass the data model object to a subview or around other views, check out the documentation for @ObservedObject and @EnvironmentObject attributes.

There are a few others things we haven't touched upon: the @MainActor attribute, various view modifiers on the MapView, and async/await syntax in the task modifiers. They will be covered in the following posts, so don't miss out! 😉

Viewpoint, A Deeper Look at State Property

 

When you start writing code with the new Swift Maps SDK, one of the first things you will discover is that you cannot directly access the viewpoint of the MapView as a property, or set a viewpoint using the setViewpoint method anymore.

That makes sense because a SwiftUI View is a description of what to display at a given state. A UIView in UIKit, on the other hand, is an object with a defined frame that renders subviews and handles interactions with its content. With that said, the map view's current displayed rectangular region becomes a state to it. That means the viewpoint of a map view should also be a state variable.

Take the initial ContentView.swift as an example.

 

struct ContentView: View {
    @State private var map = Map(basemapStyle: .arcGISTopographic)
    
    @State private var viewpoint = Viewpoint(latitude: 34.02700, longitude: -118.80500, scale: 72_000)

    var body: some View {
        MapView(map: map, viewpoint: viewpoint)
            .onViewpointChanged(kind: .centerAndScale) { viewpoint = $0; print(viewpoint.targetScale) }
    }
}

 

There are a couple of things going on with the addition of these 2 lines.

First, a stored property viewpoint marked with @State property wrapper is added. We pass the viewpoint to construct the map view so it draws at the given viewpoint. If we want to change the viewpoint programmatically, we simply assign a new value to this mutable property, and it triggers a map view update.

Second, a view modifier onViewpointChanged(kind:perform:) is added to the map view. When a user pans or interacts with the map view, the view modifier gives the map view’s latest viewpoint to the developer via a callback, in which it can be set to the state variable.

Build and run the app. The print console output shows the viewpoint’s scale changing as you zoom the map.

 

Coming Up Next

 

🤓That is a lot to digest. Sometimes it is hard to tell when to use which property wrapper even for experienced developers, so take it easy. Now you should have a basic understanding of how to manage the UI states and data flows in a view.

In the next post, we'll discuss the new Swift Concurrency, explore the async/await syntax, and examine how to perform asynchronous actions using the "task" modifier in your view. Stay tuned!

 

Links