Working with the Google Maps Android API in Android Studio

When Google decided to introduce a map service many years ago, it is hard to say whether or not they ever anticipated having a version available for integration into mobile applications. When the first web-based version of what would eventually be called Google Maps was introduced in 2005, the iPhone had yet to ignite the smartphone revolution, and the company that was developing the Android operating system would not be acquired by Google for another six months. Whatever aspirations Google had for the future of Google Maps, it is remarkable to consider that all of the power of Google Maps can now be accessed directly via Android applications using the Google Maps Android API.

This chapter is intended to provide an overview of the Google Maps system and Google Maps Android API. The chapter will provide an overview of the different elements that make up the API, detail the steps necessary to configure a development environment to work with Google Maps and then work through some code examples demonstrating some of the basics of Google Maps Android integration.

The Elements of the Google Maps Android API

The Google Maps Android API consists of a core set of classes that combine to provide mapping capabilities in Android applications. The key elements of a map are as follows:

  • GoogleMap – The main class of the Google Maps Android API. This class is responsible for downloading and displaying map tiles and for displaying and responding to map controls. The GoogleMap object is not created directly by the application but is instead created when MapView or MapFragment instances are created. A reference to the GoogleMap object can be obtained within application code via a call to the getMap() method of a MapView, MapFragment or SupportMapFragment instance.
  • MapView – A subclass of the View class, this class provides the view canvas onto which the map is drawn by the GoogleMap object, allowing a map to be placed in the user interface layout of an activity.
  • SupportMapFragment – A subclass of the Fragment class, this class allows a map to be placed within a Fragment in an Android layout.
  • Marker – The purpose of the Marker class is to allow locations to be marked on a map. Markers are added to a map by obtaining a reference to the GoogleMap object associated with a map and then making a call to the addMarker() method of that object instance. The position of a marker is defined via Longitude and Latitude. Markers can be configured in a number of ways, including specifying a title, text and an icon. Markers may also be made to be “draggable”, allowing the user to move the marker to different positions on a map.
  • Shapes – The drawing of lines and shapes on a map is achieved through the use of the Polyline, Polygon and Circle classes.
  • UiSettings – The UiSettings class provides a level of control from within an application of which user interface controls appear on a map. Using this class, for example, the application can control whether or not the zoom, current location and compass controls appear on a map. This class can also be used to configure which touch screen gestures are recognized by the map.
  • My Location Layer – When enabled, the My Location Layer displays a button on the map which, when selected by the user, centers the map on the user’s current geographical location. If the user is stationary, this location is represented on the map by a blue marker. If the user is in motion the location is represented by a chevron indicating the user’s direction of travel.

The best way to gain familiarity with the Google Maps Android API is to work through an example. The remainder of this chapter will create a Google Maps based application while highlighting the key areas of the API.

Creating the Google Maps Project

Select the New Project option from the welcome screen and, within the resulting new project dialog, choose the Google Maps Activity template before clicking on the Next button.

Enter MapDemo into the Name field and specify com.ebookfrenzy.mapdemo as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Java.

Creating a Google Cloud Billing Account

Before you can use the Google Map APIs you must first create Google Cloud billing account (unless you already have one, in which case you can skip to the next section). To do this, open a browser and use the following link to navigate to the Google Cloud Console:

https://console.cloud.google.com/

Next, click on the menu button in the top left-hand corner of the console page and select the Billing entry as illustrated in Figure 74-1 below:

Figure 74-1

On the Billing page, select the option to add a new billing account and then follow the steps to start a free trial. You will need to provide a credit card to open the account, but Google won’t charge you when the free trial ends without your consent.

Creating a New Google Cloud Project

The next step is to create a Google Cloud project to be associated with the MapDemo app. To do this, return to the Google Cloud Console dashboard by using the following URL:

https://console.cloud.google.com/home/dashboard

Within the dashboard, click the Select a project button located in the top toolbar:

Figure 74-2

When the project selection dialog appears, click on the New Project button (highlighted in Figure 74-3):

Figure 74-3

When the new project screen appears, provide a name for the project. The console will display a default id for the project beneath the project name field. If you don’t like the default id, click the Edit button to change it:

Figure 74-4

Click the Create button, and after a brief pause, you will be returned to the dashboard where your new project will be listed.

Enabling the Google Maps SDK

Now that we have created a new Google Cloud project, the next step is to allow the project to use the Google Maps SDK. To enable Google Maps support, select your project in the Google Cloud Console, click the menu button in the top left-hand corner and select the Google Maps Platform entry. Then, from the resulting menu, select the APIs option as shown in Figure 74-5:

Figure 74-5

On the APIs screen, click on the Maps SDK for Android option and, on the resulting screen, click the Enable button:

Figure 74-6

Repeat the above steps to enable the Geocoding API credential, which will be needed later in the chapter to allow our app to display the user’s current location.

Once you have enabled the credentials for your project, click the back arrow to return to the product details page in preparation for the next step.

Generating a Google Maps API Key

Before an application can use the Google Maps Android SDK, it must first be configured with an API key that will associate it with a Maps-enabled Google Cloud project. To generate an API key, select the Credentials menu option (marked A in Figure 74-7) followed by Create Credentials button (B):

Figure 74-7

After the credential has been created, a dialog will appear displaying the API key. Copy the key before closing the API dialog:

Figure 74-8

Adding the API Key to the Android Studio Project

Now that we have generated an API key that will allow our app to use the Google Maps SDK, we need to add it to our project. Return to Android Studio, edit the manifests -> AndroidManifest.xml file, and locate the API key entry, which will read as follows:

<meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="YOUR_API_KEY" />

Delete the text that reads “YOUR_API_KEY” and replace it with the API key created in the Google Play Console.

Next, edit the Gradle Scripts -> local.properties file and add a new line that reads as follows (where the API key for your project replaces YOUR_API_KEY):

MAPS_API_KEY=YOUR_API_KEY

Adding the Apache HTTP Legacy Library Requirement

Since this example project will be built for use with Android version 9.0 or later, the following declaration needs to be added within the <application> section of the AndroidManifest.xml file as follows:

.
.
    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/Theme.MapDemo"
        tools:targetApi="31">
 
        <uses-library
                android:name="org.apache.http.legacy"
                android:required="false" />
.
.

Testing the Application

Perform a test run of the application to verify that the API key is correctly configured. Assuming the configuration is correct, the application will run and display a map on the screen.

If a map is not displayed, check the following areas:

  • If the application is running on an emulator, make sure that the emulator is running a version of Android that includes the Google APIs. The current operating system can be changed for an AVD configuration by selecting the Tools -> Android -> AVD Manager menu option, clicking on the pencil icon in the Actions column of the AVD followed by the Change… button next to the current Android version. Within the system image dialog, select a target that includes the Google APIs.
  • Check the Logcat output for any areas relating to authentication problems with regard to the Google Maps API.

This usually means the API key was entered incorrectly. Ensure that the API key in both the AndroidManifest.xml and local.properties files matches the key generated in the Google Cloud console.

  • Verify within the Google API Console that Maps SDK for Android has been enabled in the Credentials panel.

Understanding Geocoding and Reverse Geocoding

It is impossible to talk about maps and geographical locations without first covering the subject of Geocoding. Geocoding can best be described as the process of converting a textual-based geographical location (such as a street address) into geographical coordinates expressed in terms of longitude and latitude.

Geocoding can be achieved using the Android Geocoder class. An instance of the Geocoder class can, for example, be passed a string representing a location, such as a city name, street address or airport code. The Geocoder will attempt to find a match for the location and return a list of Address objects that potentially match the location string, ranked in order with the closest match at position 0 in the list. A variety of information can then be extracted from the Address objects, including the longitude and latitude of the potential matches.

The following code, for example, requests the location of the National Air and Space Museum in Washington, D.C.:

import java.io.IOException;
import java.util.List;
 
import android.location.Address;
import android.location.Geocoder;
.
.
double latitude;
double longitude;
 
List<Address> geocodeMatches = null;
 
try {
       geocodeMatches = 
          new Geocoder(this).getFromLocationName(
               "600 Independence Ave SW, Washington, DC 20560", 1);
    } catch (IOException e) {
       // TODO Auto-generated catch block
       e.printStackTrace();
}
 
if (!geocodeMatches.isEmpty())
{
       latitude = geocodeMatches.get(0).getLatitude(); 
       longitude = geocodeMatches.get(0).getLongitude();
}

Note that the value of 1 is passed through as the second argument to the getFromLocationName() method. This simply tells the Geocoder to return only one result in the array. Given the specific nature of the address provided, there should only be one potential match. For more vague location names, however, it may be necessary to request more potential matches and allow the user to choose the correct one.

The above code is an example of forward-geocoding in that coordinates are calculated based on a text location description. Reverse-geocoding, as the name suggests, involves the translation of geographical coordinates into a human readable address string. Consider, for example, the following code:

import java.io.IOException;
import java.util.List;
 
import android.location.Address;
import android.location.Geocoder;
.
.
List<Address> geocodeMatches = null;
String Address1;
String Address2;
String State;
String Zipcode;
String Country;
 
try {
       geocodeMatches = 
         new Geocoder(this).getFromLocation(38.8874245, -77.0200729, 1);
} catch (IOException e) {
       // TODO Auto-generated catch block
       e.printStackTrace();
}
 
if (!geocodeMatches.isEmpty())
{
       Address1 = geocodeMatches.get(0).getAddressLine(0);
       Address2 = geocodeMatches.get(0).getAddressLine(1);
       State = geocodeMatches.get(0).getAdminArea();
       Zipcode = geocodeMatches.get(0).getPostalCode();
       Country = geocodeMatches.get(0).getCountryName();
}

In this case, the Geocoder object is initialized with latitude and longitude values via the getFromLocation() method. Once again, only a single matching result is requested. The text based address information is then extracted from the resulting Address object.

It should be noted that the geocoding is not actually performed on the Android device, but rather on a server to which the device connects when a translation is required and the results subsequently returned when the translation is complete. As such, geocoding can only take place when the device has an active internet connection.

Adding a Map to an Application

The simplest way to add a map to an application is to specify it in the user interface layout XML file for an activity. The following example layout file shows the SupportMapFragment instance added to the activity_maps. xml file created by Android Studio:

<fragment xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/map"
    tools:context=".MapsActivity"
    android:name="com.google.android.gms.maps.SupportMapFragment"/> 

Requesting Current Location Permission

As outlined in the chapter entitled “Making Runtime Permission Requests in Android”, certain permissions are considered dangerous and require special handling for Android 6.0 or later. One set of permissions allows applications to identify the user’s current location. Edit the AndroidManifest.xml file located under app -> manifests in the Project tool window and add the following permission lines:

<uses-permission   
    android:name="android.permission.ACCESS_FINE_LOCATION" /> 
 
<uses-permission
    android:name="android.permission.ACCESS_COARSE_LOCATION" />

These settings will ensure that the app can provide permission for the app to obtain location information when installed on older versions of Android. To support Android 6.0 or later, however, we need to add some code to the MapsActivity.java file to request this permission at runtime.

Begin by adding some import directives and a constant to act as the permission request code:

package com.ebookfrenzy.mapdemo;
.
.
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.core.app.ActivityCompat;
import android.Manifest;
import android.widget.Toast;
import android.content.pm.PackageManager;
.
.
public class MapsActivity extends FragmentActivity implements OnMapReadyCallback { 
    private static final int LOCATION_REQUEST_CODE = 101;
    private GoogleMap mMap;
.
.
}

Next, a method needs to be added to the class to request a specified permission from the user. Remaining within the MapsActivity.java class file, implement this method as follows:

protected void requestPermission(String permissionType,
                                 int requestCode) {
 
    ActivityCompat.requestPermissions(this,
            new String[]{permissionType}, requestCode
    ); 
}

When the user has responded to the permission request, the onRequestPermissionsResult() method will be called on the activity. Remaining in the MapsActivity.java file, implement this method now so that it reads as follows:

@Override
public void onRequestPermissionsResult(int requestCode,
             @NonNull String[] permissions, @NonNull int[] grantResults) {
 
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    if (requestCode == LOCATION_REQUEST_CODE) {
        if (grantResults.length == 0
                || grantResults[0] !=
                PackageManager.PERMISSION_GRANTED) {
            Toast.makeText(this,
                    "Unable to show location - permission required",
                    Toast.LENGTH_LONG).show();
        } else {
 
            SupportMapFragment mapFragment =
                    (SupportMapFragment) getSupportFragmentManager()
                            .findFragmentById(R.id.map);
            mapFragment.getMapAsync(this);
        }
    }
}

If permission has not been granted by the user, the app displays a message indicating that the current location cannot be displayed. If, on the other hand, permission was granted, the map is refreshed to provide an opportunity for the location marker to be displayed.

Displaying the User’s Current Location

Once the appropriate permission has been granted, the user’s current location may be displayed on the map by obtaining a reference to the GoogleMap object associated with the displayed map and calling the setMyLocationEnabled() method of that instance, passing through a value of true.

When the map is ready to display, the onMapReady() method of the activity is called. This method will also be called when the map is refreshed within the onRequestPermissionsResult() method above. By default, Android Studio has implemented this method and added some code to orient the map over Australia with a marker positioned over the city of Sidney. Locate and edit the onMapReady() method in the MapsActivity.java file to remove this template code and to add code to check the location permission has been granted before enabling display of the user’s current location. If permission has not been granted, a request is made to the user via a call to the previously added requestPermission() method:

@Override
public void onMapReady(GoogleMap googleMap) {
    mMap = googleMap;
 
    // Add a marker in Sydney and move the camera
    LatLng sydney = new LatLng(-34, 151);
    mMap.addMarker(new MarkerOptions().position(sydney).title("Marker in Sydney"));
    mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney));
 
    if (mMap != null) {
        int permission = ContextCompat.checkSelfPermission(this, 
              Manifest.permission.ACCESS_FINE_LOCATION);
 
        if (permission == PackageManager.PERMISSION_GRANTED) {
            mMap.setMyLocationEnabled(true);
        } else {
                requestPermission(
                  Manifest.permission.ACCESS_FINE_LOCATION,
                        LOCATION_REQUEST_CODE);
        } 
     } 
}

When the app is now run, the dialog shown in Figure 74-9 will appear requesting location permission. If permission is granted, a blue dot will appear on the map indicating the current location of the device.

Figure 74-9

Changing the Map Type

The type of map displayed can be modified dynamically by making a call to the setMapType() method of the corresponding GoogleMap object, passing through one of the following values:

  • GoogleMap.MAP_TYPE_NONE – An empty grid with no mapping tiles displayed.
  • GoogleMap.MAP_TYPE_NORMAL – The standard view consisting of the classic road map.
  • GoogleMap.MAP_TYPE_SATELLITE – Displays the satellite imagery of the map region.
  • GoogleMap.MAP_TYPE_HYBRID – Displays satellite imagery with the road map superimposed.
  • GoogleMap.MAP_TYPE_TERRAIN – Displays topographical information such as contour lines and colors.

The following code change to the onMapReady() method, for example, switches a map to Satellite mode:

