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.

 

Integrating SwiftUI with UIKit

Apps developed before the introduction of SwiftUI will have been developed using UIKit and other UIKit-based frameworks included with the iOS SDK. Given the benefits of using SwiftUI for future development, it will be a common requirement to integrate the new SwiftUI app functionality with the existing project code base. Fortunately, this integration can be achieved with relative ease using the UIHostingController.

An Overview of the Hosting Controller

The hosting controller (in the form of the UIHostingController class) is a subclass of UIViewController, the sole purpose of which is to enclose a SwiftUI view so that it can be integrated into an existing UIKit-based project.

Using a hosting view controller, a SwiftUI view can be treated either as an entire scene (occupying the full screen), or as an individual component within an existing UIKit scene layout by embedding a hosting controller in a container view. A container view essentially allows a view controller to be configured as the child of another view controller.

SwiftUI views can be integrated into a UIKit project either from within the code or using an Interface Builder storyboard. The following code excerpt embeds a SwiftUI content view in a hosting view controller and then presents it to the user:

let swiftUIController = 
                   UIHostingController(rootView: SwiftUIView())
present(swiftUIController, animated: true, completion: nil)

The following example, on the other hand, embeds a hosted SwiftUI view directly into the layout of an existing UIViewController:

let swiftUIController = 
             UIHostingController(rootView: SwiftUIView())
 
addChild(swiftUIController)
view.addSubview(swiftUIController.view)
 
swiftUIController.didMove(toParent: self)

In the rest of this chapter, an example project will be created that demonstrates the use of UIHostingController instances to integrate SwiftUI views into an existing UIKit-based project both programmatically and using storyboards.

A UIHostingController Example Project

Unlike previous chapters, the project created in this chapter will create a UIKit Storyboard-based project instead of a SwiftUI Multiplatform app. Launch Xcode and select the iOS tab followed by the App template as shown in Figure 54-1 below:

Figure 54-1

Click the Next button and, on the options screen, name the project HostingControllerDemo and change the Interface menu from SwiftUI to Storyboard. Click the Next button once again and proceed with the project creation process.

Adding the SwiftUI Content View

In the course of building this project, a SwiftUI content view will be integrated into a UIKit storyboard scene using the UIHostingController in three different ways. In preparation for this integration process, a SwiftUI View file needs to be added to the project. Add this file now by selecting the File -> New -> File… menu option and choosing the SwiftUI View template option from the resulting dialog. Proceed through the screens, keeping the default SwiftUIView file name.

With the SwiftUIView.swift file loaded into the editor, modify the declaration so that it reads as follows:

import SwiftUI
 
struct SwiftUIView: View {
    
    var text: String
    
    var body: some View {
        VStack {
            Text(text)
            HStack {
                Image(systemName: "smiley")
                Text("This is a SwiftUI View")
            }
        }
        .font(.largeTitle)
    }
}
 
struct SwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        SwiftUIView(text: "Sample Text")
    }
}

With the SwiftUI view added, the next step is to integrate it so that it can be launched as a separate view controller from within the storyboard.

Preparing the Storyboard

Within Xcode, select the Main.storyboard file so that it loads into the Interface Builder tool. As currently configured, the storyboard consists of a single view controller scene as shown in Figure 54-2:

Figure 54-2

So that the user can navigate back to the current scene, the view controller needs to be embedded into a Navigation Controller. Select the current scene by clicking on the View Controller button circled in the figure above so that the scene highlights with a blue outline and select the Editor -> Embed In -> Navigation Controller menu option. At this point the storyboard canvas should resemble Figure 54-3:

Figure 54-3

The first SwiftUI integration will require a button which, when clicked, will show a new view controller containing the SwiftUI View. Display the Library panel by clicking on the button highlighted in Figure 54-4 and locate and drag a Button view onto the view controller scene canvas:

Figure 54-4

Double-click on the button to enter editing mode and change the text so that it reads “Show Second Screen”. To maintain the position of the button it will be necessary to add some layout constraints. Use the Resolve Auto Layout Issues button indicated in Figure 54-5 to display the menu and select the Reset to Suggested Constraints option to add any missing constraints to the button widget:

Figure 54-5

Adding a Hosting Controller

The storyboard is now ready to add a UIHostingController and to implement a segue on the button to display the SwiftUIView layout. Display the Library panel once again, locate the Hosting View Controller and drag and drop it onto the storyboard canvas so that it resembles Figure 54-6 below:

Figure 54-6

Next, add the segue by selecting the “Show Second Screen” button and Ctrl-clicking and dragging to the Hosting Controller:

Figure 54-7

Release the line once it is within the bounds of the hosting controller and select Show from the resulting menu.

Compile and run the project on a simulator or connected device and verify that clicking the button navigates to the hosting controller screen and that the Navigation Controller has provided a back button to return to the initial screen. At this point the hosting view controller appears with a black background indicating that it currently has no content.

Configuring the Segue Action

The next step is to add an IBSegueAction to the segue that will load the SwiftUI view into the hosting controller when the button is clicked. Within Xcode, select the Editor -> Assistant menu option to display the Assistant Editor panel. When the Assistant Editor panel appears, make sure that it is displaying the content of the ViewController.swift file. By default, the Assistant Editor will be in Automatic mode, whereby it automatically attempts to display the correct source file based on the currently selected item in Interface Builder. If the correct file is not displayed, the toolbar along the top of the editor panel can be used to select the correct file.

If the ViewController.swift file is not loaded, begin by clicking on the Automatic entry in the editor toolbar as highlighted in Figure 54-8:

Figure 54-8

From the resulting menu (Figure 54-9), select the ViewController.swift file to load it into the editor:

Figure 54-9

Next, Ctrl-click on the segue line between the initial view controller and the hosting controller and drag the resulting line to a position beneath the viewDidLoad() method in the Assistant panel as shown in Figure 54-10:

Figure 54-10

Release the line and enter showSwiftUIView into the Name field of the connection dialog before clicking the Connect button:

Figure 54-11

Within the ViewController.swift file Xcode will have added the IBSegueAction method which needs to be modified as follows to embed the SwiftUIView layout into the hosting controller (note that the SwiftUI framework also needs to be imported):

import UIKit
import SwiftUI
.
.
@IBSegueAction func showSwiftUIView(_ coder: NSCoder) -> UIViewController? {
    return UIHostingController(coder: coder, 
                rootView: SwiftUIView(text: "Integration One"))
}

Compile and run the app once again, this time confirming that the second screen appears as shown in Figure 54-12:

Figure 54-12

Embedding a Container View

For the second integration, a Container View will be added to an existing view controller scene and used to embed a SwiftUI view alongside UIKit components. Within the Main.storyboard file, display the Library and drag and drop a Container View onto the scene canvas of the initial view controller, then position and size the view so that it appears as shown in Figure 54-13:

Figure 54-13

Before proceeding, click on the background of the view controller scene before using the Resolve Auto Layout Issues button indicated in Figure 54-5 once again and select the Reset to Suggested Constraints option to add any missing constraints to the layout.

Note that Xcode has also added an extra View Controller for the Container View (located above the initial view controller in the above figure). This will need to be replaced by a Hosting Controller so select this controller and tap the keyboard delete key to remove it from the storyboard.

Display the Library, locate the Hosting View Controller and drag and drop it so that it is positioned above the initial view controller in the storyboard canvas. Ctrl-click on the Container View in the view controller scene and drag the resulting line to the new hosting controller before releasing. From the segue menu, select the Embed option:

Figure 54-14

Once the Container View has been embedded in the hosting controller, the storyboard should resemble Figure 54-15:

Figure 54-15

All that remains is to add an IBSegueAction to the connection between the Container View and the hosting controller. Display the Assistant Editor once again, Ctrl-click on the arrow pointing in towards the left side of the hosting controller and drag the line to a position beneath the showSwiftUIView action method. Name the action embedSwiftUIView and click on the Connect button. Once the new method has been added, modify it as follows:

@IBSegueAction func embedSwiftUIView(_ coder: NSCoder) ->
                                         UIViewController? {
    return UIHostingController(coder: coder, rootView: SwiftUIView(text: "Integration Two"))
}

When the app is now run, the SwiftUI view will appear in the initial view controller within the Container View:

Figure 54-16

Embedding SwiftUI in Code

In this, the final integration example, the SwiftUI view will be embedded into the layout for the initial view controller programmatically. Within Xcode, edit the ViewController.swift file, locate the viewDidLoad() method and modify it as follows:

override func viewDidLoad() {
    super.viewDidLoad()
    
    let swiftUIController = UIHostingController(rootView: SwiftUIView(text: "Integration Three"))
 
    addChild(swiftUIController)
    swiftUIController.view.translatesAutoresizingMaskIntoConstraints 
                                                      = false
 
    view.addSubview(swiftUIController.view)
 
    swiftUIController.view.centerXAnchor.constraint(
              equalTo: view.centerXAnchor).isActive = true
    swiftUIController.view.centerYAnchor.constraint(
              equalTo: view.centerYAnchor).isActive = true
 
    swiftUIController.didMove(toParent: self)
}

The code begins by creating a UIHostingController instance containing the SwiftUIView layout before adding it as a child to the current view controller. The translates autoresizing property is set to false so that any constraints we add will not conflict with the automatic constraints usually applied when a view is added to a layout. Next, the UIView child of the UIHostingController is added as a subview of the containing view controller’s UIView. Constraints are then set on the hosting view controller to position it in the center of the screen. Finally, an event is triggered to let UIKit know that the hosting controller has been moved to the container view controller. Run the app one last time and confirm that it appears as shown in Figure 54-17:

Figure 54-17

Summary

Any apps developed before the introduction of SwiftUI will have been created using UIKit. While it is certainly possible to continue using UIKit when enhancing and extending an existing app, it probably makes more sense to use SwiftUI when adding new app features (unless your app needs to run on devices that do not support iOS 13 or newer). Recognizing the need to integrate new SwiftUI-based views and functionality with existing UIKit code, Apple created the UIHostingViewController. This controller is designed to wrap SwiftUI views in a UIKit view controller that can be integrated into existing UIKit code. As demonstrated in this chapter, the hosting controller can be used to integrate SwiftUI and UIKit both within storyboards and programmatically within code. Options are available to integrate entire SwiftUI user interfaces in independent view controllers or, through the use of container views, to embed SwiftUI views alongside UIKit views within an existing layout.

Integrating UIViewControllers with SwiftUI

The previous chapter outlined how to integrate UIView based components into SwiftUI using the UIViewRepresentable protocol. This chapter will focus on the second option for combining SwiftUI and UIKit within an iOS project in the form of UIViewController integration.

UIViewControllers and SwiftUI

The UIView integration outlined in the previous chapter is useful for integrating either individual or small groups of UIKit-based components with SwiftUI. Existing iOS apps are likely to consist of multiple ViewControllers, each representing an entire screen layout and functionality (also referred to as scenes). SwiftUI allows entire view controller instances to be integrated via the UIViewControllerRepresentable protocol. This protocol is similar to the UIViewRepresentable protocol and works in much the same way with the exception that the method names are different.

The remainder of this chapter will work through an example that demonstrates the use of the UIViewControllerRepresentable protocol to integrate a UIViewController into SwiftUI.

Creating the ViewControllerDemo project

For the purposes of an example, this project will demonstrate the integration of the UIImagePickerController into a SwiftUI project. This is a class that is used to allow the user to browse and select images from the device photo library and for which there is currently no equivalent within SwiftUI.

Just like custom built view controllers in an iOS app UIImagePickerController is a subclass of UIViewController so can be used with UIViewControllerRepresentable to integrate into SwiftUI. Begin by launching Xcode and creating a new Multiplatform App project named ViewControllerDemo.

Wrapping the UIImagePickerController

With the project created, it is time to create a new SwiftUI View file to contain the wrapper that will make the UIPickerController available to SwiftUI. Create this file by right-clicking on the Shared folder item in the project navigator panel, selecting the New File… menu option and creating a new file named MyImagePicker using the SwiftUI View file template.

Once the file has been created, delete the current content and modify the file so that it reads as follows:

import SwiftUI
 
struct MyImagePicker: UIViewControllerRepresentable {
 
