Mobile Test

Before the release of iOS 10, the only way to share CloudKit records between users was to store those records in a public database. With the introduction of CloudKit sharing, individual app users can now share private database records with other users.

This chapter aims to provide an overview of CloudKit sharing and the classes used to implement sharing within an iOS app. The techniques outlined in this chapter will be put to practical use in the An iOS CloudKit Sharing Tutorial chapter.

<s>let share = CKShare(rootRecord: myRecord)</s>
share[CKShare.SystemFieldKey.title] = "My First Share" as CKRecordValue
share.publicPermission = .readOnlyCode language: HTML, XML (xml)

Understanding CloudKit Sharing

CloudKit sharing provides a way for records within a private database to be shared with other app users, entirely at the discretion of the database owner. When a user decides to share CloudKit data, a share link in the form of a URL is sent to the person with whom the data is to be shared. This link can be sent in various ways, including text messages, email, Facebook, or Twitter. When the recipient taps on the share link, the app (if installed) will be launched and provided with the shared record information ready to be displayed.

The level of access to a shared record may also be defined to control whether a recipient can view and modify the record. It is important to be aware that when a share recipient accepts a share, they are receiving a reference to the original record in the owner’s private database. Therefore, a modification performed on a share will be reflected in the original private database.

Preparing for CloudKit Sharing

Before an app can take advantage of CloudKit sharing, the CKSharingSupported key needs to be added to the project Info.plist file with a Boolean true value. Also, a CloudKit record may only be shared if it is stored in a private database and is a member of a record zone other than the default zone.

The CKShare Class

CloudKit sharing is made possible primarily by the CKShare class. This class is initialized with the root CKRecord instance that is to be shared with other users together with the permission setting. The CKShare object may also be configured with title and icon information to be included in the share link message. The CKShare and associated CKRecord objects are then saved to the private database. The following code, for example, creates a CKShare object containing the record to be shared and configured for read-only access:

Once the share has been created, it is saved to the private database using a CKModifyRecordsOperation object. Note the recordsToSave: argument is declared as an array containing both the share and record objects:

let modifyRecordsOperation = CKModifyRecordsOperation(
    recordsToSave: [myRecord, share], recordIDsToDelete: nil)Code language: JavaScript (javascript)

Next, a CKConfiguration instance needs to be created, configured with optional settings, and assigned to the operation:

let configuration = CKOperation.Configuration()
        
configuration.timeoutIntervalForResource = 10
configuration.timeoutIntervalForRequest = 10Code language: JavaScript (javascript)

Next, a lambda must be assigned to the modifyRecordsResultBlock property of the modifyRecordsOperation object. The code in this lambda is called when the operation completes to let your app know whether the share was successfully saved:

modifyRecordsOperation.modifyRecordsResultBlock = { result in
    switch result {
    case .success:
        // Handle completion
    case .failure(let error):
        print(error.localizedDescription)
    }
}Code language: JavaScript (javascript)

Finally, the operation is added to the database to begin execution:

self.privateDatabase?.add(modifyRecordsOperation)Code language: CSS (css)

The UICloudSharingController Class

To send a share link to another user, CloudKit needs to know both the identity of the recipient and the method by which the share link is to be transmitted. One option is to manually create CKShareParticipant objects for each participant and add them to the CKShare object. Alternatively, the CloudKit framework includes a view controller specifically for this purpose. When presented to the user (Figure 51-1), the UICloudSharingController class provides the user with a variety of options for sending the share link to another user:

Figure 51-1

The app is responsible for creating and presenting the controller to the user, the template code for which is outlined below:

let controller = UICloudSharingController { 
	controller, prepareCompletionHandler in

	// Code here to create the CKShare and save it to the database
}

controller.availablePermissions = 
        [.allowPublic, .allowReadOnly, .allowReadWrite, .allowPrivate]

controller.popoverPresentationController?.barButtonItem =
    sender as? UIBarButtonItem

present(controller, animated: true)Code language: JavaScript (javascript)

Note that the above code fragment also specifies the permissions to be provided as options within the controller user interface. These options are accessed and modified by tapping the link in the Collaboration section of the sharing controller (in Figure 51-1 above, the link reads “Only invited people can edit”). Figure 51-2 shows an example share options settings screen:

Figure 51-2

Once the user selects a method of communication from the cloud-sharing controller, the completion handler assigned to the controller will be called. As outlined in the previous section, the CKShare object must be created and saved within this handler. After the share has been saved to the database, the cloud-sharing controller must be notified that the share is ready to be sent. This is achieved by a call to the prepareCompletionHandler method that was passed to the completion handler in the above code. When prepareCompletionHandler is called, it must be passed the share object and a reference to the app’s CloudKit container. Bringing these requirements together gives us the following code:

let controller = UICloudSharingController { controller,
    prepareCompletionHandler in

let share = CKShare(rootRecord: thisRecord)

        share[CKShare.SystemFieldKey.title]
                 = "An Amazing House" as CKRecordValue
        share.publicPermission = .readOnly

        // Create a CKModifyRecordsOperation object and configure it
        // to save the CKShare instance and the record to be shared.
        let modifyRecordsOperation = CKModifyRecordsOperation(
            recordsToSave: [myRecord, share],
            recordIDsToDelete: nil)

        // Create a CKOperation instance
        let configuration = CKOperation.Configuration()

        // Set configuration properties to provide timeout limits
        configuration.timeoutIntervalForResource = 10
        configuration.timeoutIntervalForRequest = 10

        // Apply the configuration options to the operation
        modifyRecordsOperation.configuration = configuration

        // Assign a completion block to the CKModifyRecordsOperation. This will
        // be called the modify records operation completes or fails. 
                     
        modifyRecordsOperation.modifyRecordsResultBlock = { result in
            switch result {
            case .success:
                // The share operation was successful. Call the completion
                // handler
                prepareCompletionHandler(share, CKContainer.default(), nil)
            case .failure(let error):
                print(error.localizedDescription)
            }
        }
        
        // Start the operation by adding it to the database
        self.privateDatabase?.add(modifyRecordsOperation)
}Code language: JavaScript (javascript)

Once the prepareCompletionHandler method has been called, the app for the chosen form of communication (Messages, Mail, etc.) will launch preloaded with the share link. All the user needs to do at this point is enter the contact details for the intended share recipient and send the message. Figure 51-3, for example, shows a share link loaded into the Mail app ready to be sent:

Figure 51-3

Accepting a CloudKit Share

When the recipient user receives a share link and selects it, a dialog will appear, providing the option to accept the share and open it in the corresponding app. When the app opens, the userDidAcceptCloudKitShareWith method is called on the scene delegate class located in the project’s SceneDelegate.swift file:

func windowScene(_ windowScene: UIWindowScene,
    userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
}Code language: CSS (css)

When this method is called, it is passed a CKShare.Metadata object containing information about the share. Although the user has accepted the share, the app must also accept the share using a CKAcceptSharesOperation object. As the acceptance operation is performed, it will report the results of the process via two result blocks assigned to it. The following example shows how to create and configure a CKAcceptSharesOperation instance to accept a share:

let container = CKContainer(identifier: metadata.containerIdentifier)
let operation = CKAcceptSharesOperation(shareMetadatas: [metadata])     
var rootRecordID: CKRecord.ID!