.
.
if (mMap != null) {
    int permission = ContextCompat.checkSelfPermission(
              this, Manifest.permission.ACCESS_FINE_LOCATION);
 
    if (permission == PackageManager.PERMISSION_GRANTED) {
        mMap.setMyLocationEnabled(true);
    } else {
        requestPermission(Manifest.permission.ACCESS_FINE_LOCATION,
                LOCATION_REQUEST_CODE);
    }
    mMap.setMapType(GoogleMap.MAP_TYPE_SATELLITE);
}
.
.

Alternatively, the map type may be specified in the XML layout file in which the map is embedded using the map:mapType property together with a value of none, normal, hybrid, satellite or terrain. For example:

<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:map="http://schemas.android.com/apk/res-auto"
          android:id="@+id/map"
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          map:mapType="hybrid"
          android:name="com.google.android.gms.maps.SupportMapFragment"/>

Displaying Map Controls to the User

The Google Maps Android API provides a number of controls that may be optionally displayed to the user consisting of zoom in and out buttons, a “my location” button and a compass.

Whether or not the zoom and compass controls are displayed may be controlled either programmatically or within the map element in XML layout resources. To configure the controls programmatically, a reference to the UiSettings object associated with the GoogleMap object must be obtained:

import com.google.android.gms.maps.UiSettings;
.
.
UiSettings mapSettings;
mapSettings = mMap.getUiSettings();

The zoom controls are enabled and disabled via the calls to the setZoomControlsEnabled() method of the UiSettings object. For example:

mapSettings.setZoomControlsEnabled(true);

Alternatively, the map:uiZoomControls property may be set within the map element of the XML resource file:

map:uiZoomControls="false"

The compass may be displayed either via a call to the setCompassEnabled() method of the UiSettings instance, or through XML resources using the map:uiCompass property. Note the compass icon only appears when the map camera is tilted or rotated away from the default orientation.

The “My Location” button will only appear when My Location mode is enabled as outlined earlier in this chapter. The button may be prevented from appearing even when in this mode via a call to the setMyLocationButtonEnabled() method of the UiSettings instance.

Handling Map Gesture Interaction

The Google Maps Android API is capable of responding to a number of different user interactions. These interactions can be used to change the area of the map displayed, the zoom level and even the angle of view (such that a 3D representation of the map area is displayed for certain cities).

Map Zooming Gestures

Support for gestures relating to zooming in and out of a map may be enabled or disabled using the setZoomGesturesEnabled() method of the UiSettings object associated with the GoogleMap instance. For example, the following code disables zoom gestures for our example map:

UiSettings mapSettings;
mapSettings = map.getUiSettings();
mapSettings.setZoomGesturesEnabled(false);

The same result can be achieved within an XML resource file by setting the map:uiZoomGestures property to true or false.

When enabled, zooming will occur when the user makes pinching gestures on the screen. Similarly, a double tap will zoom in while a two finger tap will zoom out. One finger zooming gestures, on the other hand, are performed by tapping twice but not releasing the second tap and then sliding the finger up and down on the screen to zoom in and out respectively.

Map Scrolling/Panning Gestures

A scrolling, or panning gesture allows the user to move around the map by dragging the map around the screen with a single finger motion. Scrolling gestures may be enabled within code via a call to the setScrollGesturesEnabled() method of the UiSettings instance:

UiSettings mapSettings;
mapSettings = mMap.getUiSettings();
mapSettings.setScrollGesturesEnabled(true);

Alternatively, scrolling on a map instance may be enabled in an XML resource layout file using the map:uiScrollGestures property.

Map Tilt Gestures

Tilt gestures allow the user to tilt the angle of projection of the map by placing two fingers on the screen and moving them up and down to adjust the tilt angle. Tilt gestures may be enabled or disabled via a call to the setTiltGesturesEnabled() method of the UiSettings instance, for example:

UiSettings mapSettings;
mapSettings = mMap.getUiSettings();
mapSettings.setTiltGesturesEnabled(true);

Tilt gestures may also be enabled and disabled using the map:uiTiltGestures property in an XML layout resource file.

Map Rotation Gestures

By placing two fingers on the screen and rotating them in a circular motion, the user may rotate the orientation of a map when map rotation gestures are enabled. This gesture support is enabled and disabled in code via a call to the setRotateGesturesEnabled() method of the UiSettings instance, for example:

UiSettings mapSettings;
mapSettings = mMap.getUiSettings();
mapSettings.setRotateGesturesEnabled(true);

Rotation gestures may also be enabled or disabled using the map:uiRotateGestures property in an XML layout resource file.

Creating Map Markers

Markers are used to notify the user of locations on a map and take the form of either a standard or custom icon. Markers may also include a title and optional text (referred to as a snippet) and may be configured such that they can be dragged to different locations on the map by the user. When tapped by the user an info window will appear displaying additional information about the marker location.

Markers are represented by instances of the Marker class and are added to a map via a call to the addMarker() method of the corresponding GoogleMap object. Passed through as an argument to this method is a MarkerOptions class instance containing the various options required for the marker such as the title and snippet text. The location of a marker is defined by specifying latitude and longitude values, also included as part of the MarkerOptions instance. For example, the following code adds a marker including a title, snippet and a position to a specific location on the map:

import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.MarkerOptions;
.
.
.
LatLng position = new LatLng(38.8874245, -77.0200729);
Marker museum = mMap.addMarker(new MarkerOptions()
                  .position(position)
                  .title("Museum")
                  .snippet("National Air and Space Museum"));

When executed, the above code will mark the location specified which, when tapped, will display an info window containing the title and snippet as shown in Figure 74-10:

Figure 74-10

Controlling the Map Camera

Because Android device screens are flat and the world is a sphere, the Google Maps Android API uses the Mercator projection to represent the earth on a flat surface. The default view of the map is presented to the user as though through a camera suspended above the map and pointing directly down at the map. The Google Maps Android API allows the target, zoom, bearing and tilt of this camera to be changed in real-time from within the application:

  • Target – The location of the center of the map within the device display specified in terms of longitude and latitude.
  • Zoom – The zoom level of the camera specified in levels. Increasing the zoom level by 1.0 doubles the width of the amount of the map displayed.
  • Tilt – The viewing angle of the camera specified as a position on an arc spanning directly over the center of the viewable map area measured in degrees from the top of the arc (this being the nadir of the arc where the camera points directly down to the map).
  • Bearing – The orientation of the map in degrees measured in a clockwise direction from North.

Camera changes are made by creating an instance of the CameraUpdate class with the appropriate settings. CameraUpdate instances are created by making method calls to the CameraUpdateFactory class. Once a CameraUpdate instance has been created, it is applied to the map via a call to the moveCamera() method of the GoogleMap instance. To obtain a smooth animated effect as the camera changes, the animateCamera() method may be called instead of moveCamera().

A summary of CameraUpdateFactory methods is as follows:

  • CameraUpdateFactory.zoomIn() – Provides a CameraUpdate instance zoomed in by one level.
  • CameraUpdateFactory.zoomOut() – Provides a CameraUpdate instance zoomed out by one level.
  • CameraUpdateFactory.zoomTo(float) – Generates a CameraUpdate instance that changes the zoom level to the specified value.
  • CameraUpdateFactory.zoomBy(float) – Provides a CameraUpdate instance with a zoom level increased or decreased by the specified amount.
  • CameraUpdateFactory.zoomBy(float, Point) – Creates a CameraUpdate instance that increases or decreases the zoom level by the specified value.
  • CameraUpdateFactory.newLatLng(LatLng) – Creates a CameraUpdate instance that changes the camera’s target latitude and longitude.
  • CameraUpdateFactory.newLatLngZoom(LatLng, float) – Generates a CameraUpdate instance that changes the camera’s latitude, longitude and zoom.
  • CameraUpdateFactory.newCameraPosition(CameraPosition) – Returns a CameraUpdate instance that moves the camera to the specified position. A CameraPosition instance can be obtained using CameraPosition. Builder().

The following code, for example, zooms in the camera by one level using animation:

mMap.animateCamera(CameraUpdateFactory.zoomIn());

The following code, on the other hand, moves the camera to a new location and adjusts the zoom level to 10 without animation:

private static final LatLng position =
       new LatLng(38.8874245, -77.0200729);
 
mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(position, 10));

Finally, the next code example uses CameraPosition.Builder() to create a CameraPosition object with changes to the target, zoom, bearing and tilt. This change is then applied to the camera using animation:

import com.google.android.gms.maps.model.CameraPosition;
import com.google.android.gms.maps.CameraUpdateFactory;
.
.
CameraPosition cameraPosition = new CameraPosition.Builder()
    .target(position)
    .zoom(50)
    .bearing(70)
    .tilt(25)
    .build();
mMap.animateCamera(CameraUpdateFactory.newCameraPosition(
                            cameraPosition));

Summary

This chapter has provided an overview of the key classes and methods that make up the Google Maps Android API and outlined the steps involved in preparing both the development environment and an application project to make use of the API.

Using Trait Variations to Design Adaptive iOS User Interfaces

In 2007 developers only had to design user interfaces for single screen size and resolution (that of the first generation iPhone). However, considering the range of iOS devices, screen sizes, and resolutions available today, designing a single user interface layout to target the full range of device configurations now seems a much more daunting task.

Although eased to some degree by the introduction of Auto Layout, designing a user interface layout that would work on both iPhone and iPad device families (otherwise known as an adaptive interface) typically involved creating and maintaining two storyboard files, one for each device type.

iOS 9 and Xcode 7 introduced the concepts of trait variations and size classes, explicitly intended to allow a user interface layout for multiple screen sizes and orientations to be designed within a single storyboard file. In this chapter of iOS 16 App Development Essentials, the concept of traits and size classes will be covered together with a sample app demonstrating how to use them to create an adaptive user interface.

Understanding Traits and Size Classes

Traits define the features of the environment an app is likely to encounter when running on an iOS device. Traits can be defined both by the device’s hardware and how the user configures the iOS environment. Examples of hardware-based traits include hardware features such as the range of colors supported by the device display (also referred to as the display gamut) and whether or not the device supports features such as 3D Touch. The traits of a device that are dictated by user configuration include the dynamic type size setting and whether the device is configured for left-to-right or right-to-left text direction.

Arguably the most powerful trait category, however, relates specifically to the size and orientation of the device screen. Therefore, these trait values are referred to as size classes.

Size classes categorize the screen areas an app user interface will likely encounter during execution. Rather than represent specific screen dimensions and orientations, size classes represent width (w) and height (h) in terms of being compact (C) or regular (R).

All iPhone models in portrait orientation are represented by the compact width and regular height size class (wC hR). However, when smaller models such as the iPhone SE are rotated to landscape orientation, they are considered to be of compact height and width (wC hC). On the other hand, larger devices such as iPhone Pro and Pro Max models in landscape orientation are categorized as compact height and regular width (wR hC).

Regarding size class categorization, all iPad devices (including the iPad Pro) are classified as regular height and width (wR hR) in both portrait and landscape orientation. In addition, a range of different size class settings are used when apps are displayed on the iPad using multitasking, a topic covered in detail in the A Guide to Multitasking in iOS 12 chapter of this book.

Size Classes in Interface Builder

Interface Builder in Xcode 14 allows different Auto Layout constraints to be configured for different size class settings within a single storyboard file. In other words, size classes allow a single user interface file to store multiple sets of layout data, with each data set targeting a particular size class. At runtime, the app will use the layout data set for the size class that matches the device and prevailing orientation on which it is executing, ensuring that the user interface appears correctly.

By default, any layout settings configured within the Interface Builder environment will apply to all size classes. Only when trait variations are specifically specified will the layout configuration settings differ between size classes.

Customizing a user interface for different size classes goes beyond the ability to configure different Auto Layout constraints for different size classes. Size classes may also be used to designate which views in a layout are visible within each class and which version of a particular image is displayed to the user. A smaller image may be used when an app is running on an iPhone SE, for example, or extra buttons might be made to appear to take advantage of the larger iPad screen.

Enabling Trait Variations

Xcode enables trait variations by default for new Xcode projects. This setting can be viewed and changed by opening the Main storyboard file, selecting a scene, and displaying the File inspector as illustrated in Figure 23-1:

Figure 23-1

Setting “Any” Defaults

When designing user interfaces using size classes, there will often be situations where particular Auto Layout constraints or view settings will be appropriate for all size classes. Rather than configure these same settings for each size class, these can be configured within the Any category. Such settings will be picked up by default by all other size classes unless specifically overridden within those classes.

Working with Trait Variations in Interface Builder

A key part of working with trait variations involves using the device configuration bar. Located along the bottom edge of the Interface Builder storyboard canvas is an indicator showing the currently selected device, marked A in Figure 23-2:

Figure 23-2

Clicking on the current setting (A) in the status bar will display the device configuration menu shown in Figure 23-3, allowing different iOS device types to be selected:

Figure 23-3

Note that when an iPad model is selected, the button marked B is enabled so that you can test layout behavior for multitasking split-screen and slide-over modes. In addition, the device’s orientation can be switched between portrait and landscape using the button (C).

When you make device and orientation selections from the configuration bar, the scenes within the storyboard canvas will resize to match precisely the screen size corresponding to the selected device and orientation. This provides a quick way to test the adaptivity of layouts within the storyboard without compiling and running the app on different devices or emulators.

Attributes Inspector Trait Variations

Regardless of the current selection in the device configuration panel, any changes made to layouts or views within the current storyboard will, by default, apply to all devices and size classes (essentially the “Any” size class). Trait variations (in other words, configuration settings that apply to specific size classes) are configured in two ways.

The first option relates to setting size-specific properties such as fonts and colors within the Attributes Inspector panel. Consider, for example, a Label view contained within a storyboard scene. This label is required to display text at a 17pt font size for all size classes except for regular width and height, where a 30pt font is needed. Reviewing the Attributes Inspector panel shows that the 17pt font setting is already configured, as shown in Figure 23-4:

Figure 23-4

The absence of any size class information next to the attribute setting tells us that the font setting corresponds to the any width, any height, any gamut size class and will be used on all devices and orientations unless overridden. To the left of the attribute field is a + button, as indicated in Figure 23-5:

Figure 23-5

This is the Add Variation button which, when selected, displays a panel of menus allowing size class selections to be made for a trait variation. To configure a different font size for regular height, regular width, and any gamut, for example, the menu selections shown in Figure 23-6 would need to be made:

Figure 23-6

Once the trait configuration has been added, it appears within the Attributes Inspector panel labeled as wR hR and may be used to configure a larger font for regular width and height size class devices:

Figure 23-7

Using Constraint Variations

Layout size and constraint variations are managed using the Size inspector, which is accessed using the button marked A in Figure 23-8:

Figure 23-8

The Size inspector will update to reflect the currently selected object or constraint within the design layout. Constraints are selected either by clicking on the constraint line in the layout canvas or by making selections within the Document outline panel. In Figure 23-9, for example, a centerX constraint applied to a Label is selected within Interface Builder:

Figure 23-9

The area marked B in Figure 23-8 above contains a list of variations supported by the currently selected constraint (in this example, only the default constraint is installed, which will apply to all size classes). As demonstrated later in this chapter, we can add additional variations by clicking on the + button (C) and selecting the required class settings as outlined above when working with fonts. Once the new variation has been set up, the default variation must be disabled so that only the custom variation is active. In Figure 23-10, for example, a Label width constraint variation has been configured for the wR hR size class:

Figure 23-10

If we needed a different width for compact width, regular height devices, we would add a second width constraint to our Label and configure it with a wC hR variation containing the required width value. Then, the system will select the appropriate width constraint variation for the current device when the app runs.

An Adaptive User Interface Tutorial

The remainder of this chapter will create an adaptive user interface example. This tutorial aims to create a straightforward adaptive layout that demonstrates each of the key features of trait variations, including setting individual attributes, using the vary for traits process, and implementing image asset catalogs in the context of size classes. Begin by creating a new Xcode iOS app project named AdaptiveDemo with the language option set to Swift.

Designing the Initial Layout

The iPhone 14 in portrait orientation will serve as the base layout configuration for the user interface, so begin by selecting the Main.storyboard file and ensuring that the device configuration bar currently has the iPhone 14 device selected.

Drag and drop a Label view so that it is positioned in the horizontal center of the scene and slightly beneath the top edge of the safe area of the view controller scene. With the Label still selected, use the Align menu to add a constraint to center the view horizontally within the container. Using the Add New Constraints menu, set a Spacing to nearest neighbor constraint on the top edge of the Label using the current value and with the Constrain to Margins option enabled.

Double-click on the Label and change the text to read “A Sunset Photo”. Next, drag and drop an Image View object from the Library panel to be positioned beneath the Label and centered horizontally within the scene.

Resize the Image View so that the layout resembles that illustrated in Figure 23-11:

Figure 23-11

With the Image View selected, display the Align menu and enable the option to center the view horizontally within the container, then use the Add New Constraints menu to add a nearest neighbor constraint on the top edge of the Image View with the Constrain to Margins option disabled and to set a Width constraint of 320:

Figure 23-12

Ctrl-click and drag diagonally across the Image View, as shown in Figure 23-13. On releasing the line, select Aspect Ratio from the resulting menu.

Figure 23-13

If necessary, click the Update Frames button in the status bar to reset the views to match the constraints.

Adding Universal Image Assets

The tutorial’s next step is adding some image assets to the project. The images can be found in the adaptive_ images directory of the code samples download, which can be obtained from:

https://www.ebookfrenzy.com/retail/ios12/

Within the project navigator panel, locate and click on the Assets entry and click on the + button located in the panel’s bottom left-hand corner and select the Image Set option. Next, double-click on the new image set, which will have been named Image, and rename it to Sunset.

Locate the [email protected]x.jpg file in a Finder window and drag and drop it onto the 2x image box as illustrated below:

Figure 23-14

Return to the Main.storyboard file, select the Image View and display the Attributes Inspector panel. Next, click the down arrow to the right of the Image field and select Sunset from the resulting menu. The Image View in the view controller canvas will update to display the sunset image from the asset catalog.

Switch between device types and note that the same image is used for all size classes. The next step is to add a different image to be displayed in regular-width size class configurations.

Once again, select Assets from the project navigator panel and, with the Sunset image set selected, display the Attributes Inspector and change the Width Class attribute to Any & Compact. An additional row of image options will appear within the Sunset image set. Once again, locate the images in the sample source code download, this time dragging and dropping the [email protected] file onto the 2x image well in the compact row, as shown in Figure 23-15:

Figure 23-15

Return to the Main.storyboard file and verify that different sunset images appear when switching between compact and regular width size class configurations.

Increasing Font Size for iPad Devices

The next step in the tutorial is to add some trait variations that will apply when the app runs in regular width and regular height size class device configurations (in other words, when running on an iPad). The first variation is to use a larger font on the Label object.

Begin by selecting the Label object in the scene. Next, display the Attributes Inspector panel and click on the + button next to the font property, as illustrated in Figure 23-6 above. Select the Regular Width | Regular Height menu settings from the resulting menu and click on the Add Variation button. Within the new wR hR font attribute field, increase the font size to 35pt. Using the device configuration bar, test that the new font applies only to iPad device configurations.

Adding Width Constraint Variations

The next step is to increase the Image View size when the app encounters an iPad device size class. Since the Image View has a constraint that preserves the aspect ratio, only the width constraint needs to be modified to achieve this goal.

Earlier in the tutorial, we added a width constraint to the Image view that is currently being used for all size classes. The next step, therefore, is to adjust this constraint so that it only applies to compact width devices. To do this, select the width constraint for the Image view in the Document outline as shown below:

Figure 23-16

With the constraint selected, display the Size inspector, click on the button marked C in Figure 23-8 above and add a new compact width, regular height variation. Once the variation has been added, disable the default variation so that only the new variation is installed:

Figure 23-17

Now that we have a width constraint for compact width devices, we need to add a second width constraint to the Image view for regular width size classes. Select the Image view in the layout and use the Add New Constraints menu to add a width constraint with a value of 600:

Figure 23-18

Locate this new width constraint in the Document outline and select it as outlined in Figure 23-19:

Figure 23-19

With the constraint selected, display the Size inspector, add a regular width, regular height variation and disable the default variation. If the Relation field is set to Greater Than or Equal to, change it to Equal:

Figure 23-20

Testing the Adaptivity

Use the device configuration bar to test a range of size class permutations. Assuming that the adaptivity settings are working, a larger font and a larger, different image should appear when previewing iPad configurations. Use the device configuration bar to test a range of size class permutations. Assuming that the adaptivity settings are working, a larger font and a larger, different image should appear when previewing iPad configurations.

Before continuing to the next chapter, take some time to experiment by adding other variations, for example, to adjust the image size for smaller iPhone devices in landscape orientation (wC hC).

Summary

The range of iOS device screen sizes and resolutions is much more diverse than when the original iPhone was introduced in 2007. Today, developers need to be able to target an increasingly wide range of display sizes when designing user interface layouts for iOS apps. With size classes and trait variations, it is possible to target all screen sizes, resolutions, and orientations from within a single storyboard. This chapter has outlined the basics of size classes and trait variations and worked through a simple example user interface layout designed for both the iPad and iPhone device families.

A SwiftUI Charts Tutorial

The objective of this chapter is to demonstrate the main features of the SwiftUI Charts in the form of a tutorial. Topics covered include creating a chart, displaying data from multiple data sources, and use of the style modifier.

The concept behind the tutorial is a chart that displays monthly unit sales containing graphs for both online and retail sales channels.

Creating the ChartDemo Project

Launch Xcode and select the usual options to create a new multiplatform project named ChartDemo.

Adding the Project Data

The first requirement for handling the sales data is to declare a structure to store data for each month. Right-click on the ChartDemo folder in the Project Navigator panel, select the New File… menu option and create a new SwiftUI file named SalesData.swift. Edit the file and add the following struct declaration:

import Foundation

struct SalesInfo: Identifiable {
    var id = UUID()
    var month: String
    var total: Int
}

Remaining within the SalesData.swift file, add two arrays containing the retail and online sales data for January through July as follows:

.
.
var retailSales: [SalesInfo] = [
    .init(month: "Jan", total: 5),
    .init(month: "Feb", total: 7),
    .init(month: "March", total: 6),
    .init(month: "April", total: 5),
    .init(month: "May", total: 6),
    .init(month: "June", total: 3),
    .init(month: "July", total: 6)
]

var onlineSales: [SalesInfo] = [
    .init(month: "Jan", total: 2),
    .init(month: "Feb", total: 4),
    .init(month: "March", total: 5),
    .init(month: "April", total: 2),
    .init(month: "May", total: 4),
    .init(month: "June", total: 1),
    .init(month: "July", total: 4)    
]

Our next requirement is a third array that combines the data from both sales channels. Each element in this third array will also need to include a label string that can be used later when the time comes to split the data into separate channel graphs. Add this array now within the ContentView.swift file. Now is also a good opportunity to import the Charts framework:

import SwiftUI
import Charts

struct ContentView: View {
    var body: some View {

        let sales = [ (channel: "Retail", data: retailSales),
                        (channel: "Online", data: onlineSales) ]
.
.

Adding the Chart View

The chart view is going to need to use nested ForEach loops. The outer loop will iterate through the channel entries in the sales array while the inner loop will iterate through the sales data for each channel. Start by adding the Chart view and the outer loop as follows:

struct ContentView: View {
    var body: some View {

        let sales = [ (channel: "Retail", data: retailSales),
                        (channel: "Online", data: onlineSales) ]

        Chart {
            ForEach(sales, id: \.channel) { channels in
                
            }
        }
        .frame(height: 250)
        .padding()
   }
}

The outer loop passes the set of channels to the inner loop where we can then iterate through the sales data. For each month in the data, the inner ForEach loop will need to add an AreaMark instance configured with the month name and sales total. Modify the Chart view declaration so that it reads as follows:

Chart {
    ForEach(sales, id: \.channel) { channels in
        ForEach(channels.data) { sales in
            AreaMark(
                x: .value("Month", sales.month),
                y: .value("Total", sales.total)
            )
        }
    }
}
.
.

Review the chart in the Preview panel where it should appear as shown in Figure 1-1 below:

Figure 1-1

At this point, the chart is not appearing as intended because we have not taken any steps to separate the online and retail sales data. To divide the data into two graphs we will need to apply the foregroundStyle() modifier to the AreaMark declaration and configure it to filter the data based on the sales channel.

Creating Multiple Graphs

Edit the ContentView.swift file once again, locate the AreaMark instance, and apply the foregroundStyle() modifier:

Chart {
    ForEach(sales, id: \.channel) { channels in
        ForEach(channels.data) { sales in
            AreaMark(
                x: .value("Month", sales.month),
                y: .value("Total", sales.total)
            )
            .foregroundStyle(by: .value("Channel", channels.channel))
        }
    }
}

Preview the chart once again and confirm that it now appears correctly as shown in Figure 1-2:

Figure 1-2

Now that the project is complete, take some time to experiment with the chart by adding symbols and using different mark types and interpolation methods. Figure 1-3, for example, shows the effect of replacing AreaMark with PointMark:

Figure 1-3

Summary

In this chapter, we have created a project that uses SwiftUI Charts to visualize two sets of data in the form of area graphs. Steps covered in the tutorial included the use of the Chart and AreaMark components and the use of the foreground style modifier.

An Overview of SwiftUI Charts

One of the best ways to present data is to do so in the form of a chart or graph. While it has always been possible, given sufficient time and skills, to generate charts in SwiftUI by writing your own data handling and drawing code, it was not until the introduction of the SwiftUI Charts API that it became a simple as writing a few lines of code. In this chapter, we will explore the more commonly used features of SwiftUI Charts before creating a project in the next chapter that will allow you to see Charts in action and experiment with the main features of this API.

Introducing SwiftUI Charts

SwiftUI Charts consists of views and modifiers that allow you to visually present data, typically with just a few lines of code. The API is highly configurable and includes options to present data in the form of area, line, point, rectangle, bar, and stacked bar graphs. The Chart view also includes the rule mark which draws a straight line between specified start and end coordinates. The top-level view in any chart implementation is the Chart view. Each data point within a Chart takes the form of a mark view. The type of mark view used will depend on the chart style. For example, a bar chart will be made up of instances of BarMark, while an area graph will contain AreaMark instances. The Charts API supports the following mark types:

  • AreaMark
  • BarMark
  • LineMark
  • PointMark
  • RectangleMark
  • RuleMark

Each mark must be initialized with x and y values, each of which is represented by an instance of the PlottableValue class. The following declaration, for example, creates an area chart consisting of three marks (note that the Charts library must be imported when working with this API):

.
.
import Charts
.
.
Chart {
    AreaMark(
        x: PlottableValue.value("Month", "Jan"),
        y: PlottableValue.value("Temp", 50)
    )
    AreaMark(
        x: PlottableValue.value("Month", "Feb"),
        y: PlottableValue.value("Temp", 43)
    )
    AreaMark(
        x: PlottableValue.value("Month", "Mar"),
        y: PlottableValue.value("Temp", 61)
    )
}

For tidier code, the PlottableValue.value declarations can be abbreviated to .value, for example:

AreaMark(
    x: .value("Month", "Jan"),
    y: .value("Temp", 50)
)

The above example will generate a chart matching that shown in Figure 1-1 below:

Figure 1-1

Passing Data to the Chart

In the previous example, we manually coded each point on the chart. It is more likely, however, that the data to be plotted exists outside of the chart. In this situation we can pass the data as a parameter to the Chart view as follows:

struct MonthlyTemp: Identifiable {
    var id = UUID()
    var month: String
    var degrees: Int
}

let tempData: [MonthlyTemp] = [
    MonthlyTemp(month: "Jan", degrees: 50),
    MonthlyTemp(month: "Feb", degrees: 43),
    MonthlyTemp(month: "Mar", degrees: 61)
]

Chart(tempData) { data in
    AreaMark(
        x: .value("Month", data.month),
        y: .value("Temp", data.degrees)
    )
}

The above code will create the same chart illustrated in Figure 1-1. A ForEach loop will produce the same result:

Chart {
    ForEach(tempData) { data in
        AreaMark(
            x: .value("Month", data.month),
            y: .value("Temp", data.degrees)
        )
    }
}

Combining Mark Types

So far in this chapter, we have only displayed data using one type of graph. We can display the same data in different ways within the same chart. In the following example, our temperature data appears in both rectangle and line format:

Chart(tempData) { data in
    RectangleMark(
        x: .value("Month", data.month),
        y: .value("Temp", data.degrees)
    )
    LineMark(
        x: .value("Month", data.month),
        y: .value("Temp", data.degrees)
    )
}

This code will generate the graph shown in Figure 1-2:

Figure 1-2

Filtering Data into Multiple Graphs

If the data contains information that allows it to be filtered into categories, the foregroundStyle() modifier can be used to generate multiple graphs within a single chart instance. Suppose, for example, that our temperature data includes values for more than one year. This data might be declared as follows:

struct MonthlyTemp: Identifiable {
    var id = UUID()
    var month: String
    var degrees: Int
    var year: String
}

let tempData: [MonthlyTemp] = [
    MonthlyTemp(month: "Jan", degrees: 50, year: "2021"),
    MonthlyTemp(month: "Feb", degrees: 43, year: "2021"),
    MonthlyTemp(month: "Mar", degrees: 61, year: "2021"),
    
    MonthlyTemp(month: "Jan", degrees: 30, year: "2022"),
    MonthlyTemp(month: "Feb", degrees: 38, year: "2022"),
    MonthlyTemp(month: "Mar", degrees: 29, year: "2022")
]

The only change required to the Chart code is to apply the foregroundStyle() modifier to the mark declaration and pass it a PlottableValue instance configured to separate the data by year:

Chart {
    ForEach(tempData) { data in
        LineMark(
            x: .value("Month", data.month),
            y: .value("Temp", data.degrees)
        )
        .foregroundStyle(by: .value("Year", data.year))
    }
}

The chart will now separate the data into two lines, each rendered in a different color. Note also that a scale appears at the bottom of the chart showing the correspondence between colors and years:

Figure 1-3

Presenting Data with SwiftUI Charts In addition to the line colors, we can also add symbols at each mark point to further differentiate the data categories. We do this by applying the symbol() modifier to the mark declaration passing as a parameter an appropriately configured PlottableValue instance as outlined above for the foregroundStyle() modifier:

Chart {
    ForEach(tempData) { data in
        LineMark(
            x: .value("Month", data.month),
            y: .value("Temp", data.degrees)
        )
        .foregroundStyle(by: .value("Year", data.year))
        .symbol(by: .value("Year", data.year))
    }
}

A close inspection of the two lines in our chart will show that the lines are using different symbols for the mark points:

Figure 1-4

Changing the Chart Background

The background of the Chart view can be changed using the chartPlotStyle() modifier. This modifier is used with a trailing closure to which it passes a reference to the plot area on which the graph is being drawn. This reference may then be used to change properties such as the background color:

Chart {
.
.
}
.chartPlotStyle { plotArea in
    plotArea
        .background(.gray.opacity(0.3))
}
.
.

Changing the Interpolation Method

Interpolation refers to how lines are drawn to connect the data points in a graph. The interpolation method used by a chart can be changed by applying the interpolationMethod() modifier to mark declarations. The list of interpolation options is as follows:

  • cardinal
  • catmullRom
  • linear
  • monotone
  • stepCenter
  • stepEnd
  • stepStart

The following code example configures our example chart to use stepStart interpolation:

Chart {
    ForEach(tempData) { data in
        LineMark(
            x: .value("Month", data.month),
            y: .value("Temp", data.degrees)
        )
        .interpolationMethod(.stepStart)
        .foregroundStyle(by: .value("Year", data.year))
        .symbol(by: .value("Year", data.year))
    }
}

The resulting chart using stepStart interpolation will appear as shown below:

Figure 1-5

Summary

SwiftUI Charts provides an intuitive way to visualize data in the form of charts and graphs with minimal coding. This is achieved using a parent Chart view containing data points in the form of mark instances. Marks are available for several graph types including area, line, bar, plot, and rectangle. The x and y data for each mark is, in turn, contained within a PlottableValue instance. Graphs can be combined and configured using modifiers including changing the interpolation style and adding point symbol markers.

A SwiftUI NavigationStack Tutorial

The previous chapter introduced the List, NavigationStack, and NavigationLink views and explained how these can be used to present a navigable and editable list of items to the user. This chapter will work through the creation of a project intended to provide a practical example of these concepts.

About the ListNavDemo Project

When completed, the project will consist of a List view in which each row contains a cell displaying image and text information. Selecting a row within the list will navigate to a details screen containing more information about the selected item. In addition, the List view will include options to add and remove entries and to change the ordering of rows in the list.

The project extensively uses state properties and observable objects to keep the user interface synchronized with the data model.

Creating the ListNavDemo Project

Launch Xcode and select the option to create a new Multiplatform App named ListNavDemo.

Preparing the Project

Launch Xcode and select the option to create a new Multiplatform App named ListNavDemo.

Before beginning the development of the app project, some preparatory work needs to be performed involving the addition of image and data assets which will be needed later in the chapter.

The assets to be used in the project are included in the source code sample download provided with the book available from the following URL:

https://www.ebookfrenzy.com/web/swiftui-ios15/

Once the code samples have been downloaded and unpacked, open a Finder window, and locate the CarAssets.xcassets folder and drag and drop it onto the project navigator panel as illustrated in Figure 31-1:

Figure 31-1

When the options dialog appears, enable the Copy items if needed option so that the assets are included within the project folder before clicking on the Finish button. With the image assets added, find the carData.json file located in the CarData folder and drag and drop it onto the Project navigator panel to also add it to the project.

This JSON file contains entries for different hybrid and electric cars including a unique id, model, description, a Boolean property indicating whether or not it is a hybrid vehicle, and the filename of the corresponding car image in the asset catalog. The following, for example, is the JSON entry for the Tesla Model 3:

{
    "id": "aa32jj887hhg55",
    "name": "Tesla Model 3",
    "description": "Luxury 4-door all-electric car. Range of 310 miles. 0-60mph in 3.2 seconds ",
    "isHybrid": false,
    "imageName": "tesla_model_3"
}

Adding the Car Structure

Now that the JSON file has been added to the project, a structure needs to be declared to represent each car model. Add a new Swift file to the project by selecting the File -> New -> File… menu option, selecting Swift File in the template dialog and clicking on the Next button. Name the file Car.swift on the subsequent screen before clicking on the Create button.

Once created, the new file will load into the code editor where it needs to be modified so that it reads as follows:

import SwiftUI
 
struct Car : Codable, Identifiable {
    var id: String
    var name: String
    
    var description: String
    var isHybrid: Bool
    
    var imageName: String
}

As we can see, the structure contains a property for each field in the JSON file and is declared as conforming to the Identifiable protocol so that each instance can be uniquely identified within the List view.

31.5 Loading the JSON Data

The project will also need a way to load the carData.json file and translate the car entries into an array of Car objects. For this, we will add another Swift file containing a convenience function that reads the JSON file and initializes an array that can be accessed elsewhere in the project.

Using the steps outlined previously, add another Swift file named CarData.swift to the project and modify it as follows:

import UIKit
import SwiftUI
 
var carData: [Car] = loadJson("carData.json")
 
func loadJson<T: Decodable>(_ filename: String) -> T {
    let data: Data
    
    guard let file = Bundle.main.url(forResource: filename, 
                                  withExtension: nil)
    else {
        fatalError("\(filename) not found.")
    }
    
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Could not load \(filename): \(error)")
    }
    
    do {
        return try JSONDecoder().decode(T.self, from: data)
    } catch {
        fatalError("Unable to parse \(filename): \(error)")
    }
}

The file contains a variable referencing an array of Car objects which is initialized by a call to the loadJson() function. The loadJson() function is a standard example of how to load a JSON file and can be used in your own projects.

Adding the Data Store

When the user interface has been designed, the List view will rely on an observable object to ensure that the latest data is always displayed to the user. So far, we have a Car structure and an array of Car objects loaded from the JSON file to act as a data source for the project. The last step in getting the data ready for use in the app is to add a data store structure. This structure will need to contain a published property that can be observed by the user interface to keep the List view up to date. Add another Swift file to the project, this time named CarStore. swift, and implement the class as follows:

import SwiftUI
import Combine
 
class CarStore : ObservableObject {
    