    func makeUIViewController(context: 
              UIViewControllerRepresentableContext<MyImagePicker>) -> 
                      UIImagePickerController {
        let picker = UIImagePickerController()
        return picker
    }
 
    func updateUIViewController(_ uiViewController: 
            UIImagePickerController, context: 
              UIViewControllerRepresentableContext<MyImagePicker>) {
 
    }
}
 
struct MyImagePicker_Previews: PreviewProvider {
    static var previews: some View {
        MyImagePicker()
    }
}

If Xcode reports that UIViewControllerRepresentable is undefined, make sure that you have selected an iOS device or simulator as the run target in the toolbar.

Click on the Live Preview button in the canvas to test that the image picker appears as shown in Figure 53-1 below:

Figure 53-1

Designing the Content View

When the project is complete, the content view will display an Image view and a button contained in a VStack. This VStack will be embedded in a ZStack along with an instance of the MyImagePicker view. When the button is clicked, the MyImagePicker view will be made visible over the top of the VStack from which an image may be selected. Once the image has been selected, the image picker will be hidden from view and the selected image displayed on the Image view.

To make this work, two state property variables will be used, one for the image to be displayed and the other a Boolean value to control whether or not the image picker view is currently visible. Bindings for these two variables will be declared in the MyPickerView structure so that changes within the view controller are reflected within the main content view. With these requirements in mind, load the ContentView.swift file into the editor and modify it as follows:

struct ContentView: View {
    
    @State var imagePickerVisible: Bool = false
    @State var selectedImage: Image? = Image(systemName: "photo")
 
    var body: some View {
        ZStack {
            VStack {
                
                selectedImage?
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    
                Button(action: {
                    withAnimation {
                        self.imagePickerVisible.toggle()
                    }
                }) {
                    Text("Select an Image")
                }
                
            }.padding()
            
            if (imagePickerVisible) {
                MyImagePicker()
            }
        }
    }
}
.
.

Once the changes have been made, the preview for the view should resemble Figure 53-2:

Figure 53-2

Test the view using Live Preview and make sure that clicking on the “Select an Image” button causes the MyPickerView to appear. Note that selecting an image or clicking on the Cancel button does not dismiss the picker. To implement this behavior, some changes are needed within the MyImagePicker declaration.

Completing MyImagePicker

A few remaining tasks now need to be completed within the MyImagePicker.swift file. First, bindings to the two ContentView state properties need to be declared:

struct MyImagePicker: UIViewControllerRepresentable {
 
    @Binding var imagePickerVisible: Bool
    @Binding var selectedImage: Image?
.
.

Next, a coordinator needs to be implemented to act as the delegate for the UIImagePickerView instance. This will require that the coordinator class conform to both the UINavigationControllerDelegate and UIImagePickerControllerDelegate protocols. The coordinator will need to receive notification when an image is picked, or the user taps the cancel button so the imagePickerControllerDidCancel and didFinishPickingMediaWithInfo delegate methods will both need to be implemented.

In the case of the imagePickerControllerDidCancel method, the imagePickerVisible state property will need to be set to false. This will result in a state change within the content view causing the image picker to be removed from view.

The didFinishPickingMediaWithInfo method, on the other hand, will be passed the selected image which it will need to assign to the currentImage property before also setting the imagePickerVisible property to false.

The coordinator will also need local copies of the state property bindings. Bringing these requirements together results in a coordinator which reads as follows:

class Coordinator: NSObject, UINavigationControllerDelegate, 
                     UIImagePickerControllerDelegate {
 
    @Binding var imagePickerVisible: Bool
    @Binding var selectedImage: Image?
 
    init(imagePickerVisible: Binding<Bool>, 
                      selectedImage: Binding<Image?>) {
        _imagePickerVisible = imagePickerVisible
        _selectedImage = selectedImage
    }
 
    func imagePickerController(_ picker: UIImagePickerController,
                               didFinishPickingMediaWithInfo 
                info: [UIImagePickerController.InfoKey : Any]) {
        let uiImage = 
            info[UIImagePickerController.InfoKey.originalImage] as!    
                                                               UIImage
        selectedImage = Image(uiImage: uiImage)
        imagePickerVisible = false
    }
 
    func imagePickerControllerDidCancel(_ 
                        picker: UIImagePickerController) {
        imagePickerVisible = false
    }
}

Remaining in the MyPickerView.swift file, add the makeCoordinator() method, remembering to pass through the two state property bindings:

func makeCoordinator() -> Coordinator {
    return Coordinator(imagePickerVisible: $imagePickerVisible, 
                            selectedImage: $selectedImage)
}

Finally, modify the makeUIVewController() method to assign the coordinator as the delegate and comment out the preview structure to remove the remaining syntax errors:

func makeUIViewController(context: 
        UIViewControllerRepresentableContext<MyImagePicker>) ->  
                 UIImagePickerController {
    let picker = UIImagePickerController()
    picker.delegate = context.coordinator
    return picker
}
.
.
/*
struct MyImagePicker_Previews: PreviewProvider {
    static var previews: some View {
        MyImagePicker()
    }
}
*/

Completing the Content View

The final task before testing the app is to modify the Content View so that the two state properties are passed through to the MyImagePicker instance. Edit the ContentView.swift file and make the following modifications:

struct ContentView: View {
    
    @State var imagePickerVisible: Bool = false
    @State var selectedImage: Image? = Image(systemName: "photo")
 