operation.acceptSharesResultBlock = { result in
    switch result {
    case .success:
        // The share was accepted successfully. Call the completion handler.
        completion(.success(rootRecordID))
    case .failure(let error):
        completion(.failure(error))
    }
}

operation.perShareResultBlock = { metadata, result in
    switch result {
    case .success:
        // The shared record ID was successfully obtained from the metadata.
        // Save a local copy for later. 
        rootRecordID = metadata.hierarchicalRootRecordID

        // Display the appropriate view controller and use it to fetch, and 
        // display the shared record.
        DispatchQueue.main.async {
            let viewController: ViewController = 
                    self.window?.rootViewController as! ViewController
            viewController.fetchShare(metadata)
        }        
    case .failure(let error):
        print(error.localizedDescription)
    }
}Code language: JavaScript (javascript)

The final step in accepting the share is to add the configured CKAcceptSharesOperation object to the CKContainer instance to accept share the share:

container.add(operation) Code language: CSS (css)

Fetching a Shared Record

Once a share has been accepted by both the user and the app, the shared record needs to be fetched and presented to the user. This involves the creation of a CKFetchRecordsOperation object using the root record ID contained within a CKShare.Metadata instance that has been configured with result blocks to be called with the results of the fetch operation. It is essential to be aware that this fetch operation must be executed on the shared cloud database instance of the app instead of the recipient’s private database. The following code, for example, fetches the record associated with a CloudKit share:

let operation = CKFetchRecordsOperation(
                     recordIDs: [metadata.hierarchicalRootRecordID!])

operation.perRecordResultBlock = { recordId, result in
    switch result {
    case .success(let record):
        DispatchQueue.main.async() {
             // Shared record successfully fetched. Update user 
             // interface here to present to the user. 
        }
    case .failure(let error):
        print(error.localizedDescription)
    }
}

operation.fetchRecordsResultBlock = { result in
    switch result {
    case .success:
        break
    case .failure(let error):
        print(error.localizedDescription)
    }
}

CKContainer.default().sharedCloudDatabase.add(operation)Code language: JavaScript (javascript)

Once the record has been fetched, it can be presented to the user within the perRecordResultBlock code, taking the steps above to perform user interface updates asynchronously on the main thread.

Summary

CloudKit sharing allows records stored within a private CloudKit database to be shared with other app users at the discretion of the record owner. An app user could, for example, make one or more records accessible to other users so that they can view and, optionally, modify the record. When a record is shared, a share link is sent to the recipient user in the form of a URL. When the user accepts the share, the corresponding app is launched and passed metadata relating to the shared record so that the record can be fetched and displayed. CloudKit sharing involves the creation of CKShare objects initialized with the record to be shared. The UICloudSharingController class provides a pre-built view controller which handles much of the work involved in gathering the necessary information to send a share link to another user. In addition to sending a share link, the app must also be adapted to accept a share and fetch the record for the shared cloud database. This chapter has covered the basics of CloudKit sharing, a topic that will be covered further in a later chapter entitled An iOS CloudKit Sharing Tutorial.

A Compose Lazy Staggered Grid Tutorial

The chapter Jetpack Compose Lists and Grids introduced the horizontal and vertical lazy grid composables and demonstrated how they could be used to organize items in rows and columns. However, a limitation of these layouts is that the grid cells are the same size. While this may be the desired behavior for many grid implementations, it presents a problem if you need to display a grid containing items of differing sizes. To address this limitation, Jetpack Compose 1.3 introduced staggered lazy grid composables.

This chapter will introduce the LazyVerticalStaggeredGrid and LazyHorizontalStaggeredGrid composables before creating an example project that puts theory into practice.

Lazy Staggered Grids

Horizontal and vertical staggered grid layouts are created using the LazyHorizontalStaggeredGrid and LazyVerticalStaggeredGrid composable, respectively. The columns parameter controls the grid’s appearance, which can be set to either adaptive or fixed mode. In adaptive mode, the grid will calculate the number of rows and columns that will fit into the available space, with even spacing between items and subject to a minimum specified cell size. Fixed mode, on the other hand, is passed the number of rows to be displayed and sizes each row or column equally to fill the available space. Configuration options are also available to reverse the layout, add content padding, disable scrolling, and define the spacing between cells. Figure 1-1 illustrates the arrangement of items in a vertical grid layout:

Figure 1-1

A typical staggered grid instance might be implemented as follows:

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Fixed(2),
    modifier = Modifier.fillMaxSize(),
    contentPadding = PaddingValues(16.dp),
    verticalItemSpacing: 16.dp,
    horizontalArrangement = Arrangement.spacedBy(16.dp),
    verticalArrangement = Arrangement.spacedBy(16.dp),
    userScrollEnabled: true
    ) {
    items(items) { item ->
        // Cell content here
    }
}Code language: Kotlin (kotlin)

The above example creates a LazyVerticalStaggeredGrid consisting of two fixed columns with content padding and spacing between cells, the layout for which would resemble Figure 1-2:

Figure 1-2

The following is the equivalent code to create a horizontal staggered grid:

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Fixed(2),
    modifier = Modifier.fillMaxSize(),
    contentPadding = PaddingValues(16.dp),
    verticalItemSpacing: 16.dp,
    horizontalArrangement = Arrangement.spacedBy(16.dp),
    verticalArrangement = Arrangement.spacedBy(16.dp),
    userScrollEnabled: true
    ) {
    items(items) { item ->
        // Cell content here
    }
}Code language: Kotlin (kotlin)

In the rest of this chapter, we will create a project demonstrating how to use a staggered grid. The example will display a grid containing items configured with random heights and colors.

Creating the StaggeredGridDemo project

Launch Android Studio and select the New Project option from the welcome screen. Choose the Empty Activity template in the new project dialog before clicking the Next button.

Enter StaggeredGridDemo into the Name field and specify com.example.staggeredgriddemo as the package name. Before clicking 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:

LazyHorizontalStaggeredGrid(
    rows = StaggeredGridCells.Fixed(2),
    modifier = Modifier.fillMaxSize(),
    contentPadding = PaddingValues(16.dp),
    horizontalItemSpacing: 16.dp,
    verticalArrangement = Arrangement.spacedBy(16.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp),
    userScrollEnabled: true
    ) {
    items(items) { item ->
        // Cell content here
    }
}Code language: Kotlin (kotlin)

Next, edit the onCreateActivity() method and GreetingPreview function to call MainScreen instead of Greeting.

Adding the Box composable

The grid item in this project will be represented by a Box composable. Each instance of this box will be configured with random height and background color properties. Before we write the code for the Box, we first need a data class to store the color and height values, which we can pass to the Box composable. Within the MainActivity.kt file, declare the data class as follows:

@Composable
fun MainScreen() {
    
}Code language: Kotlin (kotlin)

Next, add a composable named GridItem to display a Box composable based on the values of a BoxProperties instance:

.
.
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.graphics.Color
.
.
data class BoxProperties(
    val color: Color,
    val height: Dp
)
Next, add a composable named GridItem to display a Box composable based on the values of a BoxProperties instance:
.
.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
.
.
@Composable
fun GridItem(properties: BoxProperties) {
    Box(modifier = Modifier
        .fillMaxWidth()
        .height(properties.height)
        .clip(RoundedCornerShape(10.dp))
        .background(properties.color)
    )
}Code language: Kotlin (kotlin)