    @Published var cars: [Car]
    
    init (cars: [Car] = []) {
        self.cars = cars
    }
}

This file contains a published property in the form of an array of Car objects and an initializer which is passed the array to be published.

With the data side of the project complete, it is now time to begin designing the user interface.

Designing the Content View

Select the ContentView.swift file and modify it as follows to add a state object binding to an instance of CarStore, passing through to its initializer the carData array created in the CarData.swift file:

import SwiftUI
 
struct ContentView: View {
    
    @StateObject var carStore : CarStore = CarStore(cars: carData)
.
.

The content view is going to require a List view to display information about each car. Now that we have access to the array of cars via the carStore property, we can use a ForEach loop to display a row for each car model. The cell for each row will be implemented as an HStack containing an Image and a Text view, the content of which will be extracted from the carData array elements. Remaining in the ContentView.swift file, delete the existing views and implement the list as follows:

.
.
var body: some View {
    
        List {
            ForEach (0..<carStore.cars.count, id: \.self) { i in
                HStack {
                    Image(carStore.cars[i].imageName)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 100, height: 60)
                    Text(carStore.cars[i].name)
               }
            }
        }
    }
}
.
.

With the change made to the view, use the preview canvas to verify that the list populates with content as shown in Figure 31-2:

Figure 31-2

Before moving to the next step in the tutorial, the cell declaration will be extracted to a subview to make the declaration tidier. Within the editor, hover the mouse pointer over the HStack declaration and hold down the keyboard Command key so that the declaration highlights. With the Command key still depressed, left-click and select the Extract Subview menu option:

Figure 31-3

Once the view has been extracted, change the name from the default ExtractedView to ListCell. Because the ListCell subview is used within a ForEach statement, the current car will need to be passed through when it is used. Modify both the ListCell declaration and the reference as follows to remove the syntax errors:

    var body: some View {
        
            List {
                ForEach (0..<carStore.cars.count, id: \.self) { i in
                    ListCell(car: carStore.cars[i])
            }
        }
    }
}
 
struct ListCell: View {
    
    var car: Car
    
    var body: some View {
        HStack {
            Image(car.imageName)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 100, height: 60)
            Text(car.name)
        }
    }
}

Use the preview canvas to confirm that the extraction of the cell as a subview has worked successfully.

Designing the Detail View

When a user taps a row in the list, a detail screen will appear showing additional information about the selected car. The layout for this screen will be declared in a separate SwiftUI View file which now needs to be added to the project. Use the File -> New -> File… menu option once again, this time selecting the SwiftUI View template option and naming the file CarDetail.

When the user navigates to this view from within the List, it will need to be passed the Car instance for the selected car so that the correct details are displayed. Begin by adding a property to the structure and configuring the preview provider to display the details of the first car in the carData array within the preview canvas as follows:

import SwiftUI
 
struct CarDetail: View {
    
    let selectedCar: Car
    
    var body: some View {
.
.
    }
}
 
struct CarDetail_Previews: PreviewProvider {
    static var previews: some View {
        CarDetails(selectedCar: carData[0])
    }
}

For this layout, a Form container will be used to organize the views. This is a container view that allows views to be grouped and divided into different sections. The Form also places a line divider between each child view.

Within the body of the CarDetail.swift file, implement the layout as follows:

var body: some View {
    Form {
        Section(header: Text("Car Details")) {
            Image(selectedCar.imageName)
                .resizable()
                .cornerRadius(12.0)
                .aspectRatio(contentMode: .fit)
                .padding()
                
                Text(selectedCar.name)
                    .font(.headline)
        
                Text(selectedCar.description)
                    .font(.body)
    
                HStack {
                    Text("Hybrid").font(.headline)
                    Spacer()
                    Image(systemName: selectedCar.isHybrid ?
                            "checkmark.circle" : "xmark.circle" )
                }
          }
    }
}

Note that the Image view is configured to be resizable and scaled to fit the available space while retaining the aspect ratio. Rounded corners are also applied to make the image more visually appealing and either a circle or checkmark image is displayed in the HStack based on the setting of the isHybrid Boolean setting of the selected car.

When previewed, the screen should match that shown in Figure 31-4:

Figure 31-4

Adding Navigation to the List

The next step in this tutorial is to return to the List view in the ContentView.swift file and implement navigation so that selecting a row displays the detail screen populated with the corresponding car details.

With the ContentView.swift file loaded into the code editor, locate the call to the ListCell subview declaration and embed it within a NavigationLink using the current ForEach counter as the link value:

var body: some View {
    List {
        ForEach (0..<carStore.cars.count, id: \.self) { i in
            NavigationLink(value: i) {
                ListCell(car: carStore.cars[i])
            }
        }
    
    }
}

For this navigation link to function, the List view must also be embedded in a NavigationStack as follows:

var body: some View {
    NavigationStack {
        List {
            ForEach (0..<carStore.cars.count, id: \.self) { i in
                NavigationLink(value: i) {
                    ListCell(car: carStore.cars[i])
                }
            }
        }
    }
}

Next, we need to add the navigation destination modifier to the list so that tapping an item navigates to the CarDetail view containing details of the selected car:

NavigationStack {
    List {
        ForEach (0..<carStore.cars.count, id: \.self) { i in
            NavigationLink(value: i) {
                ListCell(car: carStore.cars[i])
            }
        }
    }
    .navigationDestination(for: Int.self) { i in
        CarDetail(selectedCar: carStore.cars[i])
    }
}

Test that the navigation works using the preview canvas in Live mode and selecting different rows, confirming each time that the detail view appears containing information matching the selected car model.

Designing the Add Car View

The final view to be added to the project is the screen to be displayed when the user is adding a new car to the list. Add a new SwiftUI View file to the project named AddNewCar.swift including some state properties and a declaration for storing a reference to the carStore binding (this reference will be passed to the view from the ContentView when the user taps an Add button). Also, modify the preview provider to pass the carData array into the view for testing purposes:

import SwiftUI
 
struct AddNewCar: View {
    
    @StateObject var carStore : CarStore  
    @State private var isHybrid = false
    @State private var name: String = ""
    @State private var description: String = ""
.
.
struct AddNewCar_Previews: PreviewProvider {
    static var previews: some View {
        AddNewCar(carStore: CarStore(cars: carData))
    }
}

Next, add a new subview to the declaration that can be used to display a Text and TextField view pair into which the user will enter details of the new car. This subview will be passed a String value for the text to appear on the Text view and a state property binding into which the user’s input is to be stored. As outlined in the chapter entitled SwiftUI State Properties, Observable, State and Environment Objects, a property must be declared using the @Binding property wrapper if the view is being passed a state property. Remaining in the AddNewCar. swift file, implement this subview as follows:

struct DataInput: View {
    
    var title: String
    @Binding var userInput: String
    
    var body: some View {
        VStack(alignment: HorizontalAlignment.leading) {
            Text(title)
                .font(.headline)
            TextField("Enter \(title)", text: $userInput)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
        }
        .padding()
    }
}

With the subview added, declare the user interface layout for the main view as follows:

var body: some View {
    
    Form {
        Section(header: Text("Car Details")) {
            Image(systemName: "car.fill")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .padding()
            
            DataInput(title: "Model", userInput: $name)
            DataInput(title: "Description", userInput: $description)
 
            Toggle(isOn: $isHybrid) {
                    Text("Hybrid").font(.headline)
            }.padding()        
        }
        
        Button(action: addNewCar) {
            Text("Add Car")
            }
        }
}

Note that two instances of the DataInput subview are included in the layout with an Image view, a Toggle, and a Button. The Button view is configured to call an action method named addNewCar when clicked. Within the body of the ContentView declaration, add this function now so that it reads as follows:

.
.
            Button(action: addNewCar) {
                Text("Add Car")
                }
            }
    }
    
    func addNewCar() {
        let newCar = Car(id: UUID().uuidString,
                      name: name, description: description,
                      isHybrid: isHybrid, imageName: "tesla_model_3" )
        
        carStore.cars.append(newCar)
    }
}

The new car function creates a new Car instance using the Swift UUID() method to generate a unique identifier for the entry and the content entered by the user. For simplicity, rather than add code to select a photo from the photo library the function reuses the tesla_model_3 image for new car entries. Finally, the new Car instance is appended to the carStore car array.

When rendered in the preview canvas, the AddNewCar view should match Figure 31-5 below:

Figure 31-5

With this view completed, the next step is to modify the ContentView layout to include Add and Edit buttons.

31.11 Implementing Add and Edit Buttons

The Add and Edit buttons will be added to a navigation bar applied to the List view in the ContentView layout. The Navigation bar will also be used to display a title at the top of the list. These changes require the use of the navigationBarTitle() and navigationBarItems() modifiers as follows:

var body: some View {
    NavigationStack {
        List {
            ForEach (0..<carStore.cars.count, id: \.self) { i in
                NavigationLink(value: i) {
                    ListCell(car: carStore.cars[i])
                }
            }
        }
        .navigationDestination(for: Int.self) { i in
            CarDetail(selectedCar: carStore.cars[i])
        }
        .navigationBarTitle(Text("EV Cars"))
                .navigationBarItems(leading:
                NavigationLink(value: "Add Car") {
                    Text("Add")
                        .foregroundColor(.blue)
                }, trailing: EditButton())
    }
}