    var body: some View {
.
.
        if (imagePickerVisible) {
                MyImagePicker(imagePickerVisible:    
                        $imagePickerVisible, 
                           selectedImage: $selectedImage)
        }
.
.

Testing the App

With the ContentView.swift file still loaded into the editor, enable Live Preview mode and click on the “Select an Image” button. When the picker view appears, navigate to and select an image. When the image has been selected, the picker view should disappear to reveal the selected image displayed on the Image view:

Figure 53-3

Click the image selection button once again, this time testing that the Cancel button dismisses the image picker without changing the selected image.

Summary

In addition to allowing for the integration of individual UIView based objects into SwiftUI projects, it is also possible to integrate entire UIKit view controllers representing entire screen layouts and functionality. View controller integration is similar to working with UIViews, involving wrapping the view controller in a structure conforming to the UIViewControllerRepresentable protocol and implementing the associated methods. As with UIView integration, delegates and data sources for the view controller are handled using a Coordinator instance.

Integrating UIViews with SwiftUI

Prior to the introduction of SwiftUI, all iOS apps were developed using UIKit together with a collection of UIKit-based supporting frameworks. Although SwiftUI is provided with a wide selection of components with which to build an app, there are instances where there is no SwiftUI equivalent to options provided by the other frameworks.

Given the quantity of apps that were developed before the introduction of SwiftUI it is also important to be able to integrate existing non-SwiftUI functionality with SwiftUI development projects and vice versa. Fortunately, SwiftUI includes several options to perform this type of integration.

SwiftUI and UIKit Integration

Before looking in detail at integrating SwiftUI and UIKit it is worth taking some time to explore whether a new app project should be started as a UIKit or SwiftUI project, and whether an existing app should be migrated entirely to SwiftUI. When making this decision, it is important to remember that apps containing SwiftUI code can only be used on devices running iOS 13 or later.

If you are starting a new project, then the best approach may be to build it as a SwiftUI project (support for older iOS versions not withstanding) and then integrate with UIKit when required functionality is not provided directly by SwiftUI. Although Apple continues to enhance and support the UIKit way of developing apps, it is clear that Apple sees SwiftUI as the future of app development. SwiftUI also makes it easier to develop and deploy apps for iOS, macOS, tvOS, iPadOS and watchOS without making major code changes.

If, on the other hand, you have existing projects that pre-date the introduction of SwiftUI then it probably makes sense to leave the existing code unchanged, build any future additions to the project using SwiftUI and to integrate those additions into your existing code base.

SwiftUI provides three options for performing integrations of these types. The first, and the topic of this chapter, is to integrate individual UIKit-based components (UIViews) into SwiftUI View declarations.

For those unfamiliar with UIKit, a screen displayed within an app is typically implemented using a view controller (implemented as an instance of UIViewController or a subclass thereof). The subject of integrating view controllers into SwiftUI will be covered in the chapter entitled Integrating UIViewControllers with SwiftUI.

Finally, SwiftUI views may also be integrated into existing UIKit-based code, a topic which will be covered in the chapter entitled Integrating SwiftUI with UIKit.

Integrating UIViews into SwiftUI

The individual components that make up the user interface of a UIKit-based application are derived from the UIView class. Buttons, labels, text views, maps, sliders and drawings (to name a few) are all ultimately subclasses of the UIKit UIView class.

To facilitate the integration of a UIView based component into a SwiftUI view declaration, SwiftUI provides the UIViewRepresentable protocol. To integrate a UIView component into SwiftUI, that component needs to be wrapped in a structure that implements this protocol.

At a minimum the wrapper structure must implement the following methods to comply with the UIViewRepresentable protocol:

  • makeUIView() – This method is responsible for creating an instance of the UIView-based component, performing any necessary initialization and returning it.
  • updateView() – Called each time a change occurs within the containing SwiftUI view that requires the UIView to update itself.

The following optional method may also be implemented:

  • dismantleUIView() – Provides an opportunity to perform cleanup operations before the view is removed.

As an example, assume that there is a feature of the UILabel class that is not available with the SwiftUI Text view. To wrap a UILabel view using UIViewRepresentable so that it can be used within SwiftUI, the structure might be implemented as follows:

import SwiftUI
 
struct MyUILabel: UIViewRepresentable {
    
    var text: String
    
    func makeUIView(context: UIViewRepresentableContext<MyUILabel>) 
                             -> UILabel {
        let myLabel = UILabel()
        myLabel.text = text
        return myLabel
    }
    
    func updateUIView(_ uiView: UILabel, 
                    context: UIViewRepresentableContext<MyUILabel>) {
        // Perform any update tasks if necessary
    }
}
 
struct MyUILabel_Previews: PreviewProvider {
    static var previews: some View {
        MyUILabel(text: "Hello")
    }
}

With the UILabel view wrapped, it can now be referenced within SwiftUI as though it is a built-in SwiftUI component:

struct ContentView: View {
    var body: some View {
        
        VStack {
            MyUILabel(text: "Hello UIKit")
        }
    }
}
 
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Obviously, UILabel is a static component that does not need to handle any user interaction events. For views that need to respond to events, however, the UIViewRepresentable wrapper needs to be extended to implement a coordinator.

Adding a Coordinator

A coordinator takes the form of a class that implements the protocols and handler methods required by the wrapped UIView component to handle events. An instance of this class is then applied to the wrapper via the makeCoordinator() method of the UIViewRepresentable protocol.

As an example, consider the UIScrollView class. This class has a feature whereby a refresh control (UIRefreshControl) may be added such that when the user attempts to scroll beyond the top of the view, a spinning progress indicator appears and a method called allowing the view to be updated with the latest content. This is a common feature used by news apps to allow the user to download the latest news headlines. Once the refresh is complete, this method needs to call the endRefreshing() method of the UIRefreshControl instance to remove the progress spinner.

Clearly, if the UIScrollView is to be used with SwiftUI, there needs to be a way for the view to be notified that the UIRefreshControl has been triggered and to perform the necessary steps.

The Coordinator class for a wrapped UIScrollView with an associated UIRefreshControl object would be implemented as follows:

class Coordinator: NSObject {
    var control: MyScrollView
 
    init(_ control: MyScrollView) {
        self.control = control
    }
    
    @objc func handleRefresh(sender: UIRefreshControl) {
        sender.endRefreshing()
    }
}

In this case the initializer for the coordinator is passed the current UIScrollView instance, which it stores locally. The class also implements a function named handleRefresh() which calls the endRefreshing() method of the scrolled view instance.

An instance of the Coordinator class now needs to be created and assigned to the view via a call to the makeCoordinator() method as follows:

func makeCoordinator() -> Coordinator {
     Coordinator(self)
}

Finally, the makeUIView() method needs to be implemented to create the UIScrollView instance, configure it with a UIRefreshControl and to add a target to call the handleRefresh() method when a value changed event occurs on the UIRefreshControl instance:

func makeUIView(context: Context) -> UIScrollView {
    let scrollView = UIScrollView()
    scrollView.refreshControl = UIRefreshControl()
    
    scrollView.refreshControl?.addTarget(context.coordinator, 
            action: #selector(Coordinator.handleRefresh),
                                      for: .valueChanged)
    
    return scrollView
}

Handling UIKit Delegation and Data Sources

Delegation is a feature of UIKit that allows an object to pass the responsibility for performing one or more tasks on to another object and is another area in which extra steps may be necessary if events are to be handled by a wrapped UIView.

The UIScrolledView, for example, can be assigned a delegate which will be notified when certain events take place such as the user performing a scrolling motion or when the user scrolls to the top of the content. The delegate object will need to conform to the UIScrolledViewDelegate protocol and implement the specific methods that will be called automatically when corresponding events take place in the scrolled view.

Similarly, a data source is an object which provides a UIView based component with data to be displayed. The UITableView class, for example, can be assigned a data source object to provide the cells to be displayed in the table. This data object must conform with the UITableViewDataSource protocol.

To handle delegate events when integrating UIViews into SwiftUI, the coordinator class needs to be declared as implementing the appropriate delegate protocol and must include the callback methods for any events of interest to the scrolled view instance. The coordinator must then be assigned as the delegate for the UIScrolledView instance. The previous coordinator implementation can be extended to receive notification that the user is currently scrolling as follows:

class Coordinator: NSObject, UIScrollViewDelegate {
    var control: MyScrollView
 
    init(_ control: MyScrollView) {
        self.control = control
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // User is currently scrolling
    }
    
    @objc func handleRefresh(sender: UIRefreshControl) {
        sender.endRefreshing()
    }
}

The makeUIView() method must also be modified to access the coordinator instance (which is accessible via the representable context object passed to the method) and add it as the delegate for the UIScrolledView instance:

func makeUIView(context: Context) -> UIScrollView {
    let scrollView = UIScrollView()
    scrollView.delegate = context.coordinator
.
.

In addition to providing access to the coordinator, the context also includes an environment property which can be used to access both the SwiftUI environment and any @EnvironmentObject properties declared in the SwiftUI view. Now, while the user is scrolling, the scrollViewDidScroll delegate method will be called repeatedly.

An Example Project

The remainder of this chapter will work through the creation of a simple project that demonstrates the use of the UIViewRepresentable protocol to integrate a UIScrolledView into a SwiftUI project. Begin by launching Xcode and creating a new SwiftUI Multiplatform App project named UIViewDemo.

Wrapping the UIScrolledView

The first step in the project is to use the UIViewRepresentable protocol to wrap the UIScrollView so that it can be used with SwiftUI. Right-click on the Shared folder entry in the project navigator panel, select the New File… menu option and create a new file named MyScrollView using the SwiftUI View template.

With the new file loaded into the editor, delete the current content and modify it so it reads as follows:

import SwiftUI
 
struct MyScrollView: UIViewRepresentable {
    
    var text: String
    
    func makeUIView(context: UIViewRepresentableContext<MyScrollView>)
               -> UIScrollView {
        let scrollView = UIScrollView()
        scrollView.refreshControl = UIRefreshControl()
        let myLabel = UILabel(frame:
                   CGRect(x: 0, y: 0, width: 300, height: 50))
        myLabel.text = text
        scrollView.addSubview(myLabel)
        return scrollView
    }
    
    func updateUIView(_ uiView: UIScrollView,
        context: UIViewRepresentableContext<MyScrollView>) {
        
    }
 
}
 
struct MyScrollView_Previews: PreviewProvider {
    static var previews: some View {
        MyScrollView(text: "Hello World")
    }
}

If the above code generates syntax errors, make sure that an iOS device is selected as the run target in the Xcode toolbar instead of macOS. Use the Live Preview to build and test the view so far. Once the Live Preview is active and running, click and drag downwards so that the refresh control appears as shown in Figure 52-1:

Figure 52-1

Release the mouse button to stop the scrolling and note that the refresh indicator remains visible because the event is not being handled. Clearly, it is now time to add a coordinator.

struct MyScrollView: UIViewRepresentable {
.
.
    func updateUIView(_ uiView: UIScrollView, context: UIViewRepresentableContext<MyScrollView>) {
        
    }
 
    class Coordinator: NSObject, UIScrollViewDelegate {
        var control: MyScrollView
 
        init(_ control: MyScrollView) {
            self.control = control
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            print("View is Scrolling")
        }
        
        @objc func handleRefresh(sender: UIRefreshControl) {
            sender.endRefreshing()
        }
    }
}

Implementing the Coordinator

Remaining within the MyScrollView.swift file, add the coordinator class declaration so that it reads as follows:

Next, modify the makeUIView() method to add the coordinator as the delegate and add the handleRefresh() method as the target for the refresh control:

func makeUIView(context: Context) -> UIScrollView {
    let scrollView = UIScrollView()
    scrollView.delegate = context.coordinator
    
    scrollView.refreshControl = UIRefreshControl()
    scrollView.refreshControl?.addTarget(context.coordinator, action:
        #selector(Coordinator.handleRefresh),
                                      for: .valueChanged)
.
.
    return scrollView
}

Finally, add the makeCoordinator() method so that it reads as follows:

func makeCoordinator() -> Coordinator {
     Coordinator(self)
}

Before proceeding, test that the changes work by right-clicking on the Live Preview button in the preview canvas panel and selecting the Debug Preview option from the resulting menu (it may be necessary to stop the Live Preview first). Once the preview is running in debug mode, make sure that the refresh indicator now goes away when downward scrolling stops and that the “View is scrolling” diagnostic message appears in the console while scrolling is in effect.

Using MyScrollView

The final step in this example is to check that MyScrollView can be used from within SwiftUI. To achieve this, load the ContentView.swift file into the editor and modify it so that it reads as follows:

.
.
struct ContentView: View {
    var body: some View {
        MyScrollView(text: "UIView in SwiftUI")
    }
}
.
.

Use Live Preview to test that the view works as expected.

Summary

SwiftUI includes several options for integrating with UIKit-based views and code. This chapter has focused on integrating UIKit views into SwiftUI. This integration is achieved by wrapping the UIView instance in a structure that conforms to the UIViewRepresentable protocol and implementing the makeUIView() and updateView() methods to initialize and manage the view while it is embedded in a SwiftUI layout. For UIKit objects that require a delegate or data source, a Coordinator class needs to be added to the wrapper and assigned to the view via a call to the makeCoordinator() method.

Adding Configuration Options to a WidgetKit Widget

The WidgetDemo app created in the preceding chapters is currently only able to display weather information for a single geographical location. Through the use of configuration intents, it is possible to make aspects of the widget user configurable. In this chapter we will enhance the widget extension so that the user can choose to view the weather for different cities. This will involve some minor changes to the weather data, the modification of the SiriKit intent definition and updates to the widget implementation.

Modifying the Weather Data

Before adding configuration support to the widget, an additional structure needs to be added to the widget data to provide a way to associate cities with weather timelines. Add this structure by modifying the WeatherData. swift file as follows:

import Foundation
import WidgetKit
 
struct LocationData: Identifiable {
    
    let city: String
    let timeline: [WeatherEntry]
    
    var id: String {
        city
    }
    
    static let london = LocationData(city: "London", 
                                 timeline: londonTimeline)
    static let miami = LocationData(city: "Miami", 
                                timeline: miamiTimeline)
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(city)
    }
}
.
.

Configuring the Intent Definition

The next step is to configure the intent definition which will be used to present the user with widget configuration choices. When the WeatherWidget extension was added to the project, the “Include Configuration Intent” option was enabled, causing Xcode to generate a definition file named WeatherWidget.intentdefinition located in the WeatherWidget project folder. Select this file to load it into the intent definition editor where it will appear as shown in Figure 51-1:

Figure 51-1

Begin by making sure that the Configuration intent (marked A in Figure 51-1 above) is selected. This is the intent that was created by Xcode and will be referenced as ConfigurationIntent in the WeatherWidget.swift file. Additional intents may be added to the definition by clicking on the ‘+’ button (D) and selecting New Intent from the menu.

The Category menu (B) must be set to View to allow the intent to display a dialog to the user containing the widget configuration options. Also ensure that the Intent is eligible for widgets option (B) is enabled.

Before we add a parameter to the intent, an enumeration needs to be added to the definition file to contain the available city names. Add this now by clicking on the ‘+’ button (D) and selecting the New Enum option from the menu.

After the enumeration has been added, change both the enumeration name and Display Name to Locations as highlighted in Figure 51-2 below:

Figure 51-2

With the Locations entry selected, refer to the main editor panel and click on the ‘+’ button beneath the Cases section to add a new value. Change the new case entry name to londonUK and, in the settings area, change the display name to London so that the settings resemble Figure 51-3:

Figure 51-3

Repeat the above steps to add an additional cased named miamiFL with the display name set to Miami.

In the left-hand panel, select the Configuration option located under the Custom Intents heading. In the custom intent panel, locate the Parameters section and click on the ‘+’ button highlighted in Figure 51-4 to add a new parameter:

Figure 51-4

Name the parameter locations and change the Display Name setting to Locations. From the Type menu select Locations listed under Enums as shown in Figure 51-5 (note that this is not the same as the Location entry listed under System Types):

Figure 51-5

Once completed, the parameter settings should match those shown in Figure 51-6 below:

Figure 51-6

Modifying the Widget

With the intent configured, all that remains is to adapt the widget so that it responds to location configuration changes made by the user. When WidgetKit requests a timeline from the provider it will pass to the getTimeline() method a ConfigurationIntent object containing the current configuration settings from the intent. To return the timeline for the currently selected city, the getTimeline() method needs to be modified to extract the location from the intent and use it to return the matching timeline.

Edit the WeatherWidget.swift file, locate the getTimeline() method within the provider declaration and modify it so that it reads as follows:

func getTimeline(for configuration: ConfigurationIntent, in context: Context, 
              completion: @escaping (Timeline<Entry>) -> ()) {
    
    var chosenLocation: LocationData
        
    if configuration.locations == .londonUK {
        chosenLocation = .london
    } else {
        chosenLocation = .miami
    }
 
    var entries: [WeatherEntry] = []
    var currentDate = Date()
    let halfMinute: TimeInterval = 30
 
    for var entry in chosenLocation.timeline {
        entry.date = currentDate
        currentDate += halfMinute
        entries.append(entry)
    }
    let timeline = Timeline(entries: entries, policy: .never)
    completion(timeline)
}

In the above code, if the intent object passed to the method has London set as the location, then the london entry within the LocationData instance is used to provide the timeline for WidgetKit. If any of the above changes result in syntax errors within the editor try rebuilding the project to trigger the generation of the files associated with the intent definition file.

Testing Widget Configuration

Run the widget extension on a device or simulator and wait for it to load. Once it is running, perform a long press on the widget to display the menu shown in Figure 51-7 below:

Figure 51-7

Select the Edit Widget menu option to display the configuration intent dialog as shown in Figure 51-8:

Figure 51-8

Select the Miami location before tapping on any screen area outside of the dialog. On returning to the home screen, the widget should now be displaying entries from the Miami timeline.

Note that the intent does all of the work involved in presenting the user with the configuration options, automatically adjusting to reflect the type and quantity of options available. If more cities are included in the enumeration, for example, the intent will provide a Choose button which, when tapped, will display a scrollable list of cities from which to choose:

Figure 51-9

Customizing the Configuration Intent UI

The final task in this tutorial is to change the accent colors of the intent UI to match those used by the widget. Since we already have the widget background color declared in the widget extension’s Assets.xcassets file from the steps in an earlier chapter, this can be used for the background of the intent UI.

The color settings for the intent UI are located in the build settings screen for the widget extension. To find these settings, select the WidgetDemo entry located at the top of the project navigator panel (marked A in Figure 5110 below), followed by the WeatherWidgetExtension entry (B) in the Targets list:

Figure 51-10

In the toolbar, select Build Settings (C), then the Basic filter option (D) before scrolling down to the Asset Catalog Compiler – Options section (E).

Click on the WidgetBackground value (F) and change it to weatherBackgroundColor. If required, the foreground color used within the intent UI is defined by the Global Accent Color Name value. Note that these values must be named colors declared within the Assets.xcassets file.

Test the widget to verify that the intent UI now uses the widget background color:

Figure 51-11

Summary

When a widget is constructed using the intent configuration type (as opposed to static configuration), configuration options can be made available to the user by setting up intents and parameters within the SiriKit intent definition file. Each time the provider getTimeline() method is called, WidgetKit passes it a copy of the configuration intent object, the parameters of which can be inspected and used to tailor the resulting timeline to match the user’s preferences.

A SwiftUI WidgetKit Deep Link Tutorial

WidgetKit deep links allow the individual views that make up the widget entry view to open different screens within the companion app when tapped. In addition to the main home screen, the WidgetDemo app created in the preceding chapters contains a detail screen to provide the user with information about different weather systems. As currently implemented, however, tapping the widget always launches the home screen of the companion app, regardless of the current weather conditions.

The purpose of this chapter is to implement deep linking on the widget so that tapping the widget opens the appropriate weather detail screen within the app. This will involve some changes to both the app and widget extension.

Adding Deep Link Support to the Widget

Deep links allow specific areas of an app to be presented to the user based on the opening of a URL. The WidgetDemo app used in the previous chapters consists of a list of severe storm types. When a list item is selected, the app navigates to a details screen where additional information about the selected storm is displayed. In this tutorial, changes will be made to both the app and widget to add deep link support. This means, for example, that when the widget indicates that a thunder storm is in effect, tapping the widget will launch the app and navigate to the thunder storm detail screen.

The first step in adding deep link support is to modify the WeatherEntry structure to include a URL for each timeline entry. Edit the WeatherData.swift file and modify the structure so that it reads as follows:

.
.
struct WeatherEntry: TimelineEntry {
    var date: Date
    let city: String
    let temperature: Int
    let description: String
    let icon: String
    let image: String
    let url: URL?
}
.
.

Next, add some constants containing the URLs which will be used to identify the storm types that the app knows about:

.
.
let hailUrl = URL(string: "weatherwidget://hail")
let thunderUrl = URL(string: "weatherwidget://thunder")
let tropicalUrl = URL(string: "weatherwidget://tropical")
.
.

The last remaining change to the weather data is to include the URL within the sample timeline entries:

.
.
let londonTimeline = [
    WeatherEntry(date: Date(), city: "London", temperature: 87, 
          description: "Hail Storm", icon: "cloud.hail", 
                image: "hail", url: hailUrl),
    WeatherEntry(date: Date(), city: "London", temperature: 92, 
          description: "Thunder Storm", icon: "cloud.bolt.rain", 
                image: "thunder", url: thunderUrl),
    WeatherEntry(date: Date(), city: "London", temperature: 95,   
          description: "Hail Storm", icon: "cloud.hail", 
                image: "hail", url: hailUrl)
]
 
let miamiTimeline = [
    WeatherEntry(date: Date(), city: "Miami", temperature: 81, 
          description: "Thunder Storm", icon: "cloud.bolt.rain", 
                image: "thunder", url: thunderUrl),
    WeatherEntry(date: Date(), city: "Miami", temperature: 74,
          description: "Tropical Storm", icon: "tropicalstorm", 
                image: "tropical", url: tropicalUrl),
    WeatherEntry(date: Date(), city: "Miami", temperature: 72, 
          description: "Thunder Storm", icon: "cloud.bolt.rain", 
                image: "thunder", url: thunderUrl)
]
.
.

With the data modified to include deep link URLs, the widget declaration now needs to be modified to match the widget entry structure. First, the placeholder() and getSnapshot() methods of the provider will need to return an entry which includes the URL. Edit the WeatherWidget.swift file, locate these methods within the IntentTimelineProvider structure and modify them as follows:

struct Provider: IntentTimelineProvider {
   func placeholder(in context: Context) -> WeatherEntry {
       
        WeatherEntry(date: Date(), city: "London",
                           temperature: 89, description: "Thunder Storm",
                                icon: "cloud.bolt.rain", image: "thunder", 
                                    url: thunderUrl)
    }
 
    func getSnapshot(for configuration: ConfigurationIntent, with context: Context, completion: @escaping (WeatherEntry) -> ()) {
       
        let entry = WeatherEntry(date: Date(), city: "London", 
                      temperature: 89, description: "Thunder Storm", 
                        icon: "cloud.bolt.rain", image: "thunder", 
                         url: thunderUrl)
        completion(entry)
    }
.
.

Repeat this step for both declarations in the preview provider:

struct WeatherWidget_Previews: PreviewProvider {
    static var previews: some View {
       
        Group {
            WeatherWidgetEntryView(entry: WeatherEntry(date: Date(), 
                        city: "London", temperature: 89, 
                 description: "Thunder Storm", icon: "cloud.bolt.rain", 
                       image: "thunder", url: thunderUrl))
                .previewContext(WidgetPreviewContext(
                                      family: .systemSmall))
        
            WeatherWidgetEntryView(entry: WeatherEntry(date: Date(), 
                        city: "London", temperature: 89, 
                 description: "Thunder Storm", icon: "cloud.bolt.rain", 
                       image: "thunder", url: thunderUrl))
                 .previewContext(WidgetPreviewContext(
                                      family: .systemMedium))
        }
    }
}

The final task within the widget code is to assign a URL action to the widget entry view. This is achieved using the widgetUrl() modifier, passing through the URL from the widget entry. Remaining in the WeatherWidget.swift file, locate the WeatherWidgetEntryView declaration and add the modifier to the top level ZStack as follows:

struct WeatherWidgetEntryView : View {
    var entry: Provider.Entry
 
    @Environment(\.widgetFamily) var widgetFamily
    
    var body: some View {
 
        ZStack {
            Color("weatherBackgroundColor")
   
            HStack {
                WeatherSubView(entry: entry)
                if widgetFamily == .systemMedium {
                    ZStack {
                        Image(entry.image)
                            .resizable()
                    }
                }
            }
        }
        .widgetURL(entry.url)
    }
}

With deep link support added to the widget the next step is to add support to the app.

Adding Deep Link Support to the App

When an app is launched via a deep link, it is passed a URL object which may be accessed via the top level view in the main content view. This URL can then be used to present different content to the user than would normally be displayed.

The first step in adding deep link support to the WidgetDemo app is to modify the ContentView.swift file to add some state properties. These variables will be used to control which weather detail view instance is displayed when the app is opened by a URL:

import SwiftUI
 
struct ContentView: View {
    
    @State private var hailActive: Bool = false
    @State private var thunderActive: Bool = false
    @State private var tropicalActive: Bool = false
    
    var body: some View {
        NavigationView {
            List {

The above state variables now need to be referenced in the navigation links within the List view:

var body: some View {
    
    NavigationView {
        List {
            NavigationLink(destination: WeatherDetailView(
                   name: "Hail Storms", icon: "cloud.hail"), 
                       isActive: $hailActive) {
                Label("Hail Storm", systemImage: "cloud.hail")
            }
 
            NavigationLink(destination: WeatherDetailView(
                   name: "Thunder Storms", icon: "cloud.bolt.rain"), 
                      isActive: $thunderActive) {
                Label("Thunder Storm", systemImage: "cloud.bolt.rain")
            }
            
            NavigationLink(destination: WeatherDetailView(
                   name: "Tropical Storms", icon: "tropicalstorm"), 
                      isActive: $tropicalActive) {
                Label("Tropical Storm", systemImage: "tropicalstorm")
            }
        }
        .navigationTitle("Severe Weather")
    }
}

The isActive argument to the NavigationLink view allows the link to be controlled programmatically. For example, the first link will navigate to the WeatherDetailView screen configured for hail storms when manually selected by the user. With the addition of the isActive argument, the navigation will also occur if the hailActive state property is changed to true as the result of some other action within the code.

When a view is displayed as the result of a deep link, the URL used to launch the app can be identified using the onOpenUrl() modifier on the parent view. By applying this modifier to the NavigationView we can write code to modify the state properties based on the URL, thereby programmatically triggering navigation to an appropriately configured detail view.

Modify the ContentView declaration to add the onOpenUrl() modifier as follows:

struct ContentView: View {
    
    @State private var hailActive: Bool = false
    @State private var thunderActive: Bool = false
    @State private var tropicalActive: Bool = false
    
    var body: some View {
        
        NavigationView {
            List {
.
.                
                NavigationLink(destination: WeatherDetailView(name: "Tropical Storms", icon: "tropicalstorm"), isActive: $tropicalActive) {
                    Label("Tropical Storm", systemImage: "tropicalstorm")
                }
                
            }
            .navigationTitle("Severe Weather")
            .onOpenURL(perform: { (url) in
                self.hailActive = url == hailUrl
                self.thunderActive = url == thunderUrl
                self.tropicalActive = url == tropicalUrl
            })
        }
    }
}

The added code performs a comparison of the URL used to launch the app with each of the custom URLs supported by the widget. The result of each comparison (i.e. true or false) is then assigned to the corresponding state property. If the URL matches the thunder URL, for example, then the thunderActive state will be set to true causing the view to navigate to the detail view configured for thunder storms.

Testing the Widget

After making the changes, run the app on a device or simulator and make sure that tapping the widget opens the app and displays the detail screen correctly configured for the current weather.

Figure 50-1

Summary

By default, a widget will launch the main view of the companion app when tapped by the user. This behavior can be enhanced by establishing deep links that take the user to specific areas of the app. This involves using the widgetUrl() modifier to assign destination URLs to the views in a widget entry layout. Within the app the onOpenUrl() modifier is then used to identify the URL used to launch the app and initiate navigation to the corresponding view.

Supporting WidgetKit Size Families in SwiftUI

In the chapter titled Building Widgets with SwiftUI and WidgetKit, we learned that a widget is able to appear in small, medium and large sizes. The project created in the previous chapter included a widget view designed to fit within the small size format. Since the widget did not specify the supported sizes, it would still be possible to select a large or medium sized widget from the gallery and place it on the home screen. In those larger formats, however, the widget content would have filled only a fraction of the available widget space. If larger widget sizes are to be supported, the widget should be designed to make full use of the available space.

In this chapter, the WidgetDemo project created in the previous chapter will be modified to add support for the medium widget size.

Supporting Multiple Size Families

Begin by launching Xcode and loading the WidgetDemo project from the previous chapter. As outlined above, this phase of the project will add support for the medium widget size (though these steps apply equally to adding support for the large widget size).

In the absence of specific size configuration widgets are, by default, configured to support all three size families. To restrict a widget to specific sizes, the supportedFamilies() modifier must be applied to the widget configuration.

To restrict the widget to only small and medium sizes for the WidgetDemo project, edit the WeatherWidget.swift file and modify the WeatherWidget declaration to add the modifier. Also take this opportunity to modify the widget display name and description:

@main
struct WeatherWidget: Widget {
    private let kind: String = "WeatherWidget"
 
    public var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider(), placeholder: PlaceholderView()) { entry in
            WeatherWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Weather Widget")
        .description("A demo weather widget.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

To preview the widget in medium format, edit the preview provider to add an additional preview, embedding both in a Group:

struct WeatherWidget_Previews: PreviewProvider {
    static var previews: some View {
       
        Group {
            WeatherWidgetEntryView(entry: WeatherEntry(date: Date(), 
                  city: "London", temperature: 89, 
                  description: "Thunder Storm", icon: "cloud.bolt.rain", 
                        image: "thunder"))
                .previewContext(WidgetPreviewContext(
                                         family: .systemSmall))
        
            WeatherWidgetEntryView(entry: WeatherEntry(date: Date(), 
                  city: "London", temperature: 89, 
                  description: "Thunder Storm", icon: "cloud.bolt.rain", 
                        image: "thunder"))
                .previewContext(WidgetPreviewContext(
                                         family: .systemMedium))
        }
    }
}

When the preview canvas updates, it will now include the widget rendered in medium size as shown in Figure 49-1:

Figure 49-1

Clearly the widget is not taking advantage of the additional space offered by the medium size. To address this shortcoming, some changes to the widget view need to be made.

Adding Size Support to the Widget View

The changes made to the widget configuration mean that the widget can be displayed in either small or medium size. To make the widget adaptive, the widget view needs to identify the size in which it is currently being displayed. This can be achieved by accessing the widgetFamily property of the SwiftUI environment. Remaining in the WeatherWidget.swift file, locate and edit the WeatherWidgetEntryView declaration to obtain the widget family setting from the environment:

struct WeatherWidgetEntryView: View {
    var entry: Provider.Entry
    
    @Environment(\.widgetFamily) var widgetFamily
.
.

Next, embed the subview in a horizontal stack and conditionally display the image for the entry if the size is medium:

struct WeatherWidgetEntryView : View {
    var entry: Provider.Entry
 
    @Environment(\.widgetFamily) var widgetFamily
    
    var body: some View {
 
        ZStack {
            Color("weatherBackgroundColor")
   
            HStack {
                WeatherSubView(entry: entry)
                if widgetFamily == .systemMedium {
                    Image(entry.image)
                        .resizable()
                }
            }
        }
    }
}

When previewed, the medium sized version of the widget should appear as shown in Figure 49-2:

Figure 49-2

To test the widget on a device or simulator, run the extension as before and, once the widget is installed and running, perform a long press on the home screen background. After a few seconds have elapsed, the screen will change as shown in Figure 49-3:

Figure 49-3

Click on the ‘+’ button indicated by the arrow in the above figure to display the widget gallery and scroll down the list of widgets until the WidgetDemo entry appears:

Figure 49-4

Select the WidgetDemo entry to display the widget size options. Swipe to the left to display the medium widget size as shown in Figure 49-5 before tapping on the Add Widget button:

Figure 49-5

On returning to the home screen, click on the Done button located in the top right-hand corner of the home screen to commit the change. The widget will appear as illustrated in Figure 49-6 and update as the timeline progresses:

Figure 49-6

Summary

WidgetKit supports small, medium and large widget size families and, by default, a widget is assumed to support all three formats. In the event that a widget only supports specific sizes, WidgetKit needs to be notified using a widget configuration modifier.

To fully support a size format, a widget should take steps to detect the current size and provide a widget entry layout which makes use of the available space allocated to the widget on the device screen. This involves accessing the SwiftUI environment widgetFamily property and using it as the basis for conditional layout declarations within the widget view.

Now that widget size family support has been added to the project, the next chapter will add some interactive support to the widget in the form of deep linking into the companion app and widget configuration.

A SwiftUI WidgetKit Tutorial

From the previous chapter we now understand the elements that make up a widget and the steps involved in creating one. In this, the first of a series of tutorial chapters dedicated to WidgetKit, we will begin the process of creating an app which includes a widget extension. On completion of these tutorials, a functioning widget will have been created, including widget design and the use of timelines, support for different size families, deep links, configuration using intents and basic intelligence using SiriKit donations and relevance.

About the WidgetDemo Project

The project created in this tutorial can be thought of as the early prototype of a weather app designed to teach children about weather storms. The objective is to provide the user with a list of severe weather systems (tropical storms, thunderstorms etc.) and, when a storm type is selected, display a second screen providing a description of the weather system.

A second part of the app is intended to provide real-time updates on severe weather occurring in different locations around the world. When a storm is reported, a widget will be updated with information about the type and location of the storm, together with the prevailing temperature. When the widget is tapped by the user, the app will open the screen containing information about that storm category.

Since this app is an early prototype, however, it will only provide weather updates from two cities, and that data will be simulated rather than obtained from a real weather service. The app will be functional enough, however, to demonstrate how to implement the key features of WidgetKit.

Creating the WidgetDemo Project

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

Building the App

Before adding the widget extension to the project, the first step is to build the basic structure of the app. This will consist of a List view populated with some storm categories which, when selected, will appear in a detail screen.

The detail screen will be declared in a new SwiftUI View file named WeatherDetailView.swift. Within the project navigator panel, right-click on the Shared folder and select the New File… menu option. In the resulting dialog, select the SwiftUI View template option and click on the Next button. Name the file WeatherDetailView.swift before creating the file.

With the WeatherDetailView.swift file selected, modify the view declaration so that it reads as follows:

import SwiftUI
 
struct WeatherDetailView: View {
    
    var name: String
    var icon: String
    
    var body: some View {
        VStack {
            Image(systemName: icon)
                .resizable()
                    .scaledToFit()
                    .frame(width: 150.0, height: 150.0)
            Text(name)
                .padding()
                .font(.title)
            Text("If this were a real weather app, a description of \(name) would appear here.")
                .padding()
            Spacer()
        }
    }
}
 
struct WeatherDetailView_Previews: PreviewProvider {
    static var previews: some View {
        WeatherDetailView(name: "Thunder Storms", icon: "cloud.bolt")
    }
}

When rendered, the above view should appear in the preview canvas as shown in Figure 48-1 below:

Figure 48-1

Next, select the ContentView.swift file and modify it to add a List view embedded in a NavigationView as follows:

import SwiftUI
 
struct ContentView: View {
    var body: some View {
        
        NavigationView {
            List {      
                NavigationLink(destination: WeatherDetailView(
                                      name: "Hail Storms", 
                                      icon: "cloud.hail")) {
                    Label("Hail Storm", systemImage: "cloud.hail")
                }
 
                NavigationLink(destination: WeatherDetailView(
                                      name: "Thunder Storms", 
                                      icon: "cloud.bolt.rain")) {
                    Label("Thunder Storm", 
                               systemImage: "cloud.bolt.rain")
                }
                
                NavigationLink(destination: WeatherDetailView(
                                      name: "Tropical Storms", 
                                      icon: "tropicalstorm")) {
                    Label("Tropical Storm", systemImage: "tropicalstorm")
                }
                
            }
            .navigationTitle("Severe Weather")
        }
    }
}
.
.

Once the changes are complete, make sure that the layout matches that shown in Figure 48-2:

Figure 48-2

Using Live Preview, make sure that selecting a weather type displays the detail screen populated with the correct storm name and image.

Adding the Widget Extension

The next step in the project is to add the widget extension by selecting the File -> New -> Target… menu option. From within the target template panel, select the Widget Extension option as shown in Figure 48-3 before clicking on the Next button:

Figure 48-3

On the subsequent screen, enter WeatherWidget into the product name field. When the widget is completed, the user will be able to select the geographical location for which weather updates are to be displayed. To make this possible the widget will need to use the intent configuration type. Before clicking on the Finish button, therefore, make sure that the Include Configuration Intent option is selected as shown in Figure 48-4:

Figure 48-4

When prompted, click on the Activate button to activate the extension within the project scheme. This will ensure that the widget is included in the project build process:

Figure 48-5

Once the extension has been added, refer to the project navigator panel, where a new folder containing the widget extension will have been added as shown in Figure 48-6:

Figure 48-6

Adding the Widget Data

Now that the widget extension has been added to the project, the next step is to add some data and data structures that will provide the basis for the widget timeline. Begin by right-clicking on the Shared folder in the project navigator and selecting the New File… menu option.

From the template selection panel, select the Swift File entry, click on the Next button and name the file WeatherData.swift. Before clicking on the Create button, make sure that the WeatherWidgetExtension entry is enabled in the Targets section of the panel as shown in Figure 48-7 so that the file will be accessible to the extension:

Figure 48-7

As outlined in the previous chapter, each point in the widget timeline is represented by a widget timeline entry instance. Instances of this structure contain the date and time that the entry is to be presented by the widget, together with the data to be displayed. Within the WeatherData.swift file, add a TimelineEntry structure as follows (noting that the WidgetKit framework also needs to be imported):

import Foundation
import WidgetKit
 
struct WeatherEntry: TimelineEntry {
    var date: Date
    let city: String
    let temperature: Int
    let description: String
    let icon: String
    let image: String
}

Creating Sample Timelines

Since this prototype app does not have access to live weather data, the timelines used to drive the widget content will contain sample weather entries for two cities. Remaining within the WeatherData.swift file, add these timeline declarations as follows:

.
.
let londonTimeline = [
    WeatherEntry(date: Date(), city: "London", temperature: 87, 
          description: "Hail Storm", icon: "cloud.hail", 
                image: "hail"),
    WeatherEntry(date: Date(), city: "London", temperature: 92, 
          description: "Thunder Storm", icon: "cloud.bolt.rain", 
                image: "thunder"),
    WeatherEntry(date: Date(), city: "London", temperature: 95,   
          description: "Hail Storm", icon: "cloud.hail", 
                image: "hail")
]
 
let miamiTimeline = [
    WeatherEntry(date: Date(), city: "Miami", temperature: 81, 
          description: "Thunder Storm", icon: "cloud.bolt.rain", 
                image: "thunder"),
    WeatherEntry(date: Date(), city: "Miami", temperature: 74,
          description: "Tropical Storm", icon: "tropicalstorm", 
                image: "tropical"),
    WeatherEntry(date: Date(), city: "Miami", temperature: 72, 
          description: "Thunder Storm", icon: "cloud.bolt.rain", 
                image: "thunder")
]

Note that the timeline entries are populated with the current date and time via a call to the Swift Date() method. These values will be replaced with more appropriate values by the provider when the timeline is requested by WidgetKit.

Adding Image and Color Assets

Before moving to the next step of the tutorial, some image and color assets need to be added to the asset catalog of the widget extension.

Begin by selecting the Assets.xcassets file located in the WeatherWidget folder in the project navigator panel as highlighted in Figure 48-8:

Figure 48-8

Add a new entry to the catalog by clicking on the button indicated by the arrow in Figure 48-8 above. In the resulting menu, select the Color Set option. Click on the new Color entry and change the name to weatherBackgroundColor. With this new entry selected, click on the Any Appearance block in the main panel as shown in Figure 48-9:

Figure 48-9

Referring to the Color section of the attributes inspector panel, set Content to Display P3, Input Method to 8-bit Hexadecimal and the Hex field to #4C5057:

Figure 48-10

Select the Dark Appearance and make the same attribute changes, this time setting the Hex value to #3A4150.

Next, add a second Color Set asset, name it weatherInsetColor and use #4E7194 for the Any Appearance color value and #7E848F for the Dark Appearance.

The images used by this project can be found in the weather_images folder of the sample code download available from the following URL:

https://www.ebookfrenzy.com/code/SwiftUI-iOS14-CodeSamples.zip

Once the source archive has been downloaded and unpacked, open a Finder window, navigate to the weather_ images folder and select, drag and drop the images on to the left-hand panel of the Xcode asset catalog screen as shown in Figure 48-11:

Figure 48-11

Designing the Widget View

Now that the widget entry has been created and used as the basis for some sample timeline data, the widget view needs to be designed. When the widget extension was added to the project, a template widget entry view was included in the WeatherWidget.swift file which reads as follows:

struct WeatherWidgetEntryView : View {
    var entry: Provider.Entry
 
    var body: some View {
        Text(entry.date, style: .time)
    }
}

As currently implemented, the view is passed a widget entry from which the date value is extracted and displayed on a Text view.

Modify the view structure so that it reads as follows, keeping in mind that it will result in syntax errors appearing in the editor. These will be resolved later in the tutorial:

struct WeatherWidgetView: View {
    var entry: Provider.Entry
        
    var body: some View {
        ZStack {
            Color("weatherBackgroundColor")
            WeatherSubView(entry: entry)
        }
    }
}
 
struct WeatherSubView: View {
    
    var entry: WeatherEntry
    
    var body: some View {
        
        VStack {
            VStack {
                Text("\(entry.city)")
                    .font(.title)
                Image(systemName: entry.icon)
                    .font(.largeTitle)
                Text("\(entry.description)")
                    .frame(minWidth: 125, minHeight: nil)
            }
            .padding(.bottom, 2)
            .background(ContainerRelativeShape()
                       .fill(Color("weatherInsetColor")))
            Label("\(entry.temperature)°F", systemImage: "thermometer")
        }
        .foregroundColor(.white)
        .padding()
    }
}

Since we have changed the view, the preview provider declaration will also need to be changed as follows:

struct WeatherWidget_Previews: PreviewProvider {
    static var previews: some View {
       
        WeatherWidgetEntryView(entry: WeatherEntry(date: Date(), 
                     city: "London", temperature: 89, 
              description: "Thunder Storm", 
                     icon: "cloud.bolt.rain", image: "thunder"))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

Once all of the necessary changes have eventually been made to the WeatherWidget.swift file, the above preview provider will display a preview canvas configured for the widget small family size.

Modifying the Widget Provider

When the widget extension was added to the project, Xcode added a widget provider to the WeatherWidget. swift file. This declaration now needs to be modified to make use of the WeatherEntry structure declared in the WeatherData.swift file. The first step is to modify the getSnapshot() method to use WeatherEntry and to return an instance populated with sample data:

.
.
struct Provider: IntentTimelineProvider {
    func getSnapshot(for configuration: ConfigurationIntent, with context: Context, completion: @escaping (WeatherEntry) -> ()) {
       
        let entry = WeatherEntry(date: Date(), city: "London", 
                    temperature: 89, description: "Thunder Storm", 
                         icon: "cloud.bolt.rain", image: "thunder")
        completion(entry)
    }
.
.

Next, the getTimeline() method needs to be modified to return an array of timeline entry objects together with a reload policy value. Since user configuration has not yet been added to the widget, the getTimeline() method will be configured initially to return the timeline for London:

struct Provider: IntentTimelineProvider {
.
.
    func getTimeline(for configuration: ConfigurationIntent, with context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        
        var entries: [WeatherEntry] = []
        var eventDate = Date()
        let halfMinute: TimeInterval = 30
    
        for var entry in londonTimeline {
            entry.date = eventDate
            eventDate += halfMinute
            entries.append(entry)
        }
        let timeline = Timeline(entries: entries, policy: .never)
        completion(timeline)
    }
}

The above code begins by declaring an array to contain the WeatherEntry instances before creating variables designed to represent the current event time and a 30 second time interval respectively.

A loop then iterates through the London timeline declared in the WeatherData.swift file, setting the eventDate value as the date and time at which the event is to be displayed by the widget. A 30 second interval is then added to the eventDate ready for the next event. Finally, the modified event is appended to the entries array. Once all of the events have been added to the array, it is used to create a Timeline instance with a reload policy of never (in other words WidgetKit will not ask for a new timeline when the first timeline ends). The timeline is then returned to WidgetKit via the completion handler.

This implementation of the getTimeline() method will result in the widget changing content every 30 seconds until the final entry in the London timeline array is reached.

Configuring the Placeholder View

The Final task before previewing the widget is to make sure that the placeholder view has been implemented. Xcode will have already created a placeholder() method for this purpose within the WeatherWidget.swift file which reads as follows:

func placeholder(in context: Context) -> SimpleEntry {
   SimpleEntry(date: Date(), configuration: ConfigurationIntent())
}

This method now needs to be modified so that it returns a WeatherWidget instance populated with some sample data as follows:

func placeholder(in context: Context) -> WeatherEntry {    
    WeatherEntry(date: Date(), city: "London",
                       temperature: 89, description: "Thunder Storm",
                            icon: "cloud.bolt.rain", image: "thunder")
}

Previewing the Widget

Using the preview canvas, verify that the widget appears as shown in Figure 48-12 below:

Figure 48-12

Next, test the widget on a device or simulator by changing the active scheme in the Xcode toolbar to the WeatherWidgetExtension scheme before clicking on the run button:

Figure 48-13

After a short delay, the widget will appear on the home screen and cycle through the different weather events at 30 second intervals:

Figure 48-14

Summary

The example project created in this chapter has demonstrated how to use WidgetKit to create a widget extension for an iOS app. This included the addition of the extension to the project, the design of the widget view and entry together with the implementation of a sample timeline. The widget created in this chapter, however, has not been designed to make use of the different widget size families supported by WidgetKit, a topic which will be covered in the next chapter.

Building Widgets with SwiftUI and WidgetKit

Introduced in iOS 14, widgets allow small amounts of app content to be displayed alongside the app icons that appear on the device home screen pages, the Today view and the macOS Notification Center. Widgets are built using SwiftUI in conjunction with the WidgetKit Framework.

The focus of this chapter is to provide a high level outline of the various components that make up a widget before exploring widget creation in practical terms in the chapters that follow.

An Overview of Widgets

Widgets are intended to provide users with “at a glance” views of important, time sensitive information relating to your app. When a widget is tapped by the user, the corresponding app is launched, taking the user to a specific screen where more detailed information may be presented. Widgets are intended to display information which updates based on a timeline, ensuring that only the latest information is displayed to the user. A single app can have multiple widgets displaying different information.

Widgets are available in three size families (small, medium and large), of which at least one size must be supported by the widget, and can be implemented such that the information displayed is customizable by the user.

Widgets are selected from the widget gallery and positioned by the user on the device home screens. To conserve screen space, iOS allows widgets to be stacked, providing the user the ability to flip through each widget in the stack with a swiping gesture. A widget can increase the probability of moving automatically to the top of the stack by assigning a relevancy score to specific timeline entries. The widget for a weather app might, for example, assign a high relevancy to a severe weather warning in the hope that WidgetKit will move it to the top of the stack, thereby increasing the likelihood that the user will see the information.

The Widget Extension

A widget is created by adding a widget extension to an existing app. A widget extension consists of a Swift file, an optional intent definition file (required if the widget is to be user configurable), an asset catalog and an Info. plist file.

The widget itself is declared as a structure conforming to the Widget protocol, and it is within this declaration that the basic configuration of the widget is declared. The body of a typical widget declaration will include the following items:

  • Widget kind – Identifies the widget within the project. This can be any String value that uniquely identifies the widget within the project.
  • Widget Configuration – A declaration which conforms to the WidgetConfiguration protocol. This includes a reference to the provider of the timeline containing the information to be displayed, the widget display name and description, and the size families supported by the widget. WidgetKit supports two types of widget configuration referred to as static configuration and intent configuration.
  • Entry View – A reference to the SwiftUI View containing the layout that is to be presented to the user when the widget is displayed. This layout is populated with content from individual timeline entries at specific points in the widget timeline.

In addition to the widget declaration, the extension must also include a placeholder View defining the layout to be displayed to the user while the widget is loading and gathering data. This may either be declared manually, or configured to be generated automatically by WidgetKit based on the entry view included in the Widget view declaration outlined above.

Widget Configuration Types

When creating a widget, the choice needs to be made as to whether it should be created using the static or intent configuration model. These two options can be summarized as follows:

  • Intent Configuration – Used when it makes sense for the user to be able to configure aspects of the widget. For example, allowing the user to select the news publications from which headlines are to be displayed within the widget.
  • Static Configuration – Used when the widget does not have any user configurable properties.

When the Intent Configuration option is used, the configuration options to be presented to the user are declared within a SiriKit intent definition file.

The following is an example widget entry containing a static configuration designed to support both small and medium size families:

@main
struct SimpleWidget: Widget {
    private let kind: String = "SimpleWidget"
 
    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider(), 
                 placeholder: PlaceholderView()) { entry in
            SimpleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("A Simple Weather Widget")
        .description("This is an example widget.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

The following listing, on the other hand, declares a widget using the intent configuration:

@main
struct SimpleWidget: Widget {
    private let kind: String = "SimpleWidget"
 
    public var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, 
             intent: LocationSelectionIntent.self, provider: Provider(), 
               placeholder: PlaceholderView()) { entry in
            SimpleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Weather Fun")
        .description("Learning about weather in real-time.")
    }
}

The primary difference in the above declaration is that it uses IntentConfiguration instead of StaticConfiguration which, in turn, includes a reference to a SiriKit intent definition. The absence of the supported families modifier in the above example indicates to WidgetKit that the widget supports all three sizes. Both examples include a widget entry view.

Widget Entry View

The widget entry view is simply a SwiftUI View declaration containing the layout to be displayed by the widget. Conditional logic (for example if or switch statements based on the widgetFamily environment property) can be used to present different layouts subject to the prevailing size family.

With the exception of tapping to open the corresponding app, widgets are non-interactive. As such, the entry view will typically consist of display-only views (in other words no buttons, sliders or toggles).

When WidgetKit creates an instance of the entry view, it passes it a widget timeline entry containing the data to be displayed on the views that make up the layout. The following view declaration is designed to display city name and temperature values:

struct SimpleWidgetEntryView : View {
    var entry: Provider.Entry
 
    var body: some View {
        VStack {
            Text("City: \(entry.city)")
            Text("Tempurature: \(entry.temperature)")
        }
    }
}

The purpose of a widget is to display different information at specific points in time. The widget for a calendar app, for example, might change throughout the day to display the user’s next upcoming appointment. The content to be displayed at each point in the timeline is contained within widget entry objects conforming to the TimelineEntry protocol. Each entry must, at a minimum, include a Date object defining the point in the timeline at which the data in the entry is to be displayed, together with any data that is needed to fully populate the widget entry view at the specified time. The following is an example of a timeline entry declaration designed for use with the above entry view:

struct WeatherEntry: TimelineEntry {
    var date: Date
    let city: String
    let temperature: Int
}

If necessary, the Date object can simply be a placeholder to be updated with the actual time and date when the entry is added to a timeline.

Widget Timeline

The widget timeline is simply an array of widget entries that defines the points in time that the widget is to be updated, together with the content to be displayed at each time point. Timelines are constructed and returned to WidgetKit by a widget provider.

Widget Provider

The widget provider is responsible for providing the content that is to be displayed on the widget and must be implemented to conform to the TimelineProvider protocol. At a minimum, it must implement the following methods:

  • getSnapshot() – The getSnapshot() method of the provider will be called by WidgetKit when a single, populated widget timeline entry is required. This snapshot is used within the widget gallery to show an example of how the widget would appear if the user added it to the device. Since real data may not be available at the point that the user is browsing the widget gallery, the entry returned should typically be populated with sample data.
  • getTimeline() – This method is responsible for assembling and returning a Timeline instance containing the array of widget timeline entries that define how and when the widget content is to be updated together with an optional reload policy value.

The following code excerpt declares an example timeline provider:

struct Provider: TimelineProvider {
    public typealias Entry = SimpleEntry
 
    public func getSnapshot(with context: Context, 
              completion: @escaping (SimpleEntry) -> ()) {
 
        // Construct a single entry using sample content
        let entry = SimpleEntry(date: Date(), city: "London", 
                               temperature: 89)
        completion(entry)
    }
 
    public func getTimeline(with context: Context, 
                   completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
 
        // Construct timeline array here
 
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

Reload Policy

When a widget is displaying entries from a timeline, WidgetKit needs to know what action to take when it reaches the end of the timeline. The following predefined reload policy options are available for use when the provider returns a timeline:

  • atEnd – At the end of the current timeline, WidgetKit will request a new timeline from the provider. This is the default behavior if no reload policy is specified.
  • after(Date) – WidgetKit will request a new timeline after the specified date and time.
  • never – The timeline is not reloaded at the end of the timeline.

Relevance

As previously mentioned, iOS allows widgets to be placed in a stack in which only the uppermost widget is visible. While the user can scroll through the stacked widgets to decide which is to occupy the topmost position, this presents the risk that an important update may not be seen by the user in time to act on the information.

To address this issue, WidgetKit is allowed to move a widget to the top of the stack if the information it contains is considered to be of relevance to the user. This decision is based on a variety of factors such as previous behavior of the user (for example checking a bus schedule widget at the same time every day) together with a relevance score assigned by the widget to a particular timeline entry.

Relevance is declared using a TimelineEntryRelevance structure. This contains a relevancy score and a time duration for which the entry is relevant. The score can be any floating point value and is measured relative to all other timeline entries generated by the widget. For example, if most of the relevancy scores in the timeline entries range between 0.0 and 10.0, a relevancy score of 20.0 assigned to an entry may cause the widget to move to the top of the stack. The following code declares two relevancy entries:

let lowScore = TimelineEntryRelevance(score: 0.0, duration: 0)
let highScore = TimelineEntryRelevance(score: 10.0, duration: 0)

If a relevancy is to be included in an entry, it must appear after the date entry, for example:

struct WeatherEntry: TimelineEntry {
    var date: Date
    var relevance: TimelineEntryRelevance?
    let city: String
    let temperature: Int
}
.
.
let entry1 = WeatherEntry(date: Date(), relevance: lowScore, city: "London", temperature: 87)
 
let entry2 = WeatherEntry(date: Date(), relevance: highScore, city: "London", temperature: 87)

Forcing a Timeline Reload

When a widget is launched, WidgetKit requests a timeline containing the timepoints and content to display to the user. Under normal conditions, WidgetKit will not request another timeline update until the end of the timeline is reached, and then only if required by the reload policy.

Situations may commonly arise, however, where the information in a timeline needs to be updated. A user might, for example, add a new appointment to a calendar app which requires a timeline update. Fortunately, the widget can be forced to request an updated timeline by making a call to the reloadTimelines() method of the WidgetKit WidgetCenter instance, passing through the widget’s kind string value (defined in the widget configuration as outlined earlier in the chapter). For example:

WidgetCenter.shared.reloadTimelines(ofKind: "My Kind")

Alternatively, it is also possible to trigger a timeline reload for all the active widgets associated with an app as follows:

WidgetCenter.shared.reloadAllTimelines()

Widget Sizes

As previously discussed, widgets can be displayed in small, medium and large sizes. The widget declares which sizes it supports by applying the supportedFamilies() modifier to the widget configuration as follows:

@main
struct SimpleWidget: Widget {
    private let kind: String = "SimpleWidget"
 
    public var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, 
             intent: LocationSelectionIntent.self, provider: Provider(), 
               placeholder: PlaceholderView()) { entry in
            SimpleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Weather Fun")
        .description("Learning about weather in real-time.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

The following figure shows the built-in iOS Calendar widget in small, medium and large formats:

Figure 47-1

Widget Placeholder

As previously mentioned, the widget extension must provide a placeholder. This is the view which is displayed to the user while the widget is initializing and takes the form of the widget entry view without any data or information. Consider the following example widget:

Figure 47-2

The above example, of course, shows the widget running after it has received timeline data to be displayed. During initialization, however, the placeholder view resembling Figure 47-3 would be expected to be displayed:

Figure 47-3

Fortunately, SwiftUI includes the redacted(reason:) modifier which may be applied to an instance of the widget entry view to act as a placeholder. The following is an example of a placeholder view declaration for a widget extension using the redacted() modifier (note that the reason is set to placeholder):

struct PlaceholderView : View {
    var body: some View {
        SimpleWidgetEntryView()
            .redacted(reason: .placeholder)
    }
}

Summary

Introduced in iOS 14, widgets allow apps to present important information to the user directly on the device home screen without the need to launch the app. Widgets are implemented using the WidgetKit Framework and take the form of extensions added to the main app. Widgets are driven by timelines which control the information to be displayed to the user and when it is to appear. Widgets can support small, medium and large formats and may be designed to be configurable by the user. When adding widgets to the home screen, the user has the option to place them into a stack. By adjusting the relevance of a timeline entry, a widget can increase the chances of being moved to the top of the stack.

A SwiftUI Siri Shortcut Tutorial

As previously discussed, the purpose of Siri Shortcuts is to allow key features of an app to be invoked by the user via Siri by speaking custom phrases. This chapter will demonstrate how to integrate shortcut support into an existing iOS app, including the creation of a custom intent and intent UI, the configuration of a SiriKit Intent Definition file and outline the code necessary to handle the intents, provide responses and donate shortcuts to Siri.

About the Example App

The project used as the basis for this tutorial is an app which simulates the purchasing of financial stocks and shares. The app is named ShortcutDemo and can be found in the sample code download available at the following URL:

https://www.ebookfrenzy.com/code/SwiftUI-iOS14-CodeSamples.zip

The app consists of a “Buy” screen into which the stock symbol and quantity are entered and the purchase initiated, and a “History” screen consisting of a List view listing all previous transactions. Selecting an entry in the transaction history displays a third screen containing details about the corresponding stock purchase.

App Groups and UserDefaults

Much about the way in which the app has been constructed will be familiar from techniques outlined in previous chapters of the book. The project also makes use of app storage in the form of UserDefaults and the @AppStorage property wrapper, concepts which were introduced in the chapter entitled SwiftUI Data Persistence using AppStorage and SceneStorage. The ShortcutDemo app uses app storage to store an array of objects containing the symbol, quantity and time stamp data for all stock purchase transactions. Since this is a test app that needs to store minimal amounts of data, this storage is more than adequate. In a real-world environment, however, a storage system capable of handling larger volumes of data such as SQLite, CoreData or iCloud storage would need to be used.

In order to share the UserDefaults data between the app and the SiriKit intents extension, the project also makes use of App Groups. App Groups allow apps to share data with other apps and targets within the same app group. App Groups are assigned a name (typically similar to group.com.yourdomain.myappname) and are enabled and configured within the Xcode project Signing & Capabilities screen.

Preparing the Project

Once the ShortcutDemo project has been downloaded and opened within Xcode, some configuration changes need to be made before the app can be compiled and run. Begin by selecting the ShortcutDemo target at the top of the project navigator panel (marked A in Figure 46-1) followed by the ShortcutDemo (iOS) entry in the Targets list (B). Select the Signing & Capabilities tab (C) and choose your developer ID from the Team menu in the Signing section (D):

Figure 46-1

Next, click the “+ Capabilities” button (E) and double-click on the App Groups entry in the resulting dialog to add the capability to the project. Once added, click on the ‘+’ button located beneath the list of App Groups (as indicated in Figure 46-2):

Figure 46-2

In the resulting panel, provide a name for the app group container that will be unique to your project (for example group.com.<your domain name>.shortcutdemo). Once a name has been entered, click on the OK button to add it to the project entitlements file (ShortcutDemo.entitlements) and make sure that its toggle button is enabled.

Now that the App Group container has been created, the name needs to be referenced in the project code. Edit the PurchaseStore.swift file and replace the placeholder text in the following line of code with your own App Group name:

@AppStorage("demostorage", store: UserDefaults(
    suiteName: "YOUR APP GROUP NAME HERE")) var store: Data = Data()

Running the App

Run the app on a device or simulator and enter a stock symbol and quantity (for example 100 shares of TSLA and 20 GE shares) and click on the Purchase button. Assuming the transaction is successful, select the History tab at the bottom of the screen and confirm that the transactions appear in the list as shown in Figure 46-3:

Figure 46-3

If the purchased stocks do not appear in the list, switch between the Buy and History screens once more at which point the items should appear (this is a bug in SwiftUI which has been reported to Apple but not yet fixed). Select a transaction from the list to display the Detail screen for that purchase:

Figure 46-4

With the app installed, configured and running, the next step is to begin integrating shortcut support into the project.

Enabling Siri Support

To add the Siri entitlement, return to the Signing & Capabilities screen, click on the “+ Capability” button to display the capability selection dialog, enter Siri into the filter bar and double-click on the result to add the capability to the project.

Seeking Siri Authorization

In addition to enabling the Siri entitlement, the app must also seek authorization from the user to integrate the app with Siri. This is a two-step process which begins with the addition of an entry to the Info.plist file of the iOS app target for the NSSiriUsageDescription key with a corresponding string value explaining how the app makes use of Siri.

Select the Info.plist file located within the iOS folder in the project navigator panel as shown in Figure 46-5:

Figure 46-5

Once the file is loaded into the editor, locate the bottom entry in the list of properties and hover the mouse pointer over the item. When the plus button appears, click on it to add a new entry to the list. From within the drop-down list of available keys, locate and select the Privacy – Siri Usage Description option as shown in Figure 46-6:

Figure 46-6

Within the value field for the property, enter a message to display to the user when requesting permission to use speech recognition. For example:

Siri support is used to suggest shortcuts

In addition to adding the Siri usage description key, a call also needs to be made to the requestSiriAuthorization class method of the INPreferences class. Ideally, this call should be made the first time that the app runs, not only so that authorization can be obtained, but also so that the user learns that the app includes Siri support. For the purposes of this project, the call will be made within the onChange() modifier based on the scenePhase changes within the app declaration located in the ShortcutDemoApp.swift file as follows:

import SwiftUI
import Intents
 
@main
struct ShortcutDemoApp: App {
    
    @Environment(\.scenePhase) private var scenePhase
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: scenePhase) { phase in
                 INPreferences.requestSiriAuthorization({status in
                 // Handle errors here
             })
         }
    }
}

Before proceeding, compile and run the app on an iOS device or simulator. When the app loads, a dialog will appear requesting authorization to use Siri. Select the OK button in the dialog to provide authorization.

Adding the Intents Extension

To add shortcut support, an intents extension will be needed for the Siri shortcuts associated with this app. Select the File -> New -> Target… menu option, followed by the Intents Extension option and click on the Next button. On the options screen, enter ShortcutDemoIntent into the product name field, change the Starting Point to None and make sure that the Include UI Extension option is enabled before clicking on the Finish button:

Figure 46-7

If prompted to do so, activate the ShortcutDemoIntent target scheme.

Adding the SiriKit Intent Definition File

Now that the intent extension has been added to the project, the SiriKit Intent Definition file needs to be added so that the intent can be configured. Right-click on the Shared folder in the project navigator panel and select New File… from the menu. In the template selection dialog scroll down to the Resource section and select the SiriKit Intent Definition File template followed by the Next button:

Figure 46-8

Keep the default name of Intents in the Save As: field, but make sure that the file is available to all of the targets in the project by enabling all of the options in the Targets section of the dialog before clicking on the Create button:

Figure 46-9

Adding the Intent to the App Group

The purchase history data will be shared between the main app and the intent using app storage. This requires that the App Group capability be added to the ShortcutDemoIntent target and enabled for the same container name as that used by the ShortcutDemo target. To achieve this, select the ShortcutDemo item at the top of the project navigator panel, switch to the Signing & Capabilities panel, select the ShortcutDemoIntent entry in the list of targets and add the App Group capability. Once added, make sure that the App Group name used by the ShortcutDemo target is selected:

Figure 46-10

Configuring the SiriKit Intent Definition File

Locate the Intents.intentdefinition file and select it to load it into the editor. The file is currently empty, so add a new intent by clicking on the ‘+’ button in the lower left-hand corner of the editor panel and selecting the New Intent menu option:

Figure 46-11

In the Custom Intents panel, rename the new intent to BuyStock as shown in Figure 46-12:

Figure 46-12

Next, change the Category setting in the Custom Intent section of the editor from “Do” to “Buy”, enter “ShortcutDemo” and “Buy stocks and shares” into the Title and Description fields respectively, and enable both the Configurable in Shortcuts and Siri Suggestions options. Since this is a buy category intent, the User confirmation required option is enabled by default and cannot be disabled:

Figure 46-13

Adding Intent Parameters

In order to complete a purchase, the intent is going to need two parameters in the form of the stock symbol and quantity. Remaining within the Intent Definition editor, use the ‘+’ button located beneath the Parameters section to add a parameter named symbol with the type set to String, the display name set to “Symbol”, and both the Configurable and Resolvable options enabled. Within the Siri Dialog section, enter “Specify a stock symbol” into the Prompt field:

Figure 46-14

Repeat the above steps to add a quantity parameter to the intent, setting the prompt to “Specify a quantity to purchase”.

Declaring Shortcut Combinations

A shortcut intent can be configured to handle different combinations of intent parameters. Each unique combination of parameters defines a shortcut combination. For each combination, the intent needs to know the phrase that Siri will speak when interacting with the user which can contain embedded parameters. These need to be configured both for shortcut suggestions and for use within the Shortcuts apps so that the shortcuts can be selected manually by the user. For this example, the only combination required involves both the symbol and quantity which will have been added automatically within the Supported Combinations panel of the Shortcuts app section of the intents configuration editor screen.

Within the Supported Combinations panel, select the symbol, quantity parameter combination and begin typing into the Summary field. Type the word “Buy” followed by the first few letters of the word “quantity”. Xcode will notice that this could be a reference to the quantity parameter name and suggests it as an option to embed the parameter into the phrase as shown in Figure 46-15:

Figure 46-15

Select the parameter from the suggestion to embed it into the phrase, then continue typing so that the message reads “Buy quantity shares of symbol” where “symbol” is also an embedded parameter:

Figure 46-16

These combination settings will have been automatically duplicated under the Suggestions heading. The Supports background execution for suggestions defines whether or not the app can handle the shortcut type in the background without having to be presented to the user for additional input. Make sure this option is enabled for this shortcut combination.

Configuring the Intent Response

The final area to be addressed within the Intent Definition file is the response handling. To view these settings, select the Response entry located beneath the BuyStock intent in the Custom Intents panel:

Figure 46-17

The first task is to declare the parameters that will be included in the response phrases. As with the intent configuration, add both the symbol and quantity parameters configured as Strings.

Next, select the success entry in the response templates code list:

Figure 46-18

Enter the following message into the Voice-Only Dialog field (making sure to insert the parameters for the symbol and quantity using the same technique used above the combination summary settings):

Successfully purchased quantity symbol shares

Repeat this step to add the following template text to the failure code:

Sorry, could not purchase quantity shares of symbol

Behind the scenes, Xcode will take the information provided within the Intent Definition file and automatically generate new classes named BuyStockIntentHandling, BuyStockIntent and BuyStockIntentResponse, all of which will be used in the intent handling code. To make sure these files are generated before editing the code, select the Product -> Clean Builder Folder menu option followed by Product -> Build.

Configuring Target Membership

Many of the classes and targets in the project are interdependent and need to be accessible to each other during both compilation and execution. To allow this access, a number of classes and files within the project need specific target membership settings. While some of these settings will have set up correctly by default, others may need to be set up manually before testing the app. Begin by selecting the IntentHandler.swift file (located in the ShortcutDemoIntent folder) in the project navigator panel and display the File Inspector (View -> Inspectors -> File). In the file inspector panel, locate the Target Membership section and make sure that all targets are enabled as shown in Figure 46-19:

Figure 46-19

Repeat these steps for the Purchase.swift, PurchaseData.swift and PurchaseStore.swift files located in the Shared folder.

Modifying the Intent Handler Code

Now that the intent definition is complete and the classes have been auto-generated by Xcode, the intent handler needs to be modified to implement the BuyStockIntentHandling protocol. Edit the IntentHandler.swift file and make the following changes:

import Intents
 
class IntentHandler: INExtension, BuyStockIntentHandling {
    
    override func handler(for intent: INIntent) -> Any {
 
        guard intent is BuyStockIntent else {
            fatalError("Unknown intent type: \(intent)")
        }
 
        return self
    }
 
    func handle(intent: BuyStockIntent, 
       completion: @escaping (BuyStockIntentResponse) -> Void) {
        
    } 
}

The handler() method simply checks that the intent type is recognized and, if so, returns itself as the intent handler.

Next, add the resolution methods for the two supported parameters:

.
.
    func resolveSymbol(for intent: BuyStockIntent, with completion: @escaping (INStringResolutionResult) -> Void) {
        
        if let symbol = intent.symbol {
            completion(INStringResolutionResult.success(with: symbol))
        } else {
            completion(INStringResolutionResult.needsValue())
        }
    }
    
    func resolveQuantity(for intent: BuyStockIntent, with completion: @escaping (INStringResolutionResult) -> Void) {
        if let quantity = intent.quantity {
            completion(INStringResolutionResult.success(with: quantity))
        } else {
            completion(INStringResolutionResult.needsValue())
        }
    }
.
.

Code now needs to be added to the handle() method to perform the stock purchase. Since this will need access to the user defaults app storage, begin by making the following changes (replacing the placeholder text with your app group name):

import Intents
import SwiftUI
 
class IntentHandler: INExtension, BuyStockIntentHandling {
    
    @AppStorage("demostorage", store: UserDefaults(
      suiteName: "YOUR APP GROUP NAME HERE")) var store: Data = Data()
 
    var purchaseData = PurchaseData()
.
.

Before modifying the handle() method, add the following method to the IntentHandler.swift file which will be called to save the latest purchase to the app storage:

func makePurchase(symbol: String, quantity: String) -> Bool {
    
    var result: Bool = false
    let decoder = JSONDecoder()
    
    if let history = try? decoder.decode(PurchaseData.self, 
                                                 from: store) {
        purchaseData = history   
        result = purchaseData.saveTransaction(symbol: symbol, 
                                            quantity: quantity)
    }
    return result
}

The above method uses a JSON decoder to decode the data contained within the app storage (for a reminder about encoding and decoding app storage data, refer to the chapter entitled “SwiftUI Data Persistence using AppStorage and SceneStorage”). The result of this decoding is a PurchaseData instance, the saveTransaction() method of which is called to save the current purchase. Next, modify the handle() method as follows:

func handle(intent: BuyStockIntent,
   completion: @escaping (BuyStockIntentResponse) -> Void) {
 
    guard let symbol = intent.symbol,
            let quantity = intent.quantity
       else {
            completion(BuyStockIntentResponse(code: .failure,
                    userActivity: nil))
            return
    }
        
    let result = makePurchase(symbol: symbol, quantity: quantity)
        
    if result {
        completion(BuyStockIntentResponse.success(quantity: quantity,
                                symbol: symbol))
    } else {
        completion(BuyStockIntentResponse.failure(quantity: quantity,
                                symbol: symbol))
    }    
}

When called, the method is passed a BuyStockIntent intent instance and completion handler to be called when the purchase is completed. The method begins by extracting the symbol and quantity parameter values from the intent object:

guard let symbol = intent.symbol,
       let quantity = intent.quantity
   else {
       completion(BuyStockIntentResponse(code: .failure,
                    userActivity: nil))
       return
}

These values are then passed through to the makePurchase() method to perform the purchase transaction. Finally, the result returned by the makePurchase() method is used to select the appropriate response to be passed to the completion handler. In each case, the appropriate parameters are passed to the completion handler for inclusion in the response template:

let result = makePurchase(symbol: symbol, quantity: quantity)
    
if result {
    completion(BuyStockIntentResponse.success(quantity: quantity,
                            symbol: symbol))
} else {
    completion(BuyStockIntentResponse.failure(quantity: quantity,
                            symbol: symbol))
}

Adding the Confirm Method

To fully conform with the BuyStockIntentHandling protocol, the IntentHandler class also needs to contain a confirm() method. As outlined in the SiriKit introductory chapter, this method is called by Siri to check that the handler is ready to handle the intent. All that is needed for this example is for the confirm() method to provide Siri with a ready status as follows:

public func confirm(intent: BuyStockIntent, 
    completion: @escaping (BuyStockIntentResponse) -> Void) {
    
    completion(BuyStockIntentResponse(code: .ready, userActivity: nil))
}

Donating Shortcuts to Siri

Each time the user successfully completes a stock purchase within the main app the action needs to be donated to Siri as a potential shortcut. The code to make these donations should now be added to the PurchaseView.swift file in a method named makeDonation(), which also requires that the Intents framework be imported:

import SwiftUI
import Intents
 
struct PurchaseView: View {
.
.
 
        .onAppear() {
            purchaseData.refresh()
          
        }
    }
    
    func makeDonation(symbol: String, quantity: String) {
        let intent = BuyStockIntent()
        
        intent.quantity = quantity
        intent.symbol = symbol
        intent.suggestedInvocationPhrase = "Buy \(quantity) \(symbol)"
        
        let interaction = INInteraction(intent: intent, response: nil)
        
        interaction.donate { (error) in
            if error != nil {
                if let error = error as NSError? {
                    print(
                     "Donation failed: %@" + error.localizedDescription)
                }
            } else {
                print("Successfully donated interaction")
            }
        }
    }
.
.
}

The method is passed string values representing the stock and quantity of the purchase. A new BuyStockIntent instance is then created and populated with both these values and a suggested activation phrase containing both the quantity and symbol. Next, an INInteraction object is created using the BuyStockIntent instance and the donate() method of the object called to make the donation. The success or otherwise of the donation is then output to the console for diagnostic purposes.

The donation will only be made after a successful purchase has been completed, so add the call to makeDonation() after the saveTransaction() call in the buyStock() method:

private func buyStock() {
    if (symbol == "" || quantity == "") {
        status = "Please enter a symbol and quantity"
    } else {
        if purchaseData.saveTransaction(symbol: symbol, 
                                           quantity: quantity) {
            status = "Purchase completed"
            makeDonation(symbol: symbol, quantity: quantity)
        }
    }
}

Testing the Shortcuts

Before running and testing the app, some settings on the target device or simulator need to be changed in order to be able to fully test the shortcut functionality. To enable these settings, open the Settings app on the device or simulator on which you intend to test the app, select the Developer option and locate and enable the Display Recent Shortcuts and Display Donations on Lock Screen options as shown in Figure 46-20:

Figure 46-20

These settings will ensure that newly donated shortcuts always appear in Siri search and on the lock screen rather than relying on Siri to predict when the shortcuts should be suggested to the user.

With the settings changed, run the ShortcutDemo app target and make a stock purchase (for example buy 75 IBM shares). After the purchase is complete, check the Xcode console to verify that the “Successfully donated interaction” message appeared.

Next, locate the built-in iOS Shortcuts app on the device home screen as highlighted in Figure 46-21 below and tap to launch it:

Figure 46-21

Within the Shortcuts app, select the Gallery tab where the donated shortcut should appear as shown in Figure 46-22 below:

Figure 46-22

Click on the ‘+’ button to the right of the shortcut title to display the Add to Siri screen (Figure 46-23). Note that “Buy 75 IBM” is suggested as configured when the donation was made in the makeDonation() method. To change the phrase, delete the current “When I say” setting and enter a different phrase:

Figure 46-23

Click the Add to Siri button to add the shortcut and return to the Gallery screen. Select the My Shortcuts tab and verify that the new shortcut has been added:

Figure 46-24

Press and hold the Home button to launch Siri and speak the shortcut phrase. Siri will seek confirmation that the purchase is to be made. After completing the purchase, Siri will use the success response template declared in the Intent Definition file to confirm that the transaction was successful.

After making a purchase using the shortcut, open the ShortcutDemo app and verify that the transaction appears in the transaction history (keeping in mind that it may be necessary to switch between the Buy and History screens before the purchase appears due to the previously mentioned SwiftUI bug).

Designing the Intent UI

When the shortcut was tested, the intent UI will have appeared as a large empty space. Clearly some additional steps are required before the shortcut is complete. Begin by selecting the MainInterface.storyboard file located in the ShortcutDemoIntentUI folder in the project navigator so that it loads into Interface Builder.

Add a Label to the layout by clicking on the button marked A in Figure 46-25 below and dragging and dropping a Label object from the Library (B) onto the layout canvas as indicated by the arrow:

Figure 46-25

Next, the Label needs to be constrained so that it has a 5dp margin between the leading, trailing and top edges of the parent view. With the Label selected in the canvas, click on the Add New Constraints button located in the bottom right-hand corner of the editor to display the menu shown in Figure 46-26 below:

Figure 46-26

Enter 5 into the top, left and right boxes and click on the I-beam icons next to each value so that they are displayed in solid red instead of dashed lines before clicking on the Add 3 Constraints button.

Before proceeding to the next step, establish an outlet connection from the Label component to a variable in the IntentViewController.swift file named contentLabel. This will allow the view controller to change the text displayed on the Label to reflect the intent content parameter. This is achieved using the Assistant Editor which is displayed by selecting the Xcode Editor -> Assistant menu option. Once displayed, Ctrl-click on the Label in the canvas and drag the resulting line to a position in the Assistant Editor immediately above the viewDidLoad() method declaration:

Figure 46-27

On releasing the line, the dialog shown in Figure 46-28 will appear. Enter contentLabel into the Name field before clicking on Connect to establish the outlet.

Figure 46-28

On completion of these steps, the outlets should appear in the IntentViewController.swift file as follows:

class IntentViewController: UIViewController, INUIHostedViewControlling {
    
    @IBOutlet weak var contentLabel: UILabel!
.
.

Edit the IntentViewController.swift file and modify the configureView() method and declaredSize variable so that the code reads as follows:

func configureView(for parameters: Set<INParameter>, 
  of interaction: INInteraction, 
  interactiveBehavior: INUIInteractiveBehavior, 
  context: INUIHostedViewContext, 
  completion: @escaping (Bool, Set<INParameter>, CGSize) -> Void) {
    
    guard let intent = interaction.intent as? BuyStockIntent else {
        completion(false, Set(), .zero)
        return
    }
    
    if let symbol = intent.symbol, let quantity = intent.quantity {
        self.contentLabel.text = "Buy \(quantity) \(symbol) shares?"
    }
    
    completion(true, parameters, self.desiredSize)
}
 
var desiredSize: CGSize {
    return CGSize.init(width: 10, height: 100)
}

Re-build and run the app, then use Siri to trigger the shortcut. This time the intent UI will contain text describing the purchase to be made:

Figure 46-29

Summary

This chapter has provided a practical demonstration of how to integrate Siri shortcut support into a SwiftUI app. This included the creation and configuration of an Intent Definition file, the addition of a custom intent extension and the implementation of intent handling code.