Generating random height and color values

Now that we have a grid item and a way to store the current item properties, the next step is to write code to generate random height and color values. We will do this by creating a list of BoxProperties items, calling the Kotlin Random.nextInt() method for each instance to generate height and RGB color values. Edit the MainScreen composable to add the following code:

.
.
import androidx.compose.foundation.ExperimentalFoundationApi

import kotlin.random.Random
.
.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MainScreen() {
 
    val items = (1 .. 50).map {
        BoxProperties(
            height = Random.nextInt(50, 200).dp,
            color = Color(
                Random.nextInt(255),
                Random.nextInt(255),
                Random.nextInt(255),
                255
            )
        )
    }
}Code language: JavaScript (javascript)

The above code configures 50 BoxProperties instances with random height values between 50 and 200 dp. Next, Color objects are created using random RGB values (0 to 255). In addition, the alpha Color property is set to 255 to ensure only solid, non-transparent colors are generated.

Note that the above code includes a directive to opt into experimental API features. At the time of writing, the staggered grid composables were still in the experimental development phase. Depending on when you are reading this book, this setting may no longer be required.

Creating the Staggered List

The final task before testing the app is to add the LazyVerticalStaggeredGrid to the layout. The goal is to create a staggered vertical grid using the items list containing three fixed-width columns with horizontal and vertical spacing between each cell. Edit the MainScreen composable once again and modify it as follows:

.
.
import androidx.compose.foundation.lazy.staggeredgrid.*
.
.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MainScreen() {

    val items = (1 .. 50).map {
        BoxProperties(
.
.
    }

    LazyVerticalStaggeredGrid(
        columns = StaggeredGridCells.Fixed(3),
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(items) { values ->
            GridItem(properties = values)
        }
    }
}Code language: JavaScript (javascript)

Testing the project

With the code writing completed, display the preview panel where the staggered grid layout should appear, as shown in Figure 1-3 (allowing, of course, for the random color and height properties). Assuming that the layout is rendered as expected, enable interactive mode and test that it is possible to scroll vertically through the grid items.

Figure 1-3

Switching to a horizontal staggered grid

To convert the example grid to use the LazyHorizontalStaggeredGrid layout, make the following changes to the MainActivity.kt file:

.
.
data class BoxProperties(
    val color: Color,
    val width: Dp
)

@Composable
fun GridItem(properties: BoxProperties) {
    Box(modifier = Modifier
        .fillMaxWidth()
        .width(properties.width)
        .clip(RoundedCornerShape(10.dp))
        .background(properties.color)
    )
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MainScreen() {

    val items = (1 .. 50).map {
        BoxProperties(
            width = Random.nextInt(50, 200).dp,
.
.
    }

    LazyHorizontalStaggeredGrid(
        rows = StaggeredGridCells.Fixed(3),
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(items) { values ->
            GridItem(properties = values)
        }
    }
}Code language: Kotlin (kotlin)

Horizontal staggered grid layouts are best tested on a device or emulator in landscape orientation, so run the app on a suitable target where the layout should appear as shown in Figure 1-4:

Figure 1-4

Once the app has launched, check that you can scroll horizontally through the grid.

Summary

In this chapter, we have introduced the vertical and horizontal lazy grid composables. These layouts are useful when items of varying sizes need to be shown in a grid format. Grids can be presented in either adaptive or fixed mode. Adaptive mode calculates how many rows or columns will fit into the available space, with even spacing between items and subject to a minimum specified size. Fixed mode, on the other hand, is passed the number of rows or columns to be displayed and sizes each to fill the available space.

An iOS 16 CloudKit Sharing Tutorial

The chapter entitled An Introduction to iOS 16 CloudKit Sharing provided an overview of how CloudKit sharing works and the steps involved in integrating sharing into an iOS app. The intervening chapters have focused on creating a project that demonstrates the integration of CloudKit data storage into iOS apps. This chapter will extend the project started in the previous chapter to add CloudKit sharing to the CloudKitDemo app.

Preparing the Project for CloudKit Sharing

Launch Xcode and open the CloudKitDemo project created in this book’s chapter entitled An Introduction to iOS 16 CloudKit Sharing. If you have not completed the tasks in the previous chapter and are only interested in learning about CloudKit sharing, a snapshot of the project is included as part of the sample code archive for this book on the following web page:

https://www.ebookfrenzy.com/web/ios16/

Once the project has been loaded into Xcode, the CKSharingSupported key needs to be added to the project Info.plist file with a Boolean value of true. Select the CloudKitDemo target at the top of the Project Navigator panel, followed by the Info tab in the main panel. Next, locate the bottom entry in the Custom iOS Target Properties list, and hover the mouse pointer over the item. When the plus button appears, click it to add a new entry to the list. Complete the new property with the key field set to CKSharingSupported, the type to Boolean, and the value to YES, as illustrated in Figure 53-1:

Figure 53-1

Adding the Share Button

The user interface for the app now needs to be modified to add a share button to the toolbar. First, select the Main.storyboard file, locate the Bar Button Item in the Library panel, and drag and drop an instance onto the toolbar to position it to the right of the existing delete button.

Once added, select the button item, display the Attributes inspector, and select the square and arrow image:

Figure 53-2

Once the new button has been added, the toolbar should match Figure 53-3:

Figure 53-3

With the new share button item still selected, display the Assistant Editor panel and establish an Action connection to a method named shareRecord.

Creating the CloudKit Share

The next step is to add some code to the shareRecord action method to initialize and display the UICloudSharingController and to create and save the CKShare object. Next, select the ViewController.swift file, locate the stub shareRecord method, and modify it so that it reads as follows:

@IBAction func shareRecord(_ sender: Any) {

    let controller = UICloudSharingController { controller,
        prepareCompletionHandler in
        
        if let thisRecord = self.currentRecord {
            let share = CKShare(rootRecord: thisRecord)
            
            share[CKShare.SystemFieldKey.title] = 
                             "An Amazing House" as CKRecordValue
            share.publicPermission = .readOnly
            
            let modifyRecordsOperation = CKModifyRecordsOperation(
                recordsToSave: [thisRecord, share],
                recordIDsToDelete: nil)
            
            let configuration = CKOperation.Configuration()
            
            configuration.timeoutIntervalForResource = 10
            configuration.timeoutIntervalForRequest = 10
                         
            modifyRecordsOperation.modifyRecordsResultBlock = {
                result in
                switch result {
                case .success:
                    prepareCompletionHandler(share, CKContainer.default(), nil)
                case .failure(let error):
                    print(error.localizedDescription)
                }
            }     
            self.privateDatabase?.add(modifyRecordsOperation)
        } else {
            print("User error: No record selected")
        }
    }
    
    controller.availablePermissions = [.allowPublic, .allowReadOnly,
            .allowReadWrite, .allowPrivate]
    controller.popoverPresentationController?.barButtonItem =
        sender as? UIBarButtonItem
    
    present(controller, animated: true)
}Code language: Swift (swift)

The code added to this method follows the steps outlined in the chapter entitled An Introduction to iOS 16 CloudKit Sharing to display the CloudKit sharing view controller, create a share object initialized with the currently selected record and save it to the user’s private database.

Accepting a CloudKit Share

Now that the user can create a CloudKit share, the app needs to be modified to accept a share and display it to the user. The first step in this process is implementing the userDidAcceptCloudKitShareWith method within the project’s scene delegate class. Edit the SceneDelegate.swift file and implement this method as follows:

.
.
import CloudKit
.
.
func windowScene(_ windowScene: UIWindowScene,
    userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
   
    acceptCloudKitShare(metadata: cloudKitShareMetadata) { [weak self] result in
        switch result {
        case .success:
            DispatchQueue.main.async {
                let viewController: ViewController = 
                     self?.window?.rootViewController as! ViewController
                viewController.fetchShare(cloudKitShareMetadata)
            }
        case .failure(let error):
            print(error.localizedDescription )
        }
    }
}
.
.Code language: Swift (swift)

When the user clicks on a CloudKit share link, for example, in an email or text message, the operating system will call the above method to notify the app that shared CloudKit data is available. The above implementation of this method calls a method named acceptCloudKitShare and passes it the CloudKitShareMetadata object it received from the operating system. If the acceptCloudKitShare method returns a successful result, the delegate method obtains a reference to the app’s root view controller and calls a method named fetchShare (which we will write in the next section) to extract the shared record from the CloudKit database and display it. Next, we need to add the acceptCloudKitShare method as follows:

Fetching the Shared Record

At this point, the share has been accepted and a CKShare.Metadata object provided, from which information about the shared record may be extracted. All that remains before the app can be tested is to implement the fetchShare method within the ViewController.swift file:

func fetchShare(_ metadata: CKShare.Metadata) {

    let operation = CKFetchRecordsOperation(recordIDs: 
                            [metadata.hierarchicalRootRecordID!])

    operation.perRecordResultBlock = { recordId, result in
        switch result {
        case .success(let record):
            DispatchQueue.main.async() {
                self.currentRecord = record
                self.addressField.text =
                    record.object(forKey: "address") as? String
                self.commentsField.text =
                    record.object(forKey: "comment") as? String
                let photo =
                    record.object(forKey: "photo") as! CKAsset
                let image = UIImage(contentsOfFile:
                                        photo.fileURL!.path)
                self.imageView.image = image
                self.photoURL = self.saveImageToFile(image!)
            }
        case .failure(let error):
            print(error.localizedDescription)
        }
    }
    
    operation.fetchRecordsResultBlock = { result in
        switch result {
        case .success:
            break
        case .failure(let error):
            print(error.localizedDescription)
        }
    }
    CKContainer.default().sharedCloudDatabase.add(operation)
}Code language: Swift (swift)

The method prepares a standard CloudKit fetch operation based on the record ID contained within the share metadata object and performs the fetch using the sharedCloudDatabase instance. On a successful fetch, the completion handler extracts the data from the shared record and displays it in the user interface.

Testing the CloudKit Share Example

To thoroughly test CloudKit sharing, two devices with different Apple IDs must be used. If you have access to two devices, create a second Apple ID for testing purposes and sign in using that ID on one of the devices. Once logged in, make sure that the devices can send and receive iMessage or email messages between each other and install and run the CloudKitDemo app on both devices. Once the testing environment is set up, launch the CloudKitDemo app on one of the devices and add a record to the private database. Once added, tap the Share button and use the share view controller interface to send a share link message to the Apple ID associated with the second device. When the message arrives on the second device, tap the share link and accept the share when prompted. Once the share has been accepted, the CloudKitDemo app should launch and display the shared record.

Summary

This chapter puts the theory of CloudKit sharing outlined in the chapter entitled An Introduction to iOS 16 CloudKit Sharing into practice by enhancing the CloudKitDemo project to include the ability to share CloudKit-based records with other app users. This involved creating and saving a CKShare object, using the UICloudSharingController class, and adding code to handle accepting and fetching a shared CloudKit database record.

An Introduction to iOS 16 CloudKit Sharing

Before the release of iOS 10, the only way to share CloudKit records between users was to store those records in a public database. With the introduction of CloudKit sharing, individual app users can now share private database records with other users.

This chapter aims to provide an overview of CloudKit sharing and the classes used to implement sharing within an iOS app. The techniques outlined in this chapter will be put to practical use in the An iOS CloudKit Sharing Tutorial chapter.

Understanding CloudKit Sharing

CloudKit sharing provides a way for records within a private database to be shared with other app users, entirely at the discretion of the database owner. When a user decides to share CloudKit data, a share link in the form of a URL is sent to the person with whom the data is to be shared. This link can be sent in various ways, including text messages, email, Facebook, or Twitter. When the recipient taps on the share link, the app (if installed) will be launched and provided with the shared record information ready to be displayed.

The level of access to a shared record may also be defined to control whether a recipient can view and modify the record. It is important to be aware that when a share recipient accepts a share, they are receiving a reference to the original record in the owner’s private database. Therefore, a modification performed on a share will be reflected in the original private database.

Preparing for CloudKit Sharing

Before an app can take advantage of CloudKit sharing, the CKSharingSupported key needs to be added to the project Info.plist file with a Boolean true value. Also, a CloudKit record may only be shared if it is stored in a private database and is a member of a record zone other than the default zone.

The CKShare Class

CloudKit sharing is made possible primarily by the CKShare class. This class is initialized with the root CKRecord instance that is to be shared with other users together with the permission setting. The CKShare object may also be configured with title and icon information to be included in the share link message. The CKShare and associated CKRecord objects are then saved to the private database. The following code, for example, creates a CKShare object containing the record to be shared and configured for read-only access:

let share = CKShare(rootRecord: myRecord)
share[CKShare.SystemFieldKey.title] = "My First Share" as CKRecordValue
share.publicPermission = .readOnlyCode language: Swift (swift)

Once the share has been created, it is saved to the private database using a CKModifyRecordsOperation object. Note the recordsToSave: argument is declared as an array containing both the share and record objects:

let modifyRecordsOperation = CKModifyRecordsOperation(
    recordsToSave: [myRecord, share], recordIDsToDelete: nil)Code language: Swift (swift)

Next, a CKConfiguration instance needs to be created, configured with optional settings, and assigned to the operation:

let configuration = CKOperation.Configuration()
        
configuration.timeoutIntervalForResource = 10
configuration.timeoutIntervalForRequest = 10Code language: Swift (swift)

Next, a lambda must be assigned to the modifyRecordsResultBlock property of the modifyRecordsOperation object. The code in this lambda is called when the operation completes to let your app know whether the share was successfully saved:

modifyRecordsOperation.modifyRecordsResultBlock = { result in
    switch result {
    case .success:
        // Handle completion
    case .failure(let error):
        print(error.localizedDescription)
    }
}Code language: Swift (swift)

Finally, the operation is added to the database to begin execution:

self.privateDatabase?.add(modifyRecordsOperation)Code language: Swift (swift)

The UICloudSharingController Class

To send a share link to another user, CloudKit needs to know both the identity of the recipient and the method by which the share link is to be transmitted. One option is to manually create CKShareParticipant objects for each participant and add them to the CKShare object. Alternatively, the CloudKit framework includes a view controller specifically for this purpose. When presented to the user (Figure 51-1), the UICloudSharingController class provides the user with a variety of options for sending the share link to another user:

Figure 51-1

The app is responsible for creating and presenting the controller to the user, the template code for which is outlined below:

let controller = UICloudSharingController { 
	controller, prepareCompletionHandler in

	// Code here to create the CKShare and save it to the database
}

controller.availablePermissions = 
        [.allowPublic, .allowReadOnly, .allowReadWrite, .allowPrivate]

controller.popoverPresentationController?.barButtonItem =
    sender as? UIBarButtonItem

present(controller, animated: true)Code language: Swift (swift)

Note that the above code fragment also specifies the permissions to be provided as options within the controller user interface. These options are accessed and modified by tapping the link in the Collaboration section of the sharing controller (in Figure 51-1 above, the link reads “Only invited people can edit”). Figure 51-2 shows an example share options settings screen:

Figure 51-2

Once the user selects a method of communication from the cloud-sharing controller, the completion handler assigned to the controller will be called. As outlined in the previous section, the CKShare object must be created and saved within this handler. After the share has been saved to the database, the cloud-sharing controller must be notified that the share is ready to be sent. This is achieved by a call to the prepareCompletionHandler method that was passed to the completion handler in the above code. When prepareCompletionHandler is called, it must be passed the share object and a reference to the app’s CloudKit container. Bringing these requirements together gives us the following code:

let controller = UICloudSharingController { controller,
    prepareCompletionHandler in

let share = CKShare(rootRecord: thisRecord)

        share[CKShare.SystemFieldKey.title]
                 = "An Amazing House" as CKRecordValue
        share.publicPermission = .readOnly

        // Create a CKModifyRecordsOperation object and configure it
        // to save the CKShare instance and the record to be shared.
        let modifyRecordsOperation = CKModifyRecordsOperation(
            recordsToSave: [myRecord, share],
            recordIDsToDelete: nil)

        // Create a CKOperation instance
        let configuration = CKOperation.Configuration()

        // Set configuration properties to provide timeout limits
        configuration.timeoutIntervalForResource = 10
        configuration.timeoutIntervalForRequest = 10

        // Apply the configuration options to the operation
        modifyRecordsOperation.configuration = configuration

        // Assign a completion block to the CKModifyRecordsOperation. This will
        // be called the modify records operation completes or fails. 
                     
        modifyRecordsOperation.modifyRecordsResultBlock = { result in
            switch result {
            case .success:
                // The share operation was successful. Call the completion
                // handler
                prepareCompletionHandler(share, CKContainer.default(), nil)
            case .failure(let error):
                print(error.localizedDescription)
            }
        }
        
        // Start the operation by adding it to the database
        self.privateDatabase?.add(modifyRecordsOperation)
}Code language: Swift (swift)

Once the prepareCompletionHandler method has been called, the app for the chosen form of communication (Messages, Mail, etc.) will launch preloaded with the share link. All the user needs to do at this point is enter the contact details for the intended share recipient and send the message. Figure 51-3, for example, shows a share link loaded into the Mail app ready to be sent:

Figure 51-3

Accepting a CloudKit Share

When the recipient user receives a share link and selects it, a dialog will appear, providing the option to accept the share and open it in the corresponding app. When the app opens, the userDidAcceptCloudKitShareWith method is called on the scene delegate class located in the project’s SceneDelegate.swift file:

func windowScene(_ windowScene: UIWindowScene,
    userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
}Code language: Swift (swift)

When this method is called, it is passed a CKShare.Metadata object containing information about the share. Although the user has accepted the share, the app must also accept the share using a CKAcceptSharesOperation object. As the acceptance operation is performed, it will report the results of the process via two result blocks assigned to it. The following example shows how to create and configure a CKAcceptSharesOperation instance to accept a share:

let container = CKContainer(identifier: metadata.containerIdentifier)
let operation = CKAcceptSharesOperation(shareMetadatas: [metadata])     
var rootRecordID: CKRecord.ID!

operation.acceptSharesResultBlock = { result in
    switch result {
    case .success:
        // The share was accepted successfully. Call the completion handler.
        completion(.success(rootRecordID))
    case .failure(let error):
        completion(.failure(error))
    }
}

operation.perShareResultBlock = { metadata, result in
    switch result {
    case .success:
        // The shared record ID was successfully obtained from the metadata.
        // Save a local copy for later. 
        rootRecordID = metadata.hierarchicalRootRecordID

        // Display the appropriate view controller and use it to fetch, and 
        // display the shared record.
        DispatchQueue.main.async {
            let viewController: ViewController = 
                    self.window?.rootViewController as! ViewController
            viewController.fetchShare(metadata)
        }        
    case .failure(let error):
        print(error.localizedDescription)
    }
}Code language: Swift (swift)

The final step in accepting the share is to add the configured CKAcceptSharesOperation object to the CKContainer instance to accept share the share:

container.add(operation) Code language: Swift (swift)

Fetching a Shared Record

Once a share has been accepted by both the user and the app, the shared record needs to be fetched and presented to the user. This involves the creation of a CKFetchRecordsOperation object using the root record ID contained within a CKShare.Metadata instance that has been configured with result blocks to be called with the results of the fetch operation. It is essential to be aware that this fetch operation must be executed on the shared cloud database instance of the app instead of the recipient’s private database. The following code, for example, fetches the record associated with a CloudKit share:

let operation = CKFetchRecordsOperation(
                     recordIDs: [metadata.hierarchicalRootRecordID!])

operation.perRecordResultBlock = { recordId, result in
    switch result {
    case .success(let record):
        DispatchQueue.main.async() {
             // Shared record successfully fetched. Update user 
             // interface here to present to the user. 
        }
    case .failure(let error):
        print(error.localizedDescription)
    }
}

operation.fetchRecordsResultBlock = { result in
    switch result {
    case .success:
        break
    case .failure(let error):
        print(error.localizedDescription)
    }
}

CKContainer.default().sharedCloudDatabase.add(operation)Code language: Swift (swift)

Once the record has been fetched, it can be presented to the user within the perRecordResultBlock code, taking the steps above to perform user interface updates asynchronously on the main thread.

Summary

CloudKit sharing allows records stored within a private CloudKit database to be shared with other app users at the discretion of the record owner. An app user could, for example, make one or more records accessible to other users so that they can view and, optionally, modify the record. When a record is shared, a share link is sent to the recipient user in the form of a URL. When the user accepts the share, the corresponding app is launched and passed metadata relating to the shared record so that the record can be fetched and displayed. CloudKit sharing involves the creation of CKShare objects initialized with the record to be shared. The UICloudSharingController class provides a pre-built view controller which handles much of the work involved in gathering the necessary information to send a share link to another user. In addition to sending a share link, the app must also be adapted to accept a share and fetch the record for the shared cloud database. This chapter has covered the basics of CloudKit sharing, a topic that will be covered further in a later chapter entitled An iOS CloudKit Sharing Tutorial.

Drawing Text on a Compose Canvas with drawText

Text is drawn on a canvas using DrawScope’s drawText() function and a TextMeasurer instance. The role of TextMeasurer is to calculate the size of the text drawing based on factors such as font family and size. We can obtain a TextMeasurer instance by making a call to the rememberTextMeasurer() function as follows:

val textMeasurer = rememberTextMeasurer()Code language: Kotlin (kotlin)

Having obtained a TextMeasurer instance, we can pass it to the drawText() function along with the text to be drawn:

Canvas(modifier = Modifier.fillMaxSize()) {
    drawText(textMeasurer, "Sample Text")
}Code language: Kotlin (kotlin)

While the above example displays a plain text string, text drawing works best when used with annotated strings (a topic covered in this book’s Jetpack Compose Annotated Strings and Brush Styles chapter). Try out text drawing within the CanvasDemo project by making the following changes to the MainActivity.kt file:

.
.
@Composable
fun MainScreen() {
    DrawText()
}
.
.
@OptIn(ExperimentalTextApi::class)
@Composable
fun DrawText() {

    val colorList: List<color> = listOf(Color.Black,
        Color.Blue, Color.Yellow, Color.Red, Color.Green, Color.Magenta)

    val textMeasurer = rememberTextMeasurer()

    val annotatedText = buildAnnotatedString {
        withStyle(
            style = SpanStyle(
                fontSize = 60.sp,
                fontWeight = FontWeight.ExtraBold,
                brush = Brush.verticalGradient(colors = colorList)
            )
        ) {
            append("Text Drawing")
        }
    }

    Canvas(modifier = Modifier.fillMaxSize()) {
        drawText(textMeasurer, annotatedText)
    }
}Code language: Kotlin (kotlin)

The code we have added declares a list of colors, obtains a TextMeasurer and builds an annotated string that uses a large font size with extra bold font weight. A brush style is then used to apply a vertical gradient consisting of the color list. Next, the text measurer and annotated string are passed to the drawText() function of a Canvas scope resulting in the following output displayed in the preview panel:

Figure 39-19

An interesting benefit of using TextMeasurer is that it gives us access to the dimensions of the drawn text. This information is beneficial when you need to include a background matching the text size. The text size can be obtained by passing the annotated string to TextMeasurer’s measure() function. The measure() function will return a TextLayoutResult object from which we can extract size properties.

To see this in action, modify the DrawText function as follows so that the text is drawn on an appropriately sized horizontal gradient background:

@OptIn(ExperimentalTextApi::class)
@Composable
fun DrawText() {
.
.
    Canvas(modifier = Modifier.fillMaxSize()) {

        val dimensions = textMeasurer.measure(annotatedText)

        drawRect(
            brush = Brush.horizontalGradient(colors = colorList),
            size = dimensions.size.toSize()
        )
        drawText(textMeasurer, annotatedText)
    }
}Code language: Kotlin (kotlin)

After making the above changes, the text should appear in the preview panel as illustrated in Figure 39-20:

Figure 39-20

Summary

The Compose Canvas component provides a surface on which to draw graphics. The Canvas DrawScope includes a set of functions that allow us to perform drawing operations within the canvas area, including drawing lines, shapes, gradients, images, text, and paths. In this chapter, we have explored some of the more common drawing features provided by Canvas and the DrawScope functions.

Jetpack Compose Annotated Strings and Brush Styles

The previous chapter explored how we use modifiers to change the appearance and behavior of composables. Many examples used to demonstrate modifiers involved the Text composable, performing tasks such as changing the font type, size, and weight. This chapter will introduce another powerful text-related feature of Jetpack Compose, known as annotated strings. We will also look at brush styles and how they can be used to add more effects to the text in a user interface.

What are annotated strings?

The previous chapter’s modifier examples changed the appearance of the entire string displayed by a Text composable. For instance, we could not display part one part of the text in bold while another section was in italics. It is for this reason that Jetpack Compose includes the annotated strings. Annotated strings allow a text to be divided into multiple sections, each with its own style.

Using annotated strings

An AnnotatedString instance is created by calling the buildAnnotatedString function and passing it the text and styles to be displayed. These string sections are combined via calls to the append function to create the complete text to be displayed.

Two style types are supported, the first of which, SpanStyle, is used to apply styles to a span of individual characters within a string. The syntax for building an annotated string using SpanStyle is as follows:

buildAnnotatedString {
    withStyle(style = SpanStyle( /* style settings */)) {
        append(/* text string */)
    }
          
    withStyle(style = SpanStyle(/* style settings */)) {
	append(/* more text */) 
    }
.
.
}Code language: Kotlin (kotlin)

A SpanStyle instance can be initialized with any combination of the following style options:

  • color
  • fontSize
  • fontWeight
  • fontStyle
  • fontSynthesis
  • fontFamily
  • fontFeatureSettings
  • letterSpacing
  • baselineShift,
  • textGeometricTransform
  • localeList
  • background
  • textDecoration
  • shadow

ParagraphStyle, on the other hand, applies a style to paragraphs and can be used to modify the following properties:

  • textAlign
  • textDirection
  • lineHeight
  • textIndent

The following is the basic syntax for using paragraph styles in annotated strings:

buildAnnotatedString {
    withStyle(style = ParagraphStyle( /* style settings */)) {
        append(/* text string */)
    }
            
    withStyle(style = ParagraphStyle(/* style settings */)) 
        append(/* more text */) 
    }
.
.
}Code language: Kotlin (kotlin)

Brush Text Styling

Additional effects may be added to any text by using the Compose Brush styling. Brush effects can be applied directly to standard text strings or selectively to segments of an annotated string. For example, the following syntax applies a radial color gradient to a Text composable (color gradients will be covered in the chapter entitled Jetpack Compose Canvas Graphics Drawing Tutorial):

val myColors = listOf( /* color list */)
 
Text(
    text = "text here",
    style = TextStyle(
        brush = Brush.radialGradient(
            colors = myColors
        )
    )
)Code language: Kotlin (kotlin)

Creating the example project

Launch Android Studio and select the New Project option from the welcome screen. Choose the Empty Activity template within the New Project dialog before clicking the Next button.

Enter StringsDemo into the Name field and specify com.example.stringsdemo as the package name. Before clicking the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo). Once the project has been created, the SlotApiDemo project should be listed in the Project tool window along the lefthand edge of the Android Studio main window.

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

@Composable
fun MainScreen() {
   
}Code language: Kotlin (kotlin)

Next, edit the onCreateActivity() method and GreetingPreview function to call MainScreen instead of Greeting.

An example SpanStyle annotated string

The first example we will create uses SpanStyle to build an annotated string consisting of multiple color and font styles.

Begin by editing the MainActivity.kt file and modifying the MainScreen function to read as follows:

.
.
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.foundation.layout.Column
import androidx.compose.ui.unit.sp
.
.
@Composable
fun MainScreen() {
    Column {
        SpanString()
    }
}Code language: Kotlin (kotlin)

Next, add the SpanStyle declaration to the MainActivity.kt file as follows:

@Composable
fun SpanString() {
    Text(
        buildAnnotatedString {
            withStyle(
                style = SpanStyle(fontWeight = FontWeight.Bold,
                    fontSize = 30.sp)) {
                append("T")
            }

            withStyle(style = SpanStyle(color = Color.Gray)) {
                append("his")
            }
            append(" is ")
            withStyle(
                style = SpanStyle(
                    fontWeight = FontWeight.Bold,
                    fontStyle = FontStyle.Italic,
                    color = Color.Blue
                )
            ) {
                append("great!")
            }
        }
    )
}Code language: Kotlin (kotlin)

The example code creates an annotated string in three parts using several span styles for each section. After making these changes, refer to the Preview panel, where the text should appear as shown in Figure 39-1:

Figure 39-1

An example ParagraphStyle annotated string

Now that we have seen how to create a span-style annotated string, the next step is to build a paragraph style string. Remaining in the MainActivity.kt file, make the following changes to add a new function named ParaString and to call it from the MainScreen function:

.
.
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextIndent
.
.
@Composable
fun MainScreen() {
    Column {
        SpanString()
        ParaString()
    }
}
 
@Composable
fun ParaString() {
 
    Text(
        buildAnnotatedString {
            append(
                "\nThis is some text that doesn't have any style applied to it.\n")        
        })
}Code language: Kotlin (kotlin)

The above code gives us an unmodified paragraph against which we can compare the additional paragraphs we will add. Next, modify the function to add an indented paragraph with an increased line height:

@Composable
fun ParaString() {

    Text(
        buildAnnotatedString {
            
            append("\nThis is some text that doesn't have any style applied to it.\n")

            withStyle(style = ParagraphStyle(
                lineHeight = 30.sp,
                textIndent = TextIndent(
                    firstLine = 60.sp,
                    restLine = 25.sp))
            ) {
                append("This is some text that is indented more on the first lines than the rest of the lines. It also has an increased line height.\n")
            }
    })
}Code language: Kotlin (kotlin)

When the preview is rendered, it should resemble Figure 39-2 (note that we specified different indents for the first and remaining lines):

Figure 39-2

Next, add a third paragraph that uses right alignment as follows:

@Composable
fun ParaString() {
.
.
                append("This is some text that is indented more on the first lines than the rest of the lines. It also has an increased line height.\n")
            }

            withStyle(style = ParagraphStyle(textAlign = TextAlign.End)) {
                append("This is some text that is right aligned.")
            }
    })
}Code language: Kotlin (kotlin)