The Add button is configured to appear at the leading edge of the navigation bar and is implemented as a NavigationLink using a string value that reads “Add Car”. We now need to add a second navigation destination modifier for this string-based link that displays the AddNewCar view:

NavigationStack {
    List {
        ForEach (0..<carStore.cars.count, id: \.self) { i in
            NavigationLink(value: i) {
                ListCell(car: carStore.cars[i])
            }
        }
    }
    .navigationDestination(for: Int.self) { i in
        CarDetail(selectedCar: carStore.cars[i])
    }
    .navigationDestination(for: String.self) { _ in
        AddNewCar(carStore: self.carStore)
    }
    .navigationBarTitle(Text("EV Cars"))

The Edit button, on the other hand, is positioned on the trailing edge of the navigation bar and is configured to display the built-in EditButton view. A preview of the modified layout at this point should match the following figure:

Figure 31-6

Using Live Preview mode, test that the Add button displays the new car screen and that entering new car details and clicking the Add Car button causes the new entry to appear in the list after tapping the back button to return to the content view screen. Ideally, the Add Car button should automatically return us to the content view without the need to use the back button. To implement this, we will need to use a navigation path.

Adding a Navigation Path

Begin by editing the ContentView.swift file, adding a NavigationPath declaration, and passing it through to the NavigationStack:

struct ContentView: View {
    
    @StateObject var carStore : CarStore = CarStore(cars: carData)
    @State private var stackPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $stackPath) {
.
.

Having created a navigation path, we need to pass it to the AddNewCar view so that it can be used to return to the content view within the addNewCar() function. Edit the string-based .navigationDestination() modifier to pass the path binding to the view:

.navigationDestination(for: String.self) { _ in
    AddNewCar(carStore: carStore: self.carStore, path: $stackPath)
}

Edit the AddNewCar.swift file to add the path binding parameter as follows:

struct AddNewCar: View {
    
    @StateObject var carStore : CarStore
    @Binding var path: NavigationPath
.
.

We also need to comment out the preview provider, since the view is now expecting to be passed a navigation view to which we do not have access within the live preview:

/*
struct AddNewCar_Previews: PreviewProvider {
    static var previews: some View {
        AddNewCar(carStore: CarStore(cars: carData))
    }
}
*/

The last task in this phase of the tutorial is to call removeLast() on the navigation path to pop the current view off the navigation stack and return to the content view:

func addNewCar() {
    let newCar = Car(id: UUID().uuidString,
                  name: name, description: description,
                  isHybrid: isHybrid, imageName: "tesla_model_3" )
    
    carStore.cars.append(newCar)
    path.removeLast()
}

Build and run the app on a device or simulator and test that the Add Car button returns to the content view when clicked.

Adding the Edit Button Methods

The final task in this tutorial is to add some action methods to be used by the EditButton view added to the navigation bar in the previous section. Because these actions are to be available for every row in the list, the actions must be applied to the list cells as follows:

var body: some View {
    NavigationStack(path: $stackPath) {
        List {
            ForEach (0..<carStore.cars.count, id: \.self) { i in
                NavigationLink(value: i) {
                    ListCell(car: carStore.cars[i])
                }
            }
            .onDelete(perform: deleteItems)
            .onMove(perform: moveItems)
        }
        .navigationDestination(for: Int.self) { i in
.
.

Next, add the deleteItems() and moveItems() functions within the scope of the body declaration:

.
.
            .navigationBarTitle(Text("EV Cars"))
                    .navigationBarItems(leading:
                    NavigationLink(value: "Add Car") {
                        Text("Add")
                            .foregroundColor(.blue)
                    }, trailing: EditButton())
        }
    }
    
    func deleteItems(at offsets: IndexSet) {
        carStore.cars.remove(atOffsets: offsets)
    }
    
    func moveItems(from source: IndexSet, to destination: Int) {
        carStore.cars.move(fromOffsets: source, toOffset: destination)
    }
}

In the case of the deleteItems() function, the offsets of the selected rows are provided and used to remove the corresponding elements from the car store array. The moveItems() function, on the other hand, is called when the user moves rows to a different location within the list. This function is passed source and destination values which are used to match the row position in the array.

Using Live Preview, click the Edit button and verify that it is possible to delete rows by tapping the red delete icon next to a row and to move rows by clicking and dragging on the three horizontal lines at the far-right edge of a row. In each case, the list contents should update to reflect the changes:

Figure 31-7

Summary

The main objective of this chapter has been to provide a practical example of using lists, navigation views, and navigation links within a SwiftUI project. This included the implementation of dynamic lists and list editing features. The chapter also served to reinforce topics covered in previous chapters including the use of observable objects, state properties, and property bindings. The chapter also introduced some additional SwiftUI features including the Form container view, navigation bar items, and the TextField view.

SwiftUI NavigationStack and NavigationLink Overview

The SwiftUI List view provides a way to present information to the user in the form of a vertical list of rows. Often the items within a list will navigate to another area of the app when tapped by the user. Behavior of this type is implemented in SwiftUI using the NavigationStack and NavigationLink components.

The List view can present both static and dynamic data and may also be extended to allow for the addition, removal, and reordering of row entries.

This chapter will provide an overview of the List View used in conjunction with NavigationStack and NavigationLink in preparation for the tutorial in the next chapter entitled A SwiftUI NavigationStack Tutorial.

SwiftUI Lists

The SwiftUI List control provides similar functionality to the UIKit TableView class in that it presents information in a vertical list of rows with each row containing one or more views contained within a cell. Consider, for example, the following List implementation:

struct ContentView: View {
    var body: some View {
        
        List {
            Text("Wash the car")
            Text("Vacuum house")
            Text("Pick up kids from school bus @ 3pm")
            Text("Auction the kids on eBay")
            Text("Order Pizza for dinner")
        }
    }
}

When displayed in the preview, the above list will appear as shown in Figure 30-1:

Figure 30-1

A list cell is not restricted to containing a single component. In fact, any combination of components can be displayed in a list cell. Each row of the list in the following example consists of an image and text component within an HStack:

List {
    HStack {
        Image(systemName: "trash.circle.fill")
        Text("Take out the trash")
    }
    HStack {
        Image(systemName: "person.2.fill")
        Text("Pick up the kids") }
    HStack {
        Image(systemName: "car.fill")
        Text("Wash the car")
    }
}

The preview canvas for the above view structure will appear as shown in Figure 30-2 below:

Figure 30-2

Modifying List Separators and Rows

The lines used by the List view to separate rows can be hidden by applying the listRowSeparator() modifier to the cell content views. The listRowSeparatorTint() modifier, on the other hand, can be used to change the color of the lines. It is even possible to assign a view to appear as the background of a row using the listRowBackground() modifier. The following code, for example, hides the first separator, changes the tint of the next two separators, and displays a background image on the final row:

List {
    Text("Wash the car")
        .listRowSeparator(.hidden)
    Text("Pick up kids from school bus @ 3pm")
        .listRowSeparatorTint(.green)
    Text("Auction the kids on eBay")
        .listRowSeparatorTint(.red)
    Text("Order Pizza for dinner")
        .listRowBackground(Image("MyBackgroundImage"))
}

The above examples demonstrate the use of a List to display static information. To display a dynamic list of items a few additional steps are required.

SwiftUI Dynamic Lists

A list is considered to be dynamic when it contains a set of items that can change over time. In other words, items can be added, edited, and deleted and the list updates dynamically to reflect those changes.

To support a list of this type, each data element to be displayed must be contained within a class or structure that conforms to the Identifiable protocol. The Identifiable protocol requires that the instance contain a property named id which can be used to uniquely identify each item in the list. The id property can be any Swift or custom type that conforms to the Hashable protocol which includes the String, Int, and UUID types in addition to several hundred other standard Swift types. If you opt to use UUID as the type for the property, the UUID() method can be used to automatically generate a unique ID for each list item.

The following code implements a simple structure for the To Do list example that conforms to the Identifiable protocol. In this case, the id is generated automatically via a call to UUID():

struct ToDoItem : Identifiable {
    var id = UUID()
    var task: String
    var imageName: String
}

For example, an array of ToDoItem objects can be used to simulate the supply of data to the list which can now be implemented as follows:

struct ContentView: View {
 
    @State var listData: [ToDoItem] = [
       ToDoItem(task: "Take out trash", imageName: "trash.circle.fill"),
       ToDoItem(task: "Pick up the kids", imageName: "person.2.fill"),
       ToDoItem(task: "Wash the car", imageName: "car.fill")
       ]
 
    var body: some View {
        
        List(listData) { item in
            HStack {
                Image(systemName: item.imageName)
                Text(item.task)
            }
        }
    }
}
.
.

Now the list no longer needs a view for each cell. Instead, the list iterates through the data array and reuses the same HStack declaration, simply plugging in the appropriate data for each array element.

In situations where dynamic and static content needs to be displayed together within a list, the ForEach statement can be used within the body of the list to iterate through the dynamic data while also declaring static entries. The following example includes a static toggle button together with a ForEach loop for the dynamic content:

struct ContentView: View {
    
    @State private var toggleStatus = true
.
.    
    var body: some View {
        
        List {
            Toggle(isOn: $toggleStatus) {
                Text("Allow Notifications")
            }
            
            ForEach (listData) { item in
                HStack {
                    Image(systemName: item.imageName)
                    Text(item.task)
                }
            }
        }
    }
}

Note the appearance of the toggle button and the dynamic list items in Figure 30-3:

Figure 30-3

A SwiftUI List implementation may also be divided into sections using the Section view, including headers and footers if required. Figure 30-4 shows the list divided into two sections, each with a header:

Figure 30-4

The changes to the view declaration to implement these sections are as follows:

List {
    Section(header: Text("Settings")) {
        Toggle(isOn: $toggleStatus) {
            Text("Allow Notifications")
        }
    }
    
    Section(header: Text("To Do Tasks")) {
        ForEach (listData) { item in
            HStack {
                Image(systemName: item.imageName)
                Text(item.task)
            }
        }
    }
}

Often the items within a list will navigate to another area of the app when tapped by the user. Behavior of this type is implemented in SwiftUI using the NavigationStack and NavigationLink views.

Creating a Refreshable List

The data displayed on a screen is often derived from a dynamic source which is subject to change over time. The standard paradigm within iOS apps is for the user to perform a downward swipe to refresh the displayed data. During the refresh process, the app will typically display a spinning progress indicator after which the latest data is displayed. To make it easy to add this type of refresh behavior to your apps, SwiftUI provides the refreshable() modifier. When applied to a view, a downward swipe gesture on that view will display the progress indicator and execute the code in the modifier closure. For example, we can add refresh support to our list as follows:

List {
    Section(header: Text("Settings")) {
        Toggle(isOn: $toggleStatus) {
            Text("Allow Notifications")
        }
    }
    
    Section(header: Text("To Do Tasks")) {
        ForEach (listData) { item in
            HStack {
                Image(systemName: item.imageName)
                Text(item.task)
            }
        }
    }
}
.refreshable {
    listData = [
        ToDoItem(task: "Order dinner", imageName: "dollarsign.circle.fill"),
        ToDoItem(task: "Call financial advisor", imageName: "phone.fill"),
        ToDoItem(task: "Sell the kids", imageName: "person.2.fill")
        ]
}

Figure 30-5 demonstrates the effect of performing a downward swipe gesture within the List view after adding the above modifier. Note both the progress indicator at the top of the list and the appearance of the updated to-do list items:

Figure 30-5

When using the refreshable() modifier, be sure to perform any time-consuming activities as an asynchronous task using structured concurrency (covered previously in the chapter entitled “An Overview of Swift Structured Concurrency”). This will ensure that the app remains responsive during the refresh.

SwiftUI NavigationStack and NavigationLink

To make items in a list navigable, the first step is to embed the entire list within a NavigationStack. Once the list is embedded, the individual rows must be wrapped in a NavigationLink control which is, in turn, passed a value that uniquely identifies each navigation link within the context of the NavigationStack.

The following changes to our example code embed the List view in a NavigationStack and wrap the row content in a NavigationLink:

NavigationStack {
    List {
        Section(header: Text("Settings")) {
            Toggle(isOn: $toggleStatus) {
                Text("Allow Notifications")
            }
        }
                
        Section(header: Text("To Do Tasks")) {
            ForEach (listData) { item in
                NavigationLink(value: item.task) {
                    HStack {
                        Image(systemName: item.imageName)
                        Text(item.task)
                    }
                }
            }
        }
    }
}

Note that we have used the item task string as the NavigationLink value to uniquely identify each row. The next step is to specify the destination view to which the user is to be taken when the row is tapped. We achieve this by applying the navigationDestination(for:) modifier to the list. When adding this modifier, we need to pass it the value type for which it is to provide navigation. In our example we are using the task string, so we need to specify String.self as the value type. Within the trailing closure of the navigationDestination(for:) call we need to call the view that is to be displayed when the row is selected. This closure is passed the value from the NavigationLink, allowing us to display the appropriate view:

NavigationStack {   
    List {
.
.
        Section(header: Text("To Do Tasks")) {
            ForEach (listData) { item in
                NavigationLink(value: item.task) {
                    HStack {
                        Image(systemName: item.imageName)
                        Text(item.task)
                    }
                }
            }
        }
    }
    .navigationDestination(for: String.self) { task in
        Text("Selected task = \(task)")
    }
}

In this example, the navigation link will simply display a new screen containing the destination Text view displaying the item.task string value. The finished list will appear as shown in Figure 30-6 with the title and chevrons on the far right of each row now visible indicating that navigation is available. Tapping the links will navigate to and display the destination Text view.

Figure 30-6

Navigation by Value Type

The navigationDestination() modifier is particularly useful for adding navigation support to lists containing values of different types, with each type requiring navigation to a specific view. Suppose, for example, that in addition to the string-based task navigation link, we also have a NavigationLink which is passed an integer value indicating the number of tasks in the list. This could be implemented in our example as follows:

NavigationStack {
    
    List {
        
        Section(header: Text("Settings")) {
            Toggle(isOn: $toggleStatus) {
                Text("Allow Notifications")
            }

            NavigationLink(value: listData.count) {
                Text("View Task Count")
            }
        }
.
.

When this link is selected, we need the app to navigate to a Text view that displays the current task count. All this requires is a second navigationDestination() modifier, this time configured to handle Int instead of String values:

.
.
}
    .navigationDestination(for: String.self) { task in
        Text("Selected task = \(task)")
    }
    .navigationDestination(for: Int.self) { count in
        Text("Number of tasks = \(count)")
    }
.
.

This technique allows us to configure multiple navigation destinations within a single navigation stack based solely on the value type passed to each navigation link.

Working with Navigation Paths

As the name suggests, NavigationStack provides a stack on which navigation targets are stored as the user navigates through the screens of an app. When a user navigates from one view to another, a reference to the originating view is pushed onto the stack. If the user then navigates to another view, the current view will also be placed onto the stack. At any point, the user can tap the back arrow displayed in the navigation bar to move back to the previous view. As the user navigates back through the views, each one is popped off the stack until the view from which navigation began is reached.

The views through which a user navigates are called the navigation path. SwiftUI allows us to provide our own path by passing an instance of NavigationPath to the NavigationStack instance as follows:

struct ContentView: View {

   @State private var stackPath = NavigationPath()

   var body: some View {
        
        NavigationStack(path: $stackPath) {
.
.

With NavigationStack using our path, we can perform tasks such as manually popping targets off the stack to jump back multiple navigation levels instead of making the user navigate through the targets individually. We could, for example, configure a button on a view deep within the stack to take the user directly back to the home screen. We can do this by identifying how many navigation targets are in the stack and then removing them via a call to the removeLast() method of the path instance, for example:

var stackCount = stackPath.count
stackpath.removeLast(stackCount)

We can also programmatically navigate to specific destination views by calling the navigation path’s append() method and passing through the navigation value associated with the destination:

stackPath.append(value)

Navigation Bar Customization

The NavigationStack title bar may also be customized using modifiers on the List component to set the title and to add buttons to perform additional tasks. In the following code fragment the title is set to “To Do List” and a button labeled “Add” is added as a bar item and configured to call a hypothetical method named addTask():

NavigationStack {
    List {
.
.
    }
    .navigationBarTitle(Text("To Do List"))
    .navigationBarItems(trailing: Button(action: addTask) {
        Text("Add")
     })
.
.
}

Making the List Editable

It is common for an app to allow the user to delete items from a list and, in some cases, even move an item from one position to another. Deletion can be enabled by adding an onDelete() modifier to each list cell, specifying a method to be called which will delete the item from the data source. When this method is called it will be passed an IndexSet object containing the offsets of the rows being deleted and it is the responsibility of this method to remove the selected data from the data source. Once implemented, the user will be able to swipe left on rows in the list to reveal the Delete button as shown in Figure 30-7:

Figure 30-7

The changes to the example List to implement this behavior might read as follows:

.
.
List {
    Section(header: Text("Settings")) {
        Toggle(isOn: $toggleStatus) {
            Text("Allow Notifications")
        }
    }
    
    Section(header: Text("To Do Tasks")) {
        ForEach (listData) { item in
            NavigationLink(value: item.task) {
                HStack {
                    Image(systemName: item.imageName)
                    Text(item.task)
                }
            }
        }
        .onDelete(perform: deleteItem)
    }
}
.
.
func deleteItem(at offsets: IndexSet) {
    // Delete items from the data source here
}

To allow the user to move items up and down in the list the onMove() modifier must be applied to the cell, once again specifying a method to be called to modify the ordering of the source data. In this case, the method will be passed an IndexSet object containing the positions of the rows being moved and an integer indicating the destination position.

In addition to adding the onMove() modifier, an EditButton instance needs to be added to the List. When tapped, this button automatically switches the list into editable mode and allows items to be moved and deleted by the user. This edit button is added as a navigation bar item which can be attached to a list by applying the navigationBarItems() modifier. The List declaration can be modified as follows to add this functionality:

List {
    Section(header: Text("Settings")) {
        Toggle(isOn: $toggleStatus) {
            Text("Allow Notifications")
        }
    }
    
    Section(header: Text("To Do Tasks")) {
        ForEach (listData) { item in
            NavigationLink(value: item.task) {
                HStack {
                    Image(systemName: item.imageName)
                    Text(item.task)
                }
            }
        }
        .onDelete(perform: deleteItem)
        .onMove(perform: moveItem)
    }
    
}
.navigationBarTitle(Text("To Do List"))
.navigationBarItems(trailing: EditButton())
.
.
func moveItem(from source: IndexSet, to destination: Int) {
    // Reorder items in source data here
}

Viewed within the preview canvas, the list will appear as shown in Figure 30-8 when the Edit button is tapped. Clicking and dragging the three lines on the right side of each row allows the row to be moved to a different list position (in the figure below the “Pick up the kids” entry is in the process of being moved):

Figure 30-8

Hierarchical Lists

SwiftUI also includes support for organizing hierarchical data for display in list format as shown in Figure 30-9 below:

Figure 30-9

This behavior is achieved using features of the List view together with the OutlineGroup and DisclosureGroup views which automatically analyze the parent-child relationships within a data structure to create a browsable list containing controls to expand and collapse branches of data. This topic is covered in detail beginning with the chapter titled An Overview of SwiftUI List, OutlineGroup and DisclosureGroup.

Multicolumn Navigation

NavigationStack provides navigation between views where each destination occupies the entire device screen. SwiftUI also supports multicolumn navigation where the destinations appear together on the screen with each appearing in a separate column. Multicolumn navigation is provided by the NavigationSplitView component and will be covered beginning with the chapter titled Multicolumn Navigation in SwiftUI with NavigationSplitView.

Summary

The SwiftUI List view provides a way to order items in a single column of rows, each containing a cell. Each cell, in turn, can contain multiple views when those views are encapsulated in a container view such as a stack layout. The List view provides support for displaying both static and dynamic items or a combination of both. Lists may also be used to group, organize and display hierarchical data.

List views are used to allow the user to navigate to other screens. This navigation is implemented by wrapping the List declaration in a NavigationStack and each row in a NavigationLink, using the navigationDestination() modifier to define the navigation target view.

Lists can be divided into titled sections and assigned a navigation bar containing a title and buttons. Lists may also be configured to allow rows to be added, deleted, and moved.

A SwiftUI NavigationSplitView Tutorial

The previous chapter introduced us to the SwiftUI NavigationSplitView component and described how multicolumn navigation is implemented and configured. This chapter will take what we have learned about NavigationSplitView and use it to create an example iOS app project.

About the Project

This tutorial will create the three-column split navigation app illustrated in the previous chapter. The sidebar column will list categories which, when selected, will update the content column to list icons belonging to the chosen category. The detail column will, in turn, display the currently selected icon from the content list.

Creating the NavSplitDemo Project

Launch Xcode and select the option to create a new Multiplatform App project named NavSplitDemo.

Adding the Project Data

Our first requirement is a class declaration we can use to store category names and corresponding icons. Right-click on the NavSplitDemo folder in the Project navigator, select the New File… menu option, and create a new Swift file named IconCategory.swift. With the file added to the project, modify it to declare a structure for storing each icon category:

import Foundation

struct IconCategory: Identifiable, Hashable {
    let id = UUID()
    var categoryName: String
    var images: [String]
}

Next, edit the ContentView.swift file and add a state array variable populated with categories and icon names:

import SwiftUI

struct ContentView: View {

    @State private var categories = [
        IconCategory(categoryName: "Folders", images: ["questionmark.folder.ar",
                                                   "questionmark.folder",
                                                   "questionmark.folder.fill.ar",
                                                   "folder.fill.badge.gear",
                                                   "questionmark.folder.fill"]),
        IconCategory(categoryName: "Circles", images: ["book.circle",
                                                    "books.vertical.circle",
                                                    "books.vertical.circle.fill",
                                                    "book.circle.fill",
                                                    "book.closed.circle"]),
        IconCategory(categoryName: "Clouds", images: ["cloud.rain",
                                                   "cloud",
                                                   "cloud.drizzle.fill",
                                                   "cloud.fill",
                                                   "cloud.drizzle"])
    ]
.
.

Creating the Navigation View

The next step is to replace the default view within the ContentView with a NavigationSplitView as outlined below. As the project requires three columns, we need to include the sidebar, content, and detail declarations:

.
.
var body: some View {
    NavigationSplitView {
        
    } content: {
        
    } detail: {
    
    }
}
.
.

Building the Sidebar Column

Now that we have added the NavigationSplitView we are ready to add the list of categories in the sidebar column. While doing this, we also need to add a state variable to store the currently selected item in the list. Remaining in the ContentView.swift file, make these changes as follows:

.
.
@State private var selectedCategory: IconCategory?
var body: some View {
    NavigationSplitView(columnVisibility: $columnVisibility) {
        List(categories, selection: $selectedCategory) { category in
            Text(category.categoryName).tag(category)
        }
    } content: {
.
.

The code we have added displays a List view where each item is a Text view displaying a category name from the categories array. The List is passed a reference to our selectedCategory state variable for storing the most recent item selection.

Check the Preview panel and verify that the sidebar appears as shown in Figure 1-1 below:

Figure 1-1

Adding the Content Column List

With the sidebar completed, we are ready to add a list within the content column. Since we also need to keep track of the currently selected icon name we will need another state variable. To avoid showing a blank column, we also need to add some code to display a message if the user has not yet made a category selection. We can do this by using an if-let statement to evaluate the selectedCategory state variable:

.
.
@State private var selectedCategory: IconCategory?
@State private var selectedImage: String?

var body: some View {
    NavigationSplitView {
        List(categories, selection: $selectedCategory) { category in
            Text(category.categoryName).tag(category)
        }
    } content: {
        if let selectedCategory {
            List(selectedCategory.images, id: \.self, 
                           selection: $selectedImage) { image in
                HStack {
                    Image(systemName: image)
                    Text(image)
                }.tag(image)
            }
        } else {
            Text("Select a category")
        }
    } detail: {
    
    }
}
.
.

For each item in the list, we are using a horizontal stack containing the icon and name text. Select an iPad or iPhone Pro Max simulator or physical device as the run target and build and run the app. Once the app is running, rotate the device or simulator into landscape orientation. If the app is functioning as expected, selecting items from the sidebar should cause the content column to list the corresponding icon options as shown in Figure 1-2:

Figure 1-2

Adding the Detail Column

The detail column will display the selected icon in response to selections made within the content list. Locate the detail section within the NavigationSplitView declaration and modify it as follows, once again adding code to let the user know an icon needs to be selected:

NavigationSplitView {
    List(categories, selection: $selectedCategory) { category in
        Text(category.categoryName).tag(category)
    }
} content: {
.
.
} detail: {
    if let selectedImage {
        Image(systemName: selectedImage)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .padding()
    } else {
        Text("Select an image")
    }
}

When the app is launched it will appear with only the detail column visible as shown in Figure 1-3:

Figure 1-3

Clicking on the button marked by the arrow in the above figure will display the content column. Unfortunately, since we haven’t yet made a category selection, this column only shows the “Select a category” message. Clicking the Back button will finally give us access to the sidebar column where we can begin to make selections. Once a category and icon have been selected we can see that the detail column is obscured by the sidebar and content panels. To view the entire icon image we have to click on the detail column to hide the other two columns. While this may be the desired behavior in some situations, this is the opposite of the experience we are looking for in our app so some additional configuration is required.

Configuring the Split Navigation Experience

The average user would probably find our split navigation implementation confusing. To make the app more intuitive, we need all three columns to be visible when the app first launches. We can achieve this by passing the all value to the columnVisibility parameter of the NavigationSplitView instance as follows:

.
.
@State private var selectedCategory: IconCategory?
@State private var selectedImage: String?
@State private var columnVisibility = NavigationSplitViewVisibility.all

var body: some View {
    NavigationSplitView(columnVisibility: $columnVisibility) {
        List(categories, selection: $selectedCategory) { category in
            Text(category.categoryName).tag(category)
        }
.
.

When we test the app, we will see all three columns but still have the problem that the detail view is obscured by the sidebar and content columns. The final change is to use the navigationSplitViewStyle() modifier to apply the balanced style:

.
.
           if let selectedImage {
                Image(systemName: selectedImage)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .padding()
            } else {
                Text("Select an image")
            }
        }
        .navigationSplitViewStyle(.balanced)
    }
}
.
.

With this final change applied, the running app should now match Figure 1-4:

Figure 1-4

Summary

In this chapter, we created a SwiftUI-based app project that implements three-column navigation using the NavigationSplitView component. We also changed the default navigation behavior to match the requirements of our app.

Multicolumn Navigation in SwiftUI with NavigationSplitView

The NavigationStack and NavigationLink views outlined in the previous chapters are ideal for adding navigation when each destination view needs to fill the entire device screen. While this is generally the preferred navigation paradigm when working on most iOS devices, it doesn’t take advantage of larger display configurations available on the iPad or the iPhone Pro Max in landscape orientation. To take advantage of wider displays, SwiftUI includes the NavigationSplitView component which is designed to provide multicolumn-based navigation.

In this chapter, we will explain how to use NavigationSplitView in preparation for the next chapter titled A SwiftUI NavigationSplitView Tutorial.

Introducing NavigationSplitView

The purpose of NavigationSplitView is to provide multicolumn-based navigation on wide displays. It supports a maximum of three columns consisting of the sidebar (marked A in Figure 1-1), content (B), and detail (C) columns. A selection in one column controls the content displayed in the next column. The screen in Figure 1-1 shows the example app we will create in the next chapter. In this case, the app is running on an iPhone Pro Max in landscape orientation:

Figure 1-1

When a NavigationSplitView instance runs on narrower displays it behaves similarly to the NavigationStack where each destination view fully occupies the screen.

Using NavigationSplitView

NavigationSplitView uses a simple syntax that differs depending on whether you need two or three-column navigation. The following syntax is for two-column navigation, which consists of the sidebar and detail columns:

NavigationSplitView {
    // Sidebar List here
}  detail: {
    // Detail view here    
}

Three-column navigation consists of sidebar, content, and detail columns declared using the following syntax:

NavigationSplitView {
    // Sidebar List here
} content: {
    // Content List here
} detail: {
    // Detail view here
}

Handling List Selection

Both the sidebar and content columns will typically contain a List view from which the user will choose the content to be displayed in the next column. Selections made in a column are tracked by declaring a state variable and passing it to the List view via the selection parameter, for example:

@State private var colors = ["Red", "Green", "Blue"]
@State private var selectedColor: String?
    
var body: some View {
    NavigationSplitView {
        List(colors, id: \.self, selection: $selectedColor) { color in
            Text(color).tag(color)
        }
    } detail: {
        Text( selectedColor ?? "No color selected")
    }
}

In the code above, for example, the selection made within the List of color names controls the content displayed by the detail column via the selectedColor state variable. Note that the tag() modifier has been applied to the

Text list item. This is used by SwiftUI to differentiate between selectable items in views such as List, Picker, and TabView.

NavigationSplitView Configuration

Several options are available to customize the appearance of a NavigationSplitView. For example, the width of an individual column can be controlled using the navigationSplitViewColumnWidth() modifier. In the following code the sidebar column has been assigned a fixed width of 100 points:

NavigationSplitView {
    List(colors, id: \.self, selection: $selectedColor) { color in
        Text(color).tag(color)
    }
    .navigationSplitViewColumnWidth(100)
} detail: {
    Text( selectedColor ?? "No color selected")
}

Style options can also be configured by applying the navigationSplitViewStyle() modifier to the NavigationSplitView parent declaration as follows:

NavigationSplitView {
    List(colors, id: \.self, selection: $selectedColor) { color in
        Text(color).tag(color)
    }
} detail: {
    Text( selectedColor ?? "No color selected")
}

NavigationSplitView supports the following style options:

  • automatic – This style allows the navigation view to decide how to present columns based on factors such as current content, screen size, and column selections.
  • balanced – The balanced style reduces the width of the detail column when necessary to provide space for the sidebar and content columns.
  • prominentDetail – This style prevents the size of the detail column from changing when the sidebar and content columns are added and removed from view. This style usually results in the sidebar and content columns overlapping the detail column.

Controlling Column Visibility

Column visibility can be controlled programmatically by passing an appropriately configured state value to the NavigationSplitView via its columnVisibility initialization parameter. Changes to the state value will dynamically update the column visibility. Visibility options are provided by the NavigationSplitViewVisibility structure which includes the following options:

  • automatic – Allows the navigation view to decide which columns should be visible based on the available screen space.
  • all – Displays the sidebar, content, and detail columns.
  • doubleColumn – In a two-column configuration, this setting displays the sidebar and detail columns. In a three-column configuration, only the content and detail columns will be visible.
  • detailOnly – Only the detail column is visible.

The following code provides an example of setting column visibility based on a state variable set to detailOnly:

.
.
@State private var colors = ["Red", "Green", "Blue"]
@State private var selectedColor: String?
@State private var columnVisibility = NavigationSplitViewVisibility.detailOnly

var body: some View {
    NavigationSplitView(columnVisibility: $columnVisibility) {
        List(colors, id: \.self, selection: $selectedColor) { color in
            Text(color).tag(color)
        }
    } detail: {
        Text( selectedColor ?? "No color selected")
    }
    .navigationSplitViewStyle(.automatic)
}

Summary

The NavigationSplitView component allows you to build multicolumn-based navigation into your SwiftUI apps. When enough screen width is available, the NavigationSplitView will display up to three columns consisting of sidebar, content, and detail columns. The sidebar and content panels contain List view items that, when selected, control the content in the next column. Selection handling is implemented by passing a state variable to the selection parameter of the column’s List view. The column widths and style of a split navigation user interface can be configured using the navigationSplitViewColumnWidth() and navigationSplitViewStyle() modifiers. Several combinations of column visibility may be configured programmatically using the NavigationSplitView columnVisibility initialization parameter.

A SwiftUI Grid and GridRow Tutorial

The previous chapter introduced LazyHGrid, LazyVGrid, and GridItem views and explored how they can be used to create scrollable multicolumn layouts. While these views can handle large numbers of rows, they lack flexibility, particularly in grid cell arrangement and positioning.

In this chapter, we will work with two grid layout views (Grid and GridRow) that were introduced in iOS 16. While lacking support for large grid layouts, these two views provide several features that are not available when using the lazy grid views including column spanning cells, empty cells, and a range of alignment and spacing options.

Grid and GridRow Views

A grid layout is defined using the Grid view with each row represented by a GridRow child, and direct child views of a GridRow instance represent the column cells in that row.

The syntax for declaring a grid using Grid and GridRow is as follows:

Grid {
    
    GridRow {
        // Cell views here
    }
    
    GridRow {
        // Cell views here
    }
.
.
}

Creating the GridRowDemo Project

Launch Xcode and select the option to create a new Multiplatform App project named GridRowDemo. Once the project is ready, edit the ContentView.swift file to add a custom view to be used as the content for the grid cells in later examples:

struct CellContent: View {

    var index: Int
    var color: Color

    var body: some View {
        Text("\(index)")
            .frame(minWidth: 50, maxWidth: .infinity, minHeight: 100)
            .background(color)
            .cornerRadius(8)
            .font(.system(.largeTitle))
    }
}

A Simple Grid Layout

As a first step, we will create a simple grid 5 x 3 grid by modifying the body of the ContentView structure in the ContentView.swift file as follows:

struct ContentView: View {
    var body: some View {

        Grid {
            GridRow {
                ForEach(1...5, id: \.self) { index in
                    CellContent(index: index, color: .red)
                }
            }
            
            GridRow {
                ForEach(6...10, id: \.self) { index in
                    CellContent(index: index, color: .blue)
                }
            }
            
            GridRow {
                ForEach(11...15, id: \.self) { index in
                    CellContent(index: index, color: .green)
                }
            }
        }
        .padding()
    }
}

The above example consists of a Grid view parent containing three GridRow children. Each GridRow contains a ForEach loop that generates three CellContent views. After making these changes, the layout should appear within the Preview panel, in turn, as shown in Figure 1-1:

Figure 1-1

Non-GridRow Children

So far in this chapter, we have implied that the direct children of a Grid view must be GridRows. While this is the most common use of the Grid view, it is also possible to include children outside the scope of a GridRow. Grid children not contained within a GridRow will expand to occupy an entire row within the grid layout.

The following changes, for example, add a fourth row to the grid containing a single CellContent view that fills the row:

struct ContentView: View {
    var body: some View {
        Grid {
            GridRow {
                ForEach(1...5, id: \.self) { index in
                    CellContent(index: index, color: .red)
                }
            }
            
            GridRow {
                ForEach(6...10, id: \.self) { index in
                    CellContent(index: index, color: .blue)
                }
            }
            
            GridRow {
                ForEach(11...15, id: \.self) { index in
                    CellContent(index: index, color: .green)
                }
            }
            
            CellContent(index: 16, color: .blue)
        }
        .padding()
    }
}

Within the Preview panel, the grid should appear as shown in Figure 1-2 below:

Figure 1-2

Automatic Empty Grid Cells

When creating grids, we generally assume that each row must contain the same number of columns. This is not, however, a requirement when using the Grid and GridRow views. When the Grid view is required to display rows containing different cell counts, it will automatically add empty cells to shorter rows so that they match the longest row. To experience this in our example, change the ForEach loop ranges as follows:

.
.
GridRow {
    ForEach(1...5, id: \.self) { index in
        CellContent(index: index, color: .red)
    }
}

GridRow {
    ForEach(6...8, id: \.self) { index in
        CellContent(index: index, color: .blue)
    }
}

GridRow {
    ForEach(11...12, id: \.self) { index in
        CellContent(index: index, color: .green)
    }
}
.
.

When the grid is rendered, it will place empty cells within the rows containing fewer cells as shown in Figure 1-3:

Figure 1-3

Adding Empty Cells

In addition to allowing GridRow to add empty cells, you can also insert empty cells into fixed positions in a grid layout. Empty cells are represented by a Color view configured with the “clear” color value. Applying the .gridCellUnsizedAxes() modifier to the Color view ensures that the empty cell matches the default height and width of the occupied cells. Modify the first grid row in our example so that even-numbered columns contain empty cells:

GridRow {
    ForEach(1...5, id: \.self) { index in
        if (index % 2 == 1) {
            CellContent(index: index, color: .red)
        } else {
            Color.clear
                .gridCellUnsizedAxes([.horizontal, .vertical])
        }
    }
}

Refer to the Live Preview to verify that the empty cells appear in the first row of the grid as illustrated in Figure 1-4:

Figure 1-4

Column Spanning

A key feature of Grid and GridRow is the ability for a single cell to span a specified number of columns. We can achieve this by applying the .gridCellColumns() modifier to individual content cell views within GridRow declarations. Add another row to the grid containing two cells configured to span two and three columns respectively:

.
.
CellContent(index: 16, color: .blue)

GridRow {
    CellContent(index: 17, color: .orange)
        .gridCellColumns(2)
    CellContent(index: 18, color: .indigo)
        .gridCellColumns(3)
}
.
.

The layout will now appear as shown below:

Figure 1-5

Grid Alignment and Spacing

Spacing between rows and columns can be applied using the Grid view’s verticalSpacing and horizontalSpacing parameters, for example:

Grid(horizontalSpacing: 30, verticalSpacing: 0) {
    GridRow {
        ForEach(1...5, id: \.self) { index in
.
.

The above changes increase the spacing between columns while removing the spacing between rows so that the grid appears as shown in the figure below:

Figure 1-6

We designed CellContent view used throughout this chapter to fill the available space within a grid cell. As this makes it impossible to see changes in alignment, we need to add cells containing content that will demonstrate alignment settings. Begin by inserting two new rows at the top of the grid as outlined below. Also, remove the code that placed empty cells in the row containing cells 1 through 5 so that all cells are displayed:

struct ContentView: View {
    var body: some View {
        Grid {

            GridRow {
                CellContent(index: 0, color: .orange)
                Image(systemName: "record.circle.fill")
                Image(systemName: "record.circle.fill")
                Image(systemName: "record.circle.fill")
                CellContent(index: 0, color: .yellow)
                
            }
            .font(.largeTitle)
            
            GridRow {
                CellContent(index: 0, color: .orange)
                Image(systemName: "record.circle.fill")
                Image(systemName: "record.circle.fill")
                Image(systemName: "record.circle.fill")
                CellContent(index: 0, color: .yellow)
                
            }
            .font(.largeTitle)

            GridRow {
                ForEach(1...5, id: \.self) { index in
                        CellContent(index: index, color: .red)
                }
            }
.
.

After making these changes, refer to the preview and verify that the top three rows of the grid match that shown in Figure 1-7:

Figure 1-7

We can see from the positioning of the circle symbols that the Grid and GridRow views default to centering content within grid cells. The default alignment for all cells within a grid can be changed by assigning one of the following values to the alignment parameter of the Grid view:

  • .trailing
  • .leading • .top
  • .bottom
  • .topLeading
  • .topTrailing
  • .bottomLeading
  • .bottomTrailing
  • .center

Cell content can also be aligned with the baselines of text contained in adjoining cells using the following alignment values:

  • .centerFirstTextBaseline
  • .centerLastTextBaseline
  • .leadingFirstTextBaseline
  • .leadingLastTextBaseline
  • .trailingFirstTextBaseline
  • .trailingLastTextBaseline

Modify the Grid declaration in the example code so that all content is aligned at the leading top of the containing cell:

struct ContentView: View {
    var body: some View {
        Grid(alignment: .topLeading) {
.
.

Review the preview panel and confirm that the positioning of the circle symbols matches the layout shown in Figure 1-8:

Figure 1-8

It is also possible to override the default vertical alignment setting on individual rows using the alignment property of the GridRow view. The following code modifications, for example, change the second row of symbols to use bottom alignment:

struct ContentView: View {
    var body: some View {
        Grid(alignment: .topLeading) {
                
        GridRow(alignment: .bottom) {
            CellContent(index: 0, color: .orange)
            Image(systemName: "record.circle.fill")
            Image(systemName: "record.circle.fill")
            Image(systemName: "record.circle.fill")
            CellContent(index: 0, color: .yellow)
            
        }
        .font(.largeTitle)
.
.

The circles in the first row are now positioned along the bottom of the row while the second row continues to adopt the default alignment specified by the parent grid view:

Figure 1-9

Note that GridRow alignment will only adjust the vertical positioning of cell content. As illustrated above, the first row of circles has continued to use the leading alignment applied to the parent Grid view.

Horizontal content alignment for the cells in individual columns can be changed by applying the .gridColumnAlignment() modifier to any cell within the corresponding column. The following code change, for example, applies trailing alignment to the second grid column:

struct ContentView: View {
    var body: some View {
        Grid(alignment: .topLeading) {
                
        GridRow(alignment: .bottom) {
            CellContent(index: 0, color: .orange)
            Image(systemName: "record.circle.fill")
                .gridColumnAlignment(.trailing)
            Image(systemName: "record.circle.fill")
            Image(systemName: "record.circle.fill")
            CellContent(index: 0, color: .yellow)
            
        }
        .font(.largeTitle)
.
.

When previewed, the first grid rows will appear as illustrated in Figure 1-10:

Figure 1-10

Finally, you can override content alignment in an individual using the .gridCellAnchor() modifier as follows:

Grid(alignment: .topLeading) {
        
GridRow(alignment: .bottom) {
    CellContent(index: 0, color: .orange)
    Image(systemName: "record.circle.fill")
        .gridColumnAlignment(.trailing)
    Image(systemName: "record.circle.fill")
        .gridCellAnchor(.center)
    Image(systemName: "record.circle.fill")
        .gridCellAnchor(.top)
    CellContent(index: 0, color: .yellow)  
}
.font(.largeTitle)

Once the preview updates to reflect the above changes, the circle symbol rows should appear as shown below:

Figure 1-11

Summary

The Grid and GridRow views combine to provide highly flexible grid layout options when working with SwiftUI. While these views are unsuitable for displaying scrolling grids containing a large number of views, they have several advantages over the LazyVGrid and LazyHGrid views covered in the previous chapter. Particular strengths include the ability for a single cell to span multiple columns, support for empty cells, automatic addition of empty cells to maintain matching column counts, and the ability to adjust content alignment at the grid, row, and individual cell levels.

 

A Jetpack Compose In-App Purchasing Tutorial

In the previous chapter, we explored how to integrate in-app purchasing into an Android project and also looked at some code samples that can be used when working on your own projects. This chapter will put this theory into practice by creating an example project that demonstrates how to add a consumable in-app product to an Android app using Jetpack Compose. The tutorial will also show how in-app products are added and managed within the Google Play Console and explain how to enable test payments so that purchases can be made during testing without having to spend real money.

About the In-App Purchasing Example Project

The simple concept behind this project is an app in which an in-app product must be purchased before a button can be clicked. This in-app product is consumed each time the button is clicked, requiring the user to re-purchase the product each time they want to be able to click the button. On initialization, the app will connect to the app store, obtain details of the product, and display the product name. Once the app has established that the product is available, a purchase button will be enabled which, when clicked, will step through the purchase process. On completion of the purchase, a second button will be enabled so that the user can click on it and consume the purchase.

Creating the InAppPurchase Project

The first step in this exercise is to create a new project. Begin by launching Android Studio and selecting the New Project option from the welcome screen. In the new project dialog, choose the Empty Compose Activity template before clicking on the Next button.

Enter InAppPurchase into the Name field and specify a package name that will uniquely identify your app within the Google Play ecosystem (for example com.<your company>.InAppPurchase). Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo).

Within the MainActivity.kt file, delete the Greeting function and add a new empty composable named MainScreen:

@Composable
fun MainScreen() {
 
}

Next, edit the onCreateActivity() method function to call MainScreen instead of Greeting. Since this project will be using features that are not supported by the Preview panel, also delete the DefaultPreview composable from the file. To test the project we will be running it on a device or emulator session.

Adding Libraries to the Project

Before we start writing code, some libraries need to be added to the project build configuration, including the standard Android billing client libraries. Later in the project, we will also need to use the ImmutableList class which is part of Google’s Guava Core Java libraries. Add these libraries now by modifying the Gradle Scripts -> build.gradle (Module: InAppPurchase.app) file with the following changes:

.
.
dependencies {
.
.
    implementation 'com.android.billingclient:billing:5.0.0'
    implementation 'com.android.billingclient:billing-ktx:5.0.0'
    implementation 'com.google.guava:guava:24.1-jre'
    implementation 'com.google.guava:guava:27.0.1-android'
.
.

Click on the Sync Now link at the top of the editor panel to commit these changes.

Adding the App to the Google Play Store

Using the steps outlined in the chapter entitled Creating, Testing, and Uploading an Android App Bundle, sign into the Play Console, create a new app, and set up a new internal testing track including the email addresses of designated testers. Return to Android Studio and generate a signed release app bundle for the project. Once the bundle file has been generated, upload it to the internal testing track and roll it out for testing.

Now that the app has a presence in the Google Play Store, we are ready to create an in-app product for the project.

Creating an In-App Product

With the app selected in the Play Console, scroll down the list of options in the left-hand panel until the Monetize section comes into view. Within this section, select the In-app products option listed under Products as shown in Figure 1-1:

Figure 1-1

On the In-app products page, click on the Create product button:

Figure 1-2

On the new product screen, enter the following information before saving the new product:

  • Product ID: one_button_click
  • Name: A Button Click
  • Description: This is a test in-app product that allows a button to be clicked once.
  • Default price: Set to the lowest possible price in your preferred currency.

Enabling License Testers

When testing in-app billing it is useful to be able to make test purchases without spending any money. This can be achieved by enabling license testing for the internal track testers. License testers can use a test payment card when making purchases so that they are not charged.

Within the Play Console, return to the main home screen and select the Setup -> License testing option:

Figure 1-3

Within the license testing screen, add the testers that were added for the internal testing track, change the License response setting to RESPOND_NORMALLY, and save the changes:

Figure 1-4

Now that both the app and the in-app product have been set up in the Play Console, we can start adding code to the project.

Creating a Purchase Helper Class

To establish a clean separation between the user interface and billing code, we will create a new helper class that will handle all of the purchasing tasks and use StateFlow instances to update the user interface with status changes. While it may be tempting to create this helper class as a view model, doing so will result in unstable code. The problem is that the billing client will need a reference to the main activity to process purchase transactions. This means that we will need to pass this reference to our helper class when an instance is created. As we know from previous chapters, activities are subject to being destroyed and recreated during the lifecycle of an app. Since view models are, by definition, designed to survive the destruction and recreation of activities we run the risk within our billing code of relying on a reference to an activity that no longer exists. To avoid this problem we will declare our purchase helper as a standard Kotlin data class that will be destroyed and recreated along with the activity.

Within the Project tool window, right-click on the com.<your company>.InAppPurchase entry, select the New -> Kotlin Class/File menu option and create a new class named PurchaseHelper. With the new class file created, edit it so that it reads as follows:

.
.
import android.app.Activity
import android.util.Log
import com.android.billingclient.api.*
import com.google.common.collect.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
 
data class PurchaseHelper(val activity: Activity) {

}

These changes import a set of libraries that will be needed later in the chapter and configure the class to expect an Activity initialization parameter. Next, add variable declarations to store values related to the billing process together with the id of the product created in the Google Play Console:

.
.
data class PurchaseHelper(val activity: Activity)  {

    private val coroutineScope = CoroutineScope(Dispatchers.IO)

    private lateinit var billingClient: BillingClient
    private lateinit var productDetails: ProductDetails
    private lateinit var purchase: Purchase

    private val demoProductId = "one_button_click"
.
.

Adding the StateFlow Streams

Communication between the purchase process and the user interface will be performed using StateFlow streams. Specifically, the user interface will use these to display status information on Text components and to ensure that Buttons are appropriately enabled and disabled. Using the techniques outlined in the chapter titled Kotlin Flow with Jetpack Compose, add the following StateFlow declarations to the PurchaseHelper class:

data class PurchaseHelper(val activity: Activity)  {
.
.
    private val _productName = MutableStateFlow("Searching...")
    val productName = _productName.asStateFlow()

    private val _buyEnabled = MutableStateFlow(false)
    val buyEnabled = _buyEnabled.asStateFlow()

    private val _consumeEnabled = MutableStateFlow(false)
    val consumeEnabled = _consumeEnabled.asStateFlow()

    private val _statusText = MutableStateFlow("Initializing...")
    val statusText = _statusText.asStateFlow()
}

Initializing the Billing Client

Next, the PurchaseHelper class needs a method that can be called from the MainActivity to initialize the billing client. Remaining within the PurchaseHelper.kt file, add this new method as follows:

fun billingSetup() {
    billingClient = BillingClient.newBuilder(activity)
        .setListener(purchasesUpdatedListener)
        .enablePendingPurchases()
        .build()

    billingClient.startConnection(object : BillingClientStateListener {
        override fun onBillingSetupFinished(
            billingResult: BillingResult
        ) {
            if (billingResult.responseCode ==
                BillingClient.BillingResponseCode.OK
            ) {
                _statusText.value = "Billing Client Connected"
                queryProduct(demoProductId)
            } else {
                _statusText.value = "Billing Client Connection Failure"
            }
        }

        override fun onBillingServiceDisconnected() {
            _statusText.value = "Billing Client Connection Lost"
        }
    })
}

When this method is called, it will create a new billing client instance and attempt to connect to the Google Play Billing Library. The onBillingSetupFinished() listener will be called when the connection attempt completes and update the statusText state flow indicating the success or otherwise of the connection attempt. Finally, we have also implemented the onBillingServiceDisconnected() callback which will be called if the Google Play Billing Library connection is lost.

If the connection is successful a method named queryProduct() is called. Both this method and the purchasesUpdatedListener assigned to the billing client now need to be added.

Querying the Product

To make sure the product is available for purchase, we need to create a QueryProductDetailsParams instance configured with the product ID that was specified in the Play Console, and pass it to the queryProductDetailsAsync() method of the billing client. This will require that we also add the onProductDetailsResponse() callback method where we will check that the product exists, extract the product name, and assign it to the statusText state. Now that we have obtained the product details, we can also safely enable the purchase button via the buyEnabled flow. Within the PurchaseHelper.kt file, add the queryProduct() method so that it reads as follows:

fun queryProduct(productId: String) {
    val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
        .setProductList(
            ImmutableList.of(
                QueryProductDetailsParams.Product.newBuilder()
                    .setProductId(productId)
                    .setProductType(
                        BillingClient.ProductType.INAPP
                    )
                    .build()
            )
        )
        .build()

    billingClient.queryProductDetailsAsync(
        queryProductDetailsParams
    ) { billingResult, productDetailsList ->
        if (productDetailsList.isNotEmpty()) {
            productDetails = productDetailsList[0]
            _productName.value = "Product: " + productDetails.name
        } else {
            _statusText.value = "No Matching Products Found"
            _buyEnabled.value = false
        }
    }
}

Much of the code used here should be familiar from the previous chapter. The listener code checks that at least one product was found that matches the query criteria. The ProductDetails object is then extracted from the first matching product, stored in the productDetails variable, and the product name property assigned to the productName state flow.

Handling Purchase Updates

The results of the purchase process will be reported to the app via the PurchaseUpdatedListener that was assigned to the billing client during the initialization phase. Add this handler now as follows:

private val purchasesUpdatedListener =
    PurchasesUpdatedListener { billingResult, purchases ->
        if (billingResult.responseCode ==
            BillingClient.BillingResponseCode.OK
            && purchases != null
        ) {
            for (purchase in purchases) {
                completePurchase(purchase)
            }
        } else if (billingResult.responseCode ==
            BillingClient.BillingResponseCode.USER_CANCELED
        ) {
            _statusText.value = "Purchase Canceled"
        } else {
            _statusText.value = "Purchase Error"
        }
    }

The handler will update the status text if the user cancels the purchase or another error occurs. A successful purchase, however, results in a call to a method named completePurchase() which is passed the current Purchase object. Add this method as outlined below:

private fun completePurchase(item: Purchase) {
    purchase = item
    if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
        _buyEnabled.value = false
        _consumeEnabled.value = true
        _statusText.value = "Purchase Completed"
    }
}

This method stores the purchase before verifying that the product has indeed been purchased and that payment is not still pending. The consume button is enabled, the purchase button disabled, and the user is notified that the purchase was successful.

Launching the Purchase Flow

We now need to add the following method which will be called from the purchase button in the user interface to start the purchase process:

fun makePurchase() {
    val billingFlowParams = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(
            ImmutableList.of(
                BillingFlowParams.ProductDetailsParams.newBuilder()
                    .setProductDetails(productDetails)
                    .build()
            )
        )
        .build()

    billingClient.launchBillingFlow(activity, billingFlowParams)
}

Consuming the Product

With the user now able to click on the “consume” button, the next step is to make sure the product is consumed so that only one click can be performed before another button click is purchased. This requires that we now write the consumePurchase() method:

fun consumePurchase() {
    val consumeParams = ConsumeParams.newBuilder()
        .setPurchaseToken(purchase.purchaseToken)
        .build()

    coroutineScope.launch {
        val result = billingClient.consumePurchase(consumeParams)

        if (result.billingResult.responseCode ==
            BillingClient.BillingResponseCode.OK) {
            _statusText.value = "Purchase Consumed"
            _buyEnabled.value = true
            _consumeEnabled.value = false
        }
    }
}

This method creates a ConsumeParams instance and configures it with the purchase token for the current purchase (obtained from the Purchase object previously saved in the completePurchase() method). This is passed to the consumePurchase() method which is launched within a coroutine using the IO dispatcher. If the product is successfully consumed, the consume button is disabled and the status text updated.

Restoring a Previous Purchase

With the code added so far, we can purchase a product and consume it within a single session. If we were to make a purchase and then exit the app before consuming it the purchase would currently be lost when the app restarts. We can solve this problem by configuring a QueryPurchasesParams instance to search for the unconsumed In-App product and passing it to the queryPurchasesAsync() method of the billing client together with a reference to a listener that will be called with the results. Add a new method and the listener to the MainActivity.kt file as follows:

private fun reloadPurchase() {
    val queryPurchasesParams = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.INAPP)
        .build()

    billingClient.queryPurchasesAsync(
        queryPurchasesParams,
        purchasesListener
    )
}
 
private val purchasesListener =
    PurchasesResponseListener { billingResult, purchases ->
        if (purchases.isNotEmpty()) {
            purchase = purchases.first()
            _buyEnabled.value = false
            _consumeEnabled.value = true
            _statusText.value = "Previous Purchase Found"
        } else {
            _buyEnabled.value = true
            _consumeEnabled.value = false
        }
    }

If the list of purchases passed to the listener is not empty, the first purchase in the list is assigned to the purchase variable, and the consume button enabled (in a more complete implementation code should be added to check this is the correct product by comparing the product id and to handle the return of multiple purchases). If no purchases are found, the consume button is disabled until another purchase is made. All that remains is to call our new reloadPurchase() method during the billing setup process as follows:

fun billingSetup() {
.
.
            if (billingResult.responseCode ==
                BillingClient.BillingResponseCode.OK
            ) {
                _statusText.value = "Billing Client Connected"
                queryProduct(demoProductId)
                reloadPurchase()
            } else {
                _statusText.value = "Billing Client Connection Failure"
            }
        }
.
.
}

Completing the MainActivity

Now that the helper class is completed, changes need to be made to the MainActivity.kt file. The first step is to modify the onCreate() function to create an instance of our PurchaseHelper class and pass it to the MainScreen composable:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        InAppPurchaseTheme {
            // A surface container using the 'background' color from the theme
            Surface(
                modifier = Modifier.fillMaxSize(),
                color = MaterialTheme.colorScheme.background
            ) {
                val purchaseHelper = PurchaseHelper(this)
                purchaseHelper.billingSetup()
                MainScreen(purchaseHelper)
            }
        }
    }
}

Remaining in the MainActivity.kt file, modify the MainScreen function as follows to accept the purchase handler instance and to collect from the state flow instances:

.
.
import androidx.compose.runtime.*
.
.
@Composable
fun MainScreen(purchaseHelper: PurchaseHelper) {
 
    val buyEnabled by purchaseHelper.buyEnabled.collectAsState(false)
    val consumeEnabled by purchaseHelper.consumeEnabled.collectAsState(false)
    val productName by purchaseHelper.productName.collectAsState("")
    val statusText by purchaseHelper.statusText.collectAsState("")
}

The final task before testing the app is to call the composables that make up the user interface. This will consist of a Column containing two Text components and an embedded Row containing two Buttons configured to call the makePurchase() and consumePurchase() methods of the purchase handler. The content displayed by the Text composables and the status of the buttons will be controlled by the state flow values. Make the following changes to complete the MainScreen composable:

.
.
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Alignment
import androidx.compose.material.Button
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
.
.
@Composable
fun MainScreen(purchaseHelper: PurchaseHelper) {
.
.
    Column(
        Modifier.padding(20.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {

        Text(
            productName,
            Modifier.padding(20.dp),
            fontSize = 30.sp)

        Text(statusText)

        Row(Modifier.padding(20.dp)) {

           Button(
               onClick = { purchaseHelper.makePurchase() },
               Modifier.padding(20.dp),
               enabled = buyEnabled
           ) {
               Text("Purchase")
           }

           Button(
               onClick = { purchaseHelper.consumePurchase() },
               Modifier.padding(20.dp),
               enabled = consumeEnabled
           ) {
               Text("Consume")
           }
       }
    }
}

Testing the App

Before we can test the app we need to upload this latest version to the Play Console. As we already have version 1 uploaded, we first need to increase the version number in the build.gradle (Module: InAppPurchase.app) file:

.
.
defaultConfig {
    applicationId "com.ebookfrenzy.inapppurchase"
    minSdk 26
    targetSdk 32
    versionCode 2
    versionName "2.0"
.
.

Sync the build configuration, then follow the steps in the Creating, Testing, and Uploading an Android App Bundle chapter to generate a new app bundle, upload it to the internal test track and roll it out to the testers. Using the testing track link, install the app on a device or emulator on which one of the test accounts is signed in.

After the app starts the user interface should appear as shown in Figure 1-5 below with the billing client connected, the product name displayed, and the Purchase button enabled:

Figure 1-5

Clicking the Purchase button will begin the purchase flow as shown in Figure 1-6:

Figure 1-6

Tap the buy button to complete the purchase using the test card and wait for the Consume button to be enabled.

Tap the Consume button and wait for the “Purchase Consumed” status message to appear. With the product consumed, it should now be possible to purchase it again. Make another purchase, then terminate and restart the app. The app should locate the previous unconsumed purchase and enable the consume button.

Troubleshooting

For additional information about failures, a useful trick is to access the debug message from BillingResult instances, for example:

.
.
} else if (billingResult.responseCode ==
    BillingClient.BillingResponseCode.USER_CANCELED
) {
    _statusText.value = "Purchase Canceled"
} else {
    _statusText.value = "Purchase Error"
    Log.i("InAppPurchase", billingResult.getDebugMessage())
}

After adding the debug code, make sure the device is attached to Android Studio, either via a USB cable or WiFi, and select it from within the Logcat panel. Enter InAppPurchaseTag into the Logcat search bar and check the diagnostic output, adding additional Log calls in the code if necessary.

Note that as long as you leave the app version number unchanged in the module-level build.gradle file, you should now be able to run modified versions of the app directly on the device or emulator without having to re-bundle and upload it to the console.

If the test payment card is not listed, make sure the user account on the device has been added to the license testers list. If the app is running on a physical device, try running it on an emulator. If all else fails, you can enter a valid payment method to make test purchases, and then refund yourself using the Order management screen accessible from the Play Console home page.

Summary

In this chapter, we created a project that demonstrated how to add an in-app product to an Android app. This included the creation of the product within the Google Play Console and the writing of code to initialize and connect to the billing client, querying of available products, and, finally, the purchase and consumption of the product. We also explained how to add license testers using the Play Console so that purchases can be made during testing without spending money.