This change should result in the following preview:

A Brush style example

The final example in this tutorial involves using the Brush style to change the text’s appearance. First, add another function to the MainActivity.kt file and call it from within the MainScreen function:

.
.
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.ExperimentalTextApi
.
.
@Composable
fun MainScreen() {
    Column {
        SpanString()
        ParaString()
        BrushStyle()
    }
}

@OptIn(ExperimentalTextApi::class)
@Composable
fun BrushStyle() {

}Code language: Kotlin (kotlin)

We will begin by declaring a list of colors and use a span style to display large, bold text as follows:

@OptIn(ExperimentalTextApi::class)
@Composable
fun BrushStyle() {

    val colorList: List<Color> = listOf(Color.Red, Color.Blue,
        Color.Magenta, Color.Yellow, Color.Green, Color.Red)

    Text(
        text = buildAnnotatedString {

            withStyle(
                    style = SpanStyle(
                        fontWeight = FontWeight.Bold,
                        fontSize = 70.sp
                )
            ) {
                append("COMPOSE!")
            }
        }
    )
}Code language: Kotlin (kotlin)

All that remains is to apply a linearGradient brush to the style, using the previously declared color list:

@OptIn(ExperimentalTextApi::class)
@Composable
fun BrushStyle() {
 
    Text(
        text = buildAnnotatedString {
 
            withStyle(
                    style = SpanStyle(
                        fontWeight = FontWeight.Bold,
                        fontSize = 70.sp,
                        brush = Brush.linearGradient(colors = colorList)
                )
            ) {
                append("COMPOSE!")
.
.Code language: Kotlin (kotlin)

After completing the above changes, check that the new text appears in the preview panel as illustrated in Figure 39-3:

Figure 39-3

Summary

While modifiers provide a quick and convenient way to make changes to the appearance of text in a user interface, they do not support multiple styles within a single string. On the other hand, annotated strings provide greater flexibility in changing the appearance of text. Annotated strings are built using the buildAnnotatedString function and can be configured using either span or paragraph styles. Another option for altering how text appears is using the Brush style to change the text foreground creatively, such as using color gradients.

Jetpack Compose FlowRow and FlowColumn Layouts

The chapter entitled Jetpack Compose Row and Column Layouts used the Row and Column composables to present content elements uniformly within a user interface. One limitation of Row and Column-based layouts is that they are not well suited to organizing dynamic elements in terms of the quantity and sizes of the content. These composables are also less effective when designing layouts that are responsive to device screen orientation and size changes.

In this chapter, we will learn about the Flow layout composables and explore how they provide a more flexible way to organize content in rows and columns.

FlowColumn and FlowRow

The Row and Column composables work best when you know the number of items to be displayed and their respective sizes. This results in a spreadsheet-like layout with rows of aligned columns. The Flow layouts, however, are designed to flow content onto the next row or column when space runs out. These composables also discard the spreadsheet approach to organization, providing a more flexible approach to displaying items of varying sizes. Figure 28-1, for example, shows a typical FlowRow layout:

Figure 28-1

As we will explore later in this chapter, Flow layouts provide extensive options for configuring the layout and arrangement of child items, including weight, spacing, alignment, and the maximum number of items per row or column.

The FlowRow composable uses the following syntax:

FlowRow(
    modifier: Modifier = Modifier,
    horizontalArrangement: Arrangement.Horizontal,
    verticalArrangement: Arrangement.Vertical,
    maxItemsInEachRow: Int
) {
   // Content here
}
Code language: Kotlin (kotlin)

Figure 28-2 shows an example FlowColumn layout:

Figure 28-2

The FlowColumn composable uses the following syntax:

FlowColumn(
    modifier: Modifier,
    verticalArrangement: Arrangement.Vertical,
    horizontalArrangement: Arrangement.Horizontal,
    maxItemsInEachColumn: Int,
) {
    // Content here
}Code language: Kotlin (kotlin)

Maximum number of items

Without restrictions, the Flow layouts will fit as many items into a row or column as possible before flowing to the next one. The maximum number of items can be restricted using the maxItemsInEachColumn and maxItemsInEachRow properties of the FlowColumn and FlowRow. For example:

FlowRow(maxItemsInEachRow = 10) {
    // Flow items here
}

FlowColumn(maxItemsInEachColumn = 5) {
    // Flow items here
}Code language: Kotlin (kotlin)

Working with main axis arrangement

Main axis arrangement defines how the flow items are positioned along the main axis of the parent Flow layout. For example, the horizontalArrangement property controls the arrangement of flow items along the horizontal axis of the FlowRow composable. Table 28-4 shows the effects of the various horizontalArrangement options when applied to a FlowRow instance:

Arrangement.Start
Arrangement.Center
Arrangement End
Arrangement.SpaceAround
Arrangement.SpaceEvenly
Arrangement.spacedBy(10.dp)

Table 28-4

Similarly, the verticalArrangement property controls the positioning of flow items along the vertical access of the FlowColumn. The same arrangement options are available as those listed above, except that Arrangement.Start and Arrangement.End are replaced by Arrangement.Top and Arrangement.Bottom.

Understanding cross-axis arrangement

Cross-axis arrangement controls the arrangement of a flow layout on the opposite axis to the main flow. In other words, the verticalArrangement property controls the vertical positioning of FlowRow items, while horizontalArrangement does the same along the horizontal axis of FlowColumn items. Table 28-5 demonstrates the three horizontalArrangement options applied to a FlowColumn instance:

Arrangement.Start
Arrangement.Center
Arrangement.End

Table 28-5

Item alignment

The alignment of items within individual rows or columns can be controlled by passing an alignment value to the align() modifier of the child items of a Flow layout. This is useful when the Flow items vary in height (FlowRow) or width (FlowColumn). The following code, for example, specifies bottom alignment for a FlowRow item:

FlowRow {
	repeat(6) {
            MyFlowItem(modifier = Modifier.align(Alignment.Bottom))
       }
}Code language: Kotlin (kotlin)

The following table illustrates the effect of applying Alignment.Top, Alignment.CenterVertically, and Alignment. Bottom to FlowRow items of varying height:

Alignment.Top
Alignment.CenterVertically
Alignment.Bottom

Table 28-6

Equivalent alignment effects can be achieved for FlowColumn items using Alignment.Start, Alignment. CenterHorizontally, and Alignment.End

Controlling item size

Weight factors can be applied to individual Flow items to specify the size relative to the overall space available and the weights of other items in the same row or column. Weights are expressed as Float values and applied to individual Flow items using the weight() modifier. Consider, for example, a FlowRow containing a single item with a weight of 1f:

FlowRow {
    MyFlowItem(
        Modifier
            .weight(1f)
        )
}Code language: Kotlin (kotlin)

When the layout is rendered, the item will occupy all the available space because it is the only item in the row:

Figure 28-3

If we add a second item, also with a weight of 1f, the two items will share the row equally:

Figure 28-4

If we add a third item with a weight of 1f, each item would occupy a third of the space. However, suppose that the third item has a weight of 2f, giving us a weight combination of 1f, 1f, and 2f. In this case, the first two items occupy half of the available space, while the third occupies the other half:

Figure 28-5

To calculate the size for an item when using weights, the Flow composables divide the amount of space remaining in the row or column by the total item weights, multiplied by the weight of the current item.

Another way to control the size of the items in a Flow layout is to use fractional sizing. Fractional sizing involves specifying the percentage of the overall space in a row or column that an item is to occupy. The fraction is declared as a Float value and applied to FlowRow and FlowColumn items using the fillMaxWidth() and fillMaxHeight() modifiers, respectively. For example:

FlowRow {
    MyFlowItem(Modifier.width(50.dp))
    MyFlowItem(Modifier.fillMaxWidth(0.7f))
    MyFlowItem(Modifier.width(50.dp))
}Code language: Kotlin (kotlin)

Regardless of the sizes of the other items, the fractional item in the above code example will always occupy 70% of the row:

Figure 28-6

If there is insufficient room for the fractional item, items will flow onto the next row to make room:

Figure 28-7

Summary

The FlowRow and FlowColumn composables are ideal for arranging groups of items of varying sizes and quantities into flexible rows and columns. When a Flow layout runs out of space to display items, the remaining content flows to the next row or column. Combined with an extensive collection of alignment, spacing, and arrangement options, these composables provide a flexible and easy layout solution for presenting content within apps.

Ubuntu 22.04 Remote Desktop Access with VNC

The chapter entitled “Ubuntu Remote Desktop Access with Vino” explored remote access to the Ubuntu GNOME desktop using the Vino server, an approach that is intended solely for situations where the remote system is already running a GNOME desktop session. In this chapter we will cover launching and accessing GNOME desktop sessions that run in the background, allowing multiple desktop sessions to be accessed remotely, including on server based system that do not have a graphical console attached.

1.1  Installing the GNOME Desktop Environment

It is, of course, only possible to access the desktop environment if the desktop itself has been installed. If, for example, the system was initially configured as a server it is unlikely that the desktop packages were installed. The easiest way to install the packages necessary to run the GNOME desktop is via the apt command as follows:

# apt install ubuntu-gnome-desktop

To prevent the desktop from attempting to launch automatically each time the system reboots, change the default systemd target back to multi-user:

# systemctl set-default multi-user.target

If the system has a graphical display attached, the desktop can be launched using the following command:

$ startx

If, on the other hand, the system is a server with no directly connected display, the only way to run and access the desktop will be to configure VNC support on the system.

1.2  Installing VNC on Ubuntu

Access to a remote desktop requires a VNC server installed on the remote system, a VNC viewer on the system from which access is being established and, optionally, a secure SSH connection. While a number of VNC server and viewer implementations are available, this chapter will make use of TigerVNC which provides both server and viewer components for Linux-based operating systems. VNC viewer clients for non-Linux platforms include RealVNC and TightVNC.

To install the TigerVNC server package on Ubuntu, simply run the following command:

# apt install tigervnc-standalone-server

If required, the TigerVNC viewer may also be installed as follows:

# apt install tigervnc-viewer

Once the server has been installed the system will need to be configured to run one or more VNC services and to open the appropriate ports on the firewall.

1.3  Configuring the VNC Server

With the VNC server packages installed, the next step is to configure the server. The first step is to specify a password for the user that will be accessing the remote desktop environment. While logged in as root (or with superuser privileges), execute the vncpasswd command (where the user name is assumed to be demo):

# su - demo
demo@demoserver:~$ vncpasswd
Password:
Verify:
Would you like to enter a view-only password (y/n)? n
A view-only password is not used

The above command will create a file named passwd in the .vnc directory of the user’s home directory. Next, change directory to the .vnc directory and create a new file named xstartup containing the following:

#!/bin/sh
# Start Gnome 3 Desktop 
[ -x /etc/vnc/xstartup ] && exec /etc/vnc/xstartup
[ -r $HOME/.Xresources ] && xrdb $HOME/.Xresources
vncconfig -iconic &
dbus-launch --exit-with-session gnome-session &

These are the commands that will be executed to start the GNOME desktop when the VNC server is launched.

1.4  Starting the VNC Server

With the necessary packages installed and configured for the user’s account, the VNC server can be started as follows (making sure to run the command as the user and without superuser privileges):

$ vncserver

This will start the first desktop session running on the system. Since this is the first session, it will be configured to use port 5901 (which may be abbreviated to :1). Running the command a second time while the first session is running will create a VNC server listening on port 5902 (:2) and so on. The following command may be used to obtain a list of desktop sessions currently running:

$ vncserver -list
TigerVNC server sessions:

X DISPLAY #	PROCESS ID
:1		1607
:2		4726

To terminate a session, use the vncserver command with the -kill option referencing the corresponding port. For example:

$ vncserver -kill :2
Killing Xtigervnc process ID 4726... success!

Alternatively, use the following command to kill all currently running VNC server sessions:

$ vncserver -kill :*
Killing Xtigervnc process ID 1607... success!
Killing Xtigervnc process ID 5287... success!

To manually specify the port to be used by the VNC server session, include the number in the command-line as follows:

$ vncserver :5

In the above example, the session will listen for a remote connection on port 5905.

1.5  Connecting to a VNC Server

For details on remotely connecting to a desktop session from another system, follow the steps outlined in the sections titled “Establishing a Secure Remote Desktop Session” and “Establishing a Secure Tunnel on Windows using PuTTY” in the previous chapter.

1.6  Summary

In this and the preceding chapter we have explored two different ways to remotely access the GNOME desktop environment of an Ubuntu system. While the previous chapter explored access to an existing desktop session, this chapter has focused on launching GNOME desktop sessions as background processes, thereby allowing remote access to multiple desktop sessions. This is a particularly useful technique for running and remotely accessing desktop sessions on “headless” server-based systems.