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.

1.1 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 as an entire scene (occupying the full screen) or treated 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.

1.2 A UIHostingController Example Project

Launch Xcode and create a new iOS Single View App project named HostingControllerDemo using Swift as the programming language and with the User Interface option set to Storyboard.

1.3 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 selecting the SwiftUI View template option from the resulting dialog. Proceed through the file creation process, 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.

1.4 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 34‑1:

Figure 34‑1

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 34‑2:

Figure 34‑2

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 34‑3 and locate and drag a Button view onto the view controller scene canvas:

Figure 34‑3

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 34‑4 to display the menu and select the Reset to Suggested Constraints option to add any missing constraints to the button widget:

Figure 34‑4

1.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 34‑5 below:

Figure 34‑5

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

Figure 34‑6

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.

1.6 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 34‑7:

Figure 34‑7

From the resulting menu (Figure 34‑8), select the ViewController.swift file to load it into the editor:

Figure 34‑8

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 34‑9:

Figure 34‑9

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

Figure 34‑10

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 34‑11:

Figure 34‑11

1.7 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 34‑12:

Figure 34‑12

Before proceeding, click on the background of the view controller scene before using the Resolve Auto Layout Issues button indicated in Figure 34‑4 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 34‑13

Once the Container View has been embedded in the hosting controller, the storyboard should resemble Figure 34‑14:

Figure 34‑14

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 34‑15

1.8 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 34‑16:

Figure 34‑16

1.9 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.

1.1 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.

1.2 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 Single View App SwiftUI project named ViewControllerDemo.

1.3 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 ViewControllerDemo item at the top of 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()
    }
}

Click on the live preview button in the canvas to test that the image picker appears as shown in Figure 33‑1 below:

Figure 33‑1

1.4 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 33‑2:

Figure 33‑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.

1.5 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()

    }

}
*/

1.6 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)
        }

1.7 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 33‑3

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

1.8 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 many instances where there is no SwiftUI equivalent to options provided by the other frameworks. There is, for example, no SwiftUI equivalent to the MKMapView or WebView classes provided by the MapKit and WebView frameworks, or the powerful animation features of UIKit Dynamics.

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 a number of options to perform this type of integration.

1.1 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.

1.2 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 events as a result of user interaction. For views that need to respond to events, however, the UIViewRepresentable wrapper needs to be extended to implement a coordinator.

1.3 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
}

1.4 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.

1.5 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 Single View App project named UIViewDemo.

1.6 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 UIViewDemo 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")
    }
}

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 32‑1:

Figure 32‑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.

1.7 Implementing the Coordinator

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

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()
        }
    }
}

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.

1.8 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.

1.9 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.

Working with Gesture Recognizers in SwiftUI

The term gesture is used to describe an interaction between the touch screen and the user which can be detected and used to trigger an event in the app. Drags, taps, double taps, pinching, rotation motions and long presses are all considered to be gestures in SwiftUI.

The goal of this chapter is to explore the use of SwiftUI gesture recognizers within a SwiftUI based app.

1.1 Creating the GestureDemo Example Project

To try out the examples in this chapter, create a new Single View App Xcode project named GestureDemo with SwiftUI enabled.

1.2 Basic Gestures

Gestures performed within the bounds of a view can be detected by adding a gesture recognizer to that view. SwiftUI provides detectors for tap, long press, rotation, magnification (pinch) and drag gestures.

A gesture recognizer is added to a view using the gesture() modifier, passing through the gesture recognizer to be added.

In the simplest form, a recognizer will include one or more action callbacks containing the code to be executed when a matching gesture is detected on the view. The following example adds a tap gesture detector to an Image view and implements the onEnded callback containing the code to be performed when the gesture is completed successfully:

struct ContentView: View {

    var body: some View {
        Image(systemName: "hand.point.right.fill")
            .gesture(
                TapGesture()
                    .onEnded { _ in
                        print("Tapped")
                    }
            )
    }
}

Using live preview in debug mode, test the above view declaration, noting the appearance of the “Tapped” message in the debug console panel when the image is clicked.

When working with gesture recognizers, it is usually preferable to assign the recognizer to a variable and then reference that variable in the modifier. This makes for tidier view body declarations and encourages reuse:

var body: some View {

    let tap = TapGesture()
                .onEnded { _ in
                print("Tapped")
              }

    return Image(systemName: "hand.point.right.fill")
        .gesture(tap)
}

When using the tap gesture recognizer, the number of taps required to complete the gesture may also be specified. The following, for example, will only detect double taps:

let tap = TapGesture(count: 2)
                .onEnded { _ in
                print("Tapped")
              }

The long press gesture recognizer is used in a similar way and is designed to detect when a view is touched for an extended length of time. The following declaration detects when a long press is performed on an Image view using the default time duration:

var body: some View {

    let longPress = LongPressGesture()
        .onEnded { _ in
            print("Long Press")
        }

    return Image(systemName: "hand.point.right.fill")
        .gesture(longPress)
}

To adjust the duration necessary to qualify as a long press, simply pass through a minimum duration value (in seconds) to the LongPressGesture() call. It is also possible to specify a maximum distance from the view from which the point of contact with the screen can move outside of the view during the long press. If the touch moves beyond the specified distance, the gesture will cancel and the onEnded action will not be called:

let tap = LongPressGesture(minimumDuration: 10, 
                                    maximumDistance: 25)

    .onEnded { _ in
        print("Long Press")
    }

A gesture recognizer can be removed from a view by passing a nil value to the gesture() modifier:

.gesture(nil) 

1.3 The onChange Action Callback

In the previous examples, the OnEnded action closure was used to detect when a gesture completes. Many of the gesture recognizers (except for TapGesture) also allow the addition of an onChange action callback. The onChange callback will be called when the gesture is first recognized, and each time the underlying values of the gesture change, up until the point that the gesture ends.

The onChange action callback is particularly useful when used with gestures involving motion across the device display (as opposed to taps and long presses). The magnification gesture, for example, can be used to detect the movement of touches on the screen.

struct ContentView: View {

    var body: some View {
        let magnificationGesture = 
                  MagnificationGesture(minimumScaleDelta: 0)

           .onEnded { _ in
               print("Gesture Ended")
           }

        return Image(systemName: "hand.point.right.fill")
            .resizable()
            .font(.largeTitle)
            .gesture(magnificationGesture)
            .frame(width: 100, height: 90)
    }
}

The above implementation will detect a pinching motion performed over the Image view but will only report the detection after the gesture ends. Within the preview canvas, pinch gestures can be simulated by holding down the keyboard Option key while clicking in the Image view and dragging.

To receive notifications for the duration of the gesture, the onChanged callback action can be added:

let magnificationGesture = 
                  MagnificationGesture(minimumScaleDelta: 0)
    .onChanged( { _ in
        print("Magnifying")
    })
    .onEnded { _ in
        print("Gesture Ended")
    }

Now when the gesture is detected, the onChanged action will be called each time the values associated with the pinch operation change. Each time the onChanged action is called, it will be passed a MagnificationGesture.Value instance which contains a CGFloat value representing the current scale of the magnification.

With access to this information about the magnification gesture scale, interesting effects can be implemented such as configuring the Image view to resize in response to the gesture:

struct ContentView: View {

    @State private var magnification: CGFloat = 1.0

    var body: some View {
        let magnificationGesture = 
                MagnificationGesture(minimumScaleDelta: 0)
            .onChanged({ value in
                self.magnification = value
            })
            .onEnded({ _ in
                print("Gesture Ended")
            })

        return Image(systemName: "hand.point.right.fill")
            .resizable()
            .font(.largeTitle)
            .scaleEffect(magnification)
            .gesture(magnificationGesture)
            .frame(width: 100, height: 90)
    }
}

1.4 The updating Callback Action

The updating callback action is like onChanged with the exception that it works with a special property wrapper named @GestureState. GestureState is like the standard @State property wrapper but is designed exclusively for use with gestures. The key difference, however, is that @GestureState properties automatically reset to the original state when the gesture ends. As such, the updating callback is ideal for storing transient state that is only needed while a gesture is being performed.

Each time an updating action is called, it is passed the following three arguments:

  • DragGesture.Value instance containing information about the gesture.
  • A reference to the @GestureState property to which the gesture has been bound.
  • A Transaction object containing the current state of the animation corresponding to the gesture.

The DragGesture.Value instance is particularly useful and contains the following properties:

  • location (CGPoint) – The current location of the drag gesture.
  • predictedEndLocation (CGPoint) – Predicted final location, based on the velocity of the drag if dragging stops.
  • predictedEndTranslation (CGSize) – A prediction of what the final translation would be if dragging stopped now based on the current drag velocity.
  • startLocation (CGPoint) – The location at which the drag gesture started.
  • time (Date) – The time stamp of the current drag event.
  • translation (CGSize) – The total translation from the start of the drag gesture to the current event (essentially the offset from the start position to the current drag location).

Typically, a drag gesture updating callback will extract the translation value from the DragGesture.Value object and assign it to a @GestureState property and will typically resemble the following:

let drag = DragGesture()
    .updating($offset) { dragValue, state, transaction in
        state = dragValue.translation
    }

The following example adds a drag gesture to an Image view and then uses the updating callback to keep a @GestureState property updated with the current translation value. An offset() modifier is applied to the Image view using the @GestureState offset property. This has the effect of making the Image view follow the drag gesture as it moves across the screen.

If it is not possible to drag the image this may be because of a problem with the live view in the current Xcode 11 release. The example should work if tested on a simulator or physical device. Note that once the drag gesture ends, the Image view returns to the original location. This is because the offset gesture property was automatically reverted to its original state when the drag ended.

1.5 Composing Gestures

So far in this chapter we have looked at adding a single gesture recognizer to a view in SwiftUI. Though a less common requirement, it is also possible to combine multiple gestures and apply them to a view. Gestures can be combined so that they are detected simultaneously, in sequence or exclusively. When gestures are composed simultaneously, both gestures must be detected at the same time for the corresponding action to be performed. In the case if sequential gestures, the first gestures must be completed before the second gesture will be detected. For exclusive gestures, the detection of one gesture will be treated as all gestures being detected.

Gestures are composed using the simultaneously(), sequenced() and exclusively() modifiers. The following view declaration, for example, composes a simultaneous gesture consisting of a long press and a drag:

struct ContentView: View {

    @GestureState private var offset: CGSize = .zero
    @GestureState private var longPress: Bool = false

    var body: some View {
        let longPressAndDrag = LongPressGesture(minimumDuration: 1.0)
            .updating($longPress) { value, state, transition in
                state = value
            }
            .simultaneously(with: DragGesture())
            .updating($offset) { value, state, transaction in
                state = value.second?.translation ?? .zero
             }

            return Image(systemName: "hand.point.right.fill")
                .foregroundColor(longPress ? Color.red : Color.blue)
                .font(.largeTitle)
                .offset(offset)
                .gesture(longPressAndDrag)
    }
}

In the case of the following view declaration, a sequential gesture is configured which requires the long press gesture to be completed before the drag operation can begin. When executed, the user will perform a long press on the image until it turns green, at which point the drag gesture can be used to move the image around the screen.

struct ContentView: View {

    @GestureState private var offset: CGSize = .zero
    @State private var dragEnabled: Bool = false

    var body: some View {
        let longPressBeforeDrag = LongPressGesture(minimumDuration: 2.0)
            .onEnded( { _ in
                self.dragEnabled = true
            })
            .sequenced(before: DragGesture())
            .updating($offset) { value, state, transaction in
                switch value {
                    case .first(true):
                        print("Long press in progress")
                    case .second(true, let drag):
                        state = drag?.translation ?? .zero
                    default: break
                }
            }

            .onEnded { value in
                self.dragEnabled = false
            }

            return Image(systemName: "hand.point.right.fill")
                .foregroundColor(dragEnabled ? Color.green : Color.blue)
                .font(.largeTitle)
                .offset(offset)
                .gesture(longPressBeforeDrag)
    }
}

1.6 Summary

Gesture detection can be added to SwiftUI views using gesture recognizers. SwiftUI includes recognizers for drag, pinch, rotate, long press and tap gestures. Gesture detection notification can be received from the recognizers by implementing onEnded, updated and onChange callback methods. The updating callback works with a special property wrapper named @GestureState. A GestureState property is like the standard state property wrapper but is designed exclusively for use with gestures and automatically resets to its original state when the gesture ends. Gesture recognizers may be combined so that they are recognized simultaneously, sequentially or exclusively.

SwiftUI Animation and Transitions

This chapter is intended to provide an overview and examples of animating views and implementing transitions within a SwiftUI app. Animation can take a variety of forms including the rotation, scaling and motion of a view on the screen.

Transitions, on the other hand, define how a view will appear as it is added to or removed from a layout, for example whether a view slides into place when it is added, or shrinks from view when it is removed.

1.1 Creating the AnimationDemo Example Project

To try out the examples in this chapter, create a new Single View App Xcode project named AnimationDemo with SwiftUI enabled.

1.2 Implicit Animation

Many of the built-in view types included with SwiftUI contain properties that control the appearance of the view such as scale, opacity, color and rotation angle. Properties of this type are animatable, in that the change from one property state to another can be animated instead of occurring instantly. One way to animate these changes to a view is to use the .animation() modifier (a concept referred to as implicit animation because the animation is implied for any modifiers applied to the view that precede the animation modifier).

To experience basic animation using this technique, modify the ContentView.swift file in the AnimationDemo project so that it contains a Button view configured to rotate in 60 degree increments each time it is tapped:

struct ContentView : View {

    @State private var rotation: Double = 0

    var body: some View {
       Button(action: {
           self.rotation = 
                  (self.rotation < 360 ? self.rotation + 60 : 0)
           }) {
           Text("Click to animate")
               .rotationEffect(.degrees(rotation))
       }
    }
}

When tested using live preview, each click causes the Button view to rotate as expected, but the rotation is immediate. Similarly, when the rotation reaches a full 360 degrees, the view actually rotates counter-clockwise 360 degrees, but so quickly the effect is not visible. These effects can be slowed down and smoothed out by adding the animation() modifier with an optional animation curve to control the timing of the animation:

var body: some View {

   Button(action: {
       self.rotation = 
              (self.rotation < 360 ? self.rotation + 60 : 0)
   }) {    
       Text("Click to Animate")
           .rotationEffect(.degrees(rotation))
           .animation(.linear)
   }
}

The optional animation curve defines the linearity of the animation timeline. This setting controls whether the animation is performed at a constant speed or whether it starts out slow and speeds up. SwiftUI provides the following basic animation curves:

  • .linear – The animation is performed at constant speed for the specified duration and is the option declared in the above code example.
  • .easeOut – The animation starts out fast and slows as the end of the sequence approaches.
  • .easeIn – The animation sequence starts out slow and speeds up as the end approaches.
  • .easeInOut – The animation starts slow, speeds up and then slows down again.

Preview the animation once again and note that the rotation now animates smoothly. When defining an animation, the duration may also be specified. Change the animation modifier so that it reads as follows:

.animation(.linear(duration: 1))

Now the animation will be performed more slowly each time the Button is clicked.

As previously mentioned, an animation can apply to more than one modifier. The following changes, for example, animate both rotation and scaling effects:

.

.

@State private var scale: CGFloat = 1

var body: some View {
  
   Button(action: {
        self.rotation = 
               (self.rotation < 360 ? self.rotation + 60 : 0)
        self.scale = (self.scale < 2.8 ? self.scale + 0.3 : 1)
   }) {
       Text("Click to Animate")
        .scaleEffect(scale)
        .rotationEffect(.degrees(rotation))
        .animation(.linear(duration: 1))
   }
}

These changes will cause the button to increase in size with each rotation, then scale back to its original size during the return rotation.

Figure 30‑1

A variety of spring effects may also be added to the animation using the spring() modifier, for example:

Text("Click to Animate")
    .scaleEffect(scale)
    .rotationEffect(.degrees(rotation))
    .animation(.spring(response: 1, dampingFraction: 0.2, blendDuration: 0))

This will cause the rotation and scale effects to go slightly beyond the designated setting, then bounce back and forth before coming to rest at the target angle and scale.

When working with the animation() modifier, it is important to be aware that the animation is only implicit for modifiers that are applied before the animation modifier itself. In the following implementation, for example, only the rotation effect is animated since the scale effect is applied after the animation modifier:

Text("Click to Animate")
    .rotationEffect(.degrees(rotation))
    .animation(.spring(response: 1, dampingFraction: 0.2, blendDuration: 0))
    .scaleEffect(scale)

1.3 Repeating an Animation

By default, an animation will be performed once each time it is initiated. An animation may, however, be configured to repeat one or more times. In the following example, the animation is configured to repeat a specific number of times:

.animation(Animation.linear(duration: 1).repeatCount(10))

Each time an animation repeats, it will perform the animation in reverse as the view returns to its original state. If the view is required to instantly revert to its original appearance before repeating the animation, the autoreverses parameter must be set to false:

.animation(Animation.linear(duration: 1).repeatCount(10, 
       autoreverses: false))

An animation may also be configured to repeat indefinitely using the repeatForever() modifier as follows:

.repeatForever(autoreverses: true))

1.4 Explicit Animation

As previously discussed, implicit animation using the animation() modifier implements animation on any of the animatable properties on a view that appear before the animation modifier. SwiftUI provides an alternative approach referred to as explicit animation which is implemented using the withAnimation() closure. When using explicit animation, only the property changes that take place within the withAnimation() closure will be animated. To experience this in action, modify the example so that the rotation effect is performed within a withAnimation() closure and remove the animation() modifier:

var body: some View {

    Button(action: { withAnimation(.linear (duration: 2)) {
            self.rotation =
               (self.rotation < 360 ? self.rotation + 60 : 0)
        }
        self.scale = (self.scale < 2.8 ? self.scale + 0.3 : 1) 
       }) {

       Text("Click to Animate")
          .rotationEffect(.degrees(rotation))
          .scaleEffect(scale)
    }
}

With the changes made, preview the layout and note that only the rotation is now animated. By using explicit animation, animation can be limited to specific properties of a view without having to worry about the ordering of modifiers.

1.5 Animation and State Bindings

Animations may also be applied to state property bindings such that any view changes that occur as a result of that state value changing will be animated. If the state of a Toggle view causes one or more other views to become visible to the user, for example, applying an animation to the binding will cause the appearance and disappearance of all those views to be animated.

Within the ContentView.swift file, implement the following layout which consists of a VStack, Toggle view and two Text views. The Toggle view is bound to a state property named visible, the value of which is used to control which of the two Text views is visible at one time:

.
.
@State private var visibility = false

var body: some View {

   VStack {
        Toggle(isOn: $visibility) {
           Text("Toggle Text Views")
        }
        .padding()

        if visibility {
            Text("Hello World")
                .font(.largeTitle)
        }

        if !visibility {
            Text("Goodbye World")
                .font(.largeTitle)
        }
    }
}
.
.

When previewed, switching the toggle on and off will cause one or other of the Text views to appear instantly. To add an amination to this change, simply apply a modifier to the state binding as follows:

.
.
var body: some View {

   VStack {
       Toggle(isOn: $visibility.animation(.linear(duration: 5))) {
           Text("Toggle Text Views")
       }
       .padding()
.
.

Now when the toggle is switched, one Text view will gradually fade from view as the other gradually fades in (unfortunately, at the time of writing this and other transition effects were only working when running on a simulator or physical device). The same animation will also be applied to any other views in the layout where the appearance changes as a result of the current state of the visibility property.

1.6 Automatically Starting an Animation

So far in this chapter, all the animations have been triggered by an event such as a button click. Often an animation will need to start without user interaction, for example when a view is first displayed to the user. Since an animation is triggered each time an animatable property of a view changes, this can be used to automatically start an animation when a view appears.

To see this technique in action, modify the example ContentView.swift file as follows:

struct ContentView : View {

    var body: some View {

        ZStack {
            Circle()
                .stroke(lineWidth: 2)
                .foregroundColor(Color.blue)
                .frame(width: 360, height: 360)
            Image(systemName: "forward.fill")
               .font(.largeTitle)
               .offset(y: -180)           
        } 
    }
}

The content view uses a ZStack to overlay an Image view over a circle drawing where the offset of the Image view has been adjusted to position the image on the circumference of the circle. When previewed, the view should match that shown in Figure 30‑2:

Figure 30‑2

Adding a rotation effect to the Image view will give the appearance that the arrows are following the circle. Add this effect and an animation to the Image view as follows:

Image(systemName: "forward.fill")
   .font(.largeTitle)
   .offset(y: -180)
   .rotationEffect(.degrees(360))
   .animation(Animation.linear(duration: 5)
                           .repeatForever(autoreverses: false))

As currently implemented the animation will not trigger when the view is tested in a live preview. This is because no action is taking place to change an animatable property, thereby initiating the animation.

This can be solved by making the angle of rotation subject to a Boolean state property, and then toggling that property when the ZStack first appears via the onAppear() modifier. In terms of implementing this behavior for our circle example, the content view declarations need to read as follows:

import SwiftUI

struct ContentView : View {

    @State private var isSpinning: Bool = true

    var body: some View {

       ZStack {
            Circle()
                .stroke(lineWidth: 2)
                .foregroundColor(Color.blue)
                .frame(width: 360, height: 360)
            Image(systemName: "forward.fill")
               .font(.largeTitle)
               .offset(y: -180)
               .rotationEffect(.degrees(isSpinning ? 0 : 360))
               .animation(Animation.linear(duration: 5)
                           .repeatForever(autoreverses: false))
       }
       .onAppear() {
          self.isSpinning.toggle()
       }
    }
}

When SwiftUI initializes the content view, but before it appears on the screen, the isSpinning state property will be set to true and, based on the ternary operator, the rotation angle set to zero. After the view has appeared, however, the onAppear() modifier will toggle the isSpinning state property to false which will, in turn, cause the ternary operator to change the rotation angle to 360 degrees. As this is an animatable property, the animation modifier will activate and animate the rotation of the Image view through 360 degrees. Since this animation has been configured to repeat indefinitely, the image will continue to animate around the circle.

Figure 30‑3

1.7 SwiftUI Transitions

A transition occurs in SwiftUI whenever a view is made visible or invisible to the user. To make this process more visually appealing than having the view instantly appear and disappear, SwiftUI allows these transitions to be animated in several ways using either individual effects or by combining multiple effects.

Begin by implementing a simple layout consisting of a Toggle button and a Text view. The toggle is bound to a state property which is then used to control whether the text view is visible. To make the transition more noticeable, animation has been applied to the state property binding:

struct ContentView : View {

    @State private var isButtonVisible: Bool = true

    var body: some View {

       VStack {
            Toggle(isOn:$isButtonVisible.animation(
                                   .linear(duration: 2))) {
                Text("Show/Hide Button")
            }
            .padding()
         
            if isButtonVisible {
                Button(action: {}) {
                    Text("Example Button")
                }
                .font(.largeTitle)
            }
        }
    }
} 

After making the changes, use the live preview to switch the toggle button state and note that the Text view fades in and out of view as the state changes. This fading effect is the default transition used by SwiftUI. This default can be changed by passing a different transition to the transition() modifier, for which the following options are available:

  • .scale – The view increases in size as it is made visible and shrinks as it disappears.
  • .slide  – The view slides in and out of view.
  • .move(edge: edge) – As the view is added or removed it does so by moving either from or toward direction of the specified edge.
  • .opacity – The view retains its size and position while fading from view (the default transition behavior).

To configure the Text view to slide into view, change the example as follows:

if isButtonVisible {
    Button(action: {}) {
        Text("Hidden Button")
    }
    .font(.largeTitle)
    .transition(.slide)
}

Alternatively, the view can be made to shrink from view and then grow in size when inserted and removed:

.transition(.scale)

The move() transition can be used as follows to move the view toward a specified edge of the containing view. In the following example, the view moves from bottom to top when disappearing and from top to bottom when appearing:

.transition(.move(edge: .top))

When previewing the above move transition, you may have noticed that after completing the move, the Button disappears instantly. This somewhat jarring effect can be improved by combining the move with another transition.

1.8 Combining Transitions

SwiftUI transitions are combined using an instance of AnyTransition together with the combined(with:) method. To combine, for example, movement with opacity, a transition could be configured as follows:

.transition(AnyTransition.opacity.combined(with: .move(edge: .top)))

When the above example is implemented, the Text view will include a fading effect while moving.

To remove clutter from layout bodies and to promote reusability, transitions can be implemented as extensions to the AnyTransition class. The above combined transition, for example, can be implemented as follows:

extension AnyTransition {

    static var fadeAndMove: AnyTransition {
        AnyTransition.opacity.combined(with: .move(edge: .top))
    }
}

When implemented as an extension, the transition can simply be passed as an argument to the transition() modifier, for example:

.transition(.fadeAndMove)   

1.9 Asymmetrical Transitions

By default, SwiftUI will simply reverse the specified insertion transition when removing a view. To specify a different transition for adding and removing views, the transition can be declared as being asymmetric. The following transition, for example, uses the scale transition for view insertion and sliding for removal:

.transition(.asymmetric(insertion: .scale, removal: .slide))

1.10 Summary

This chapter has explored the implementation of animation when changes are made to the appearance of a view. In the case of implicit animation, changes to a view caused by modifiers can be animated through the application of the animated() modifier. Explicit animation allows only specified properties of a view to be animated in response to appearance changes. Animation may also be applied to state property bindings such that any view changes that occur as a result of that state value changing will be animated.

A transition occurs when a view is inserted into, or removed from, a layout. SwiftUI provides several options for animating these transitions including fading, scaling and sliding. SwiftUI also provides the ability to both combine transitions and define asymmetric transitions where different animation effects are used for insertion and removal of a view.

Basic SwiftUI Graphics Drawing

The goal of this chapter is to introduce SwiftUI 2D drawing techniques. In addition to a group of built-in shape and gradient drawing options, SwiftUI also allows custom drawing to be performed by creating entirely new views that conform to the Shape and Path protocols.

1.1 Creating the DrawDemo Project

Launch Xcode and select the option to create a new Single View App named DrawDemo with the User Interface option set to SwiftUI.

1.2 SwiftUI Shapes

SwiftUI includes a set of five pre-defined shapes that conform to the Shape protocol which can be used to draw circles, rectangles, rounded rectangles and ellipses. Within the DrawDemo project, open the ContentView.swift file and add a single rectangle:

struct ContentView: View {

    var body: some View {
        Rectangle()
}

By default, a shape will occupy all the space available to it within the containing view and will be filled with the foreground color of the parent view (by default this will be black). Within the preview canvas, a black rectangle will fill the entire safe area of the screen.

The color and size of the shape may be adjusted using the fill() modifier and by wrapping it in a frame. Delete the Rectangle view and replace it with the declaration which draws a red filled 200×200 circle:

Circle()
    .fill(Color.red)
    .frame(width: 200, height: 200)

When previewed, the above circle will appear as illustrated in Figure 29‑1:

Figure 29‑1

To draw an unfilled shape with a stroked outline, the stroke() modifier can be applied, passing through an optional line width value. By default, a stroked shape will be drawn using the default foreground color which may be altered using the foregroundColor() modifier. Remaining in the ContentView.swift file, replace the circle with the following:

Capsule()
    .stroke(lineWidth: 10)
    .foregroundColor(.blue)
    .frame(width: 200, height: 100)

Note that the frame for the above Capsule shape is rectangular. A Capsule contained in a square frame simply draws a circle. The above capsule declaration appears as follows when rendered:

Figure 29‑2

The stroke modifier also supports different style types using a StrokeStyle instance. The following declaration, for example, draws a rounded rectangle using a dashed line:

RoundedRectangle(cornerRadius: CGFloat(20))
    .stroke(style: StrokeStyle(lineWidth: 8, dash: [CGFloat(10)]))
    .foregroundColor(.blue)
    .frame(width: 200, height: 100)

The above shape will be rendered as follows:

Figure 29‑3

By providing additional dash values to a StrokeStyle() instance and adding a dash phase value, a range of different dash effects can be achieved, for example:

Ellipse()
    .stroke(style: StrokeStyle(lineWidth: 20, 
             dash: [CGFloat(10), CGFloat(5), CGFloat(2)], 
             dashPhase: CGFloat(10)))
    .foregroundColor(.blue)
    .frame(width: 250, height: 150)

When run or previewed, the above declaration will draw the following ellipse:

Figure 29‑4

1.3 Using Overlays

When drawing a shape, it is not possible to combine the fill and stroke modifiers to render a filled shape with a stroked outline. This effect can, however, be achieved by overlaying a stroked view on top of the filled shape, for example:

Ellipse()
    .fill(Color.red)
    .overlay(Ellipse()
        .stroke(Color.blue, lineWidth: 10))
    .frame(width: 250, height: 150)

The above example draws a blue filled ellipse with a red stroked outlined as illustrated in Figure 29‑5:

Figure 29‑5

1.4 Drawing Custom Paths and Shapes

The shapes used so far in this chapter are essentially structure objects that conform to the Shape protocol. To conform with the shape protocol, a structure must implement a function named path() which accepts a rectangle in the form of a CGRect value and returns a Path object that defines what is to be drawn in that rectangle.

A Path instance provides the outline of a 2D shape by specifying coordinates between points and defining the lines drawn between those points. Lines between points in a path can be drawn using straight lines, cubic and quadratic Bézier curves, arcs, ellipses and rectangles.

In addition to being used in a custom shape implementation, paths may also be drawn directly within a view. Try modifying the ContentView.swift file so that it reads as follows:

struct ContentView: View {

    var body: some View {

        Path { path in
            path.move(to: CGPoint(x: 10, y: 0))
            path.addLine(to: CGPoint(x: 10, y: 350))
            path.addLine(to: CGPoint(x: 300, y: 300))
            path.closeSubpath()
        }
    }
}

A path begins with the coordinates of the start point using the move() method. Methods are then called to add additional lines between coordinates. In this case, the addLine() method is used to add straight lines. Lines may be drawn in a path using the following methods. In each case, the drawing starts at the current point in the path and ends at the specified end point:

  • addArc – Adds an arc based on radius and angle values.
  • addCurve – Adds a cubic Bézier curve using the provided end and control points.
  • addLine – Adds a straight line ending at the specified point.
  • addLines – Adds straight lines between the provided array of end points.
  • addQuadCurve – Adds a quadratic Bézier curve using the specified control and end points.
  • closeSubPath – Closes the path by connecting the end point to the start point.

A full listing of the line drawing methods and supported arguments can be found online at:

https://developer.apple.com/documentation/swiftui/path

When rendered in the preview canvas, the above path will appear as shown in Figure 29‑6:

Figure 29‑6

The custom drawing may also be adapted by applying modifiers, for example with a green fill color:

Path { path in
    path.move(to: CGPoint(x: 10, y: 0))
    path.addLine(to: CGPoint(x: 10, y: 350))
    path.addLine(to: CGPoint(x: 300, y: 300))
    path.closeSubpath()
}
.fill(Color.green)

Although it is possible to draw directly within a view, it generally makes more sense to implement custom shapes as reusable components. Within the ContentView.swift file, implement a custom shape as follows:

struct MyShape: Shape {

    func path(in rect: CGRect) -> Path {

        var path = Path()

        path.move(to: CGPoint(x: rect.minX, y: rect.minY))
        path.addQuadCurve(to: CGPoint(x: rect.minX, y: rect.maxY), 
            control: CGPoint(x: rect.midX, y: rect.midY))
        path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        path.closeSubpath()
        return path
    }
}

The custom shape structure conforms to the Shape protocol by implementing the required path() function. The CGRect value passed to the function is used to define the boundaries into which a triangle shape is drawn, with one of the sides drawn using a quadratic curve.

Now that the custom shape has been declared, it can be used in the same way as the built-in SwiftUI shapes, including the use of modifiers. To see this in action, change the body of the main view to read as follows:

struct ContentView: View {

    var body: some View {
        MyShape()
            .fill(Color.red)
            .frame(width: 360, height: 350)
    }
}

When rendered, the custom shape will appear in the designated frame as illustrated in Figure 29‑7 below:

Figure 29‑7

1.5 Drawing Gradients

SwiftUI provides support for drawing gradients including linear, angular (conic) and radial gradients. In each case, the gradient is provided with a Gradient object initialized with an array of colors to be included in the gradient and values that control the way in which the gradient is rendered.

The following declaration, for example, generates a radial gradient consisting of five colors applied as the fill pattern for a Circle:

struct ContentView: View {

    let colors = Gradient(colors: [Color.red, Color.yellow, 
                   Color.green, Color.blue, Color.purple])

    var body: some View {
            Circle()
                .fill(RadialGradient(gradient: colors, 
                      center: .center,
                      startRadius: CGFloat(0), 
                      endRadius: CGFloat(300)))
    }
}

When rendered the above gradient will appear as follows:

Figure 29‑8

The following declaration, on the other hand, generates an angular gradient with the same color range:

Circle()
    .fill(AngularGradient(gradient: colors, center: .center))

The angular gradient will appear as illustrated in the following figure:

Figure 29‑9

Similarly, a LinearGradient running diagonally would be implemented as follows:

Rectangle()
    .fill(LinearGradient(gradient: colors, 
                       startPoint: .topLeading,
                         endPoint: .bottomTrailing))
    .frame(width: 360, height: 350)

The linear gradient will be rendered as follows:

Figure 29‑10

The final step in the DrawingDemo project is to apply gradients for the fill and background modifiers for our MyShape instance as follows:

MyShape()
    .fill(RadialGradient(gradient: colors,
                           center: .center,
                      startRadius: CGFloat(0),
                        endRadius: CGFloat(300)))
     .background(LinearGradient(gradient: Gradient(colors:        
                               [Color.black, Color.white]), 
                       startPoint: .topLeading, 
                         endPoint: .bottomTrailing))
     .frame(width: 360, height: 350)

With the gradients added, the MyShape rendering should match figure below:

Figure 29‑11

1.6 Summary

SwiftUI includes a built-in set of views that conform to the Shape protocol for drawing standard shapes such as rectangles, circles and ellipses. Modifiers can be applied to these views to control stroke, fill and color properties.

Custom shapes are created by specifying paths which consist of sets of points joined by straight or curved lines. SwiftUI also includes support for drawing radial, linear and angular gradient patterns.

Building Context Menus in SwiftUI

A context menu in SwiftUI is a menu of options that appears when the user performs a long press over a view on which a menu has been configured. Each menu item will typically contain a Button view configured to perform an action when selected, together with a Text view and an optional Image view.

This chapter will work through the creation of an example app that makes use of a context menu to perform color changes on a view.

1.1 Creating the ContextMenuDemo Project

Launch Xcode and select the option to create a new Single View App named ContextMenuDemo with the User Interface option set to SwiftUI.

1.2 Preparing the Content View

A context menu may be added to any view within a layout, but for the purposes of this example, the default “Hello World” Text view will be used. Within Xcode, load the ContentView.swift file into the editor, add some state properties to store the foreground and background color values, and use these to control the color settings of the Text view. Also use the font() modifier to increase the text font size:

import SwiftUI

struct ContentView: View {

    @State private var foregroundColor: Color = Color.black
    @State private var backgroundColor: Color = Color.white

    var body: some View {

        Text("Hello World")
            .font(.largeTitle)
            .padding()
            .foregroundColor(foregroundColor)
            .background(backgroundColor)
.
.

1.3 Adding the Context Menu

Context menus are added to views in SwiftUI using the contextMenu() modifier and declaring the views that are to serve as menu items. Add menu items to the context menu by making the following changes to the body view of the ContentView.swift file:

var body: some View {

    Text("Hello World")
        .font(.largeTitle)
        .padding()
        .foregroundColor(foregroundColor)
        .background(backgroundColor)
        .contextMenu {
            Button(action: {
            }) {
                Text("Normal Colors")
                Image(systemName: "paintbrush")
            }
            Button(action: {
            }) {
                Text("Inverted Colors")
                Image(systemName: "paintbrush.fill")
            }
       }
}

Finally, add code to the two button actions to change the values assigned to the foreground and background state properties:

var body: some View {

    Text("Hello World")
        .font(.largeTitle)
        .padding()
        .foregroundColor(foregroundColor)
        .background(backgroundColor)
        .contextMenu {

            Button(action: {
                self.foregroundColor = .black
                self.backgroundColor = .white
            }) {
                Text("Normal Colors")
                Image(systemName: "paintbrush")
            }

            Button(action: {
                self.foregroundColor = .white
                self.backgroundColor = .black
            }) {
                Text("Inverted Colors")
                Image(systemName: "paintbrush.fill")
            }
    }
}

1.4 Testing the Context Menu

Use live preview mode to test the view and perform a long press on the Text view. After a short delay the context menu should appear resembling Figure 28‑1 below:

Figure 28‑1

Select the Inverted Colors option to dismiss the menu and invert the colors on the Text view:

Figure 28‑2

1.5 Summary

Context menus appear when a long press gesture is performed over a view in a layout. A context menu can be added to any view type and is implemented using the contextMenu() modifier. The menu is comprised of menu items which usually take the form of Button views configured with an action together with a text view and an optional Image view.

Building Tabbed Views in SwiftUI

The SwiftUI TabView component allows the user to navigate between different child views by selecting tab items located in a tab bar. This chapter will work through an example project intended to demonstrate how to implement a TabView based interface in a SwiftUI app.

1.1 An Overview of SwiftUI TabView

Tabbed views are created in SwiftUI using the TabView container view and consist of a range of child views which represent the screens through which the user will navigate.

The TabView presents a tab bar at the bottom of the layout which contains the tab items used to navigate between the child views. A tab item is applied to each content view using a modifier and can be customized to contain Text and Image views (other view types are not supported in tab items).

The currently selected tab may also be controlled programmatically by adding tags to the tab items.

Figure 27‑1 shows an example TabView layout:

Figure 27‑1

1.2 Creating the TabViewDemo App

Launch Xcode and select the option to create a new Single View App named TabViewDemo with the User Interface option set to SwiftUI.

1.3 Adding the TabView Container

With the ContentView.swift file loaded into the code editor, delete the default “Hello World” Text view and add a TabView as follows:

import SwiftUI

struct ContentView: View {

    var body: some View {
        TabView {
          
        }
    }
}

1.4 Adding the Content Views

Next, add three content views to the layout. For the purposes of this example Text views will be used, but in practice these are likely to be more complex views consisting of stack layouts (note the addition of a font modifier to increase the size of the content text):

var body: some View {

    TabView {
        Text("First Content View")
        Text("Second Content View")
        Text("Third Content View")
    }
    .font(.largeTitle)
}

1.5 Adding the Tab Items

When the layout is now previewed, the first content view will be visible along with the tab bar located along the bottom edge of the screen. Since no tab items have been added, the tab bar is currently empty. Clearly the next step is to apply a tab item to each content view using the tabItem() modifier. In this example, each tab item will contain a Text and an Image view:

var body: some View {
    TabView {
        Text("First Content View")
            .tabItem {
                Image(systemName: "1.circle")
                Text("Screen One")
            }
        Text("Second Content View")
            .tabItem {
                Image(systemName: "2.circle")
                Text("Screen Two")
            }
        Text("Third Content View")
            .tabItem {
                Image(systemName: "3.circle")
                Text("Screen Three")
            }
    }
    .font(.largeTitle)
}

With the changes made, verify that the tab items now appear in the tab bar before using Live Preview to test that clicking on a tab item displays the corresponding content view. The completed app should resemble that illustrated in Figure 27‑1 above.

1.6 Adding Tab Item Tags

To control the currently selected tab in code, a tag needs to be added to each tab item and a state property declared to store the current selection as follows:

struct ContentView: View {

    @State private var selection = 1

    var body: some View {

        TabView() {
            Text("First Content View")
                .tabItem {
                    Image(systemName: "1.circle")
                    Text("Screen One")
                }.tag(1)

            Text("Second Content View")
                .tabItem {
                    Image(systemName: "2.circle")
                    Text("Screen Two")
                }.tag(2)

            Text("Third Content View")
                .tabItem {
                    Image(systemName: "3.circle")
                    Text("Screen Three")
                }.tag(3)
        }
        .font(.largeTitle)
    }
}

Next, bind the current selection value of the TabView to the selection state property:

var body: some View {
    TabView(selection: $selection) {
        Text("First Content View")
            .tabItem {
.
.

Any changes to the selection state property to a different value within the tag range (in this case a value between 1 and 3) elsewhere in the view will now cause the tabbed view to switch to the corresponding content view.

Test this behavior by changing the value assigned to the selection state property while the app is running in live preview mode.

1.7 Summary

The SwiftUI TabView container provides a mechanism via which the user can navigate between content views by selecting tabs in a tab bar. The TabView is implemented by declaring child content views and assigning a tab item to each view. The tab items appear in the tab bar and can be constructed from a Text view, an Image view or a combination of Text and Image views.

To control the current selection of a TabView programmatically, each tab item must be assigned a tag containing a unique value, binding the TabView current selection value to a state property.

A SwiftUI List and Navigation Tutorial

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

1.1 About the ListNavDemo Project

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

The project will also make extensive use of state properties and observable objects to keep the user interface synchronized with the data model.

1.2 Creating the ListNavDemo Project

Launch Xcode and select the option to create a new Single View App named ListNavDemo with the User Interface option set to SwiftUI.

1.3 Preparing the Project

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

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

https://www.ebookfrenzy.com/code/SwiftUIEssentialsSamples.zip

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

Figure 26‑1

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

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

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

1.4 Adding the Car Structure

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

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

import SwiftUI

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

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

1.5 Loading the JSON Data

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

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

import UIKit
import SwiftUI

var carData: [Car] = loadJson("carData.json")

func loadJson<T: Decodable>(_ filename: String) -> T {
    let data: Data
  
    guard let file = Bundle.main.url(forResource: filename, 
                                               withExtension: nil)
    else {
        fatalError("\(filename) not found.")
    }

    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Could not load \(filename): (error)")
    }

    do {
        return try JSONDecoder().decode(T.self, from: data)
    } catch {
        fatalError("Unable to parse \(filename): (error)")
    }
}

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

1.6 Adding the Data Store

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

import SwiftUI
import Combine

class CarStore : ObservableObject {

    @Published var cars: [Car]

    init (cars: [Car] = []) {
        self.cars = cars
    }
}

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

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

1.7 Designing the Content View

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

import SwiftUI

struct ContentView: View {

    @ObservedObject var carStore : CarStore = CarStore(cars: carData)
.
.

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

.
.
var body: some View {

        List {
            ForEach (carStore.cars) { car in
                HStack {
                    Image(car.imageName)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 100, height: 60)
                Text(car.name)
            }
        }
      }
    }
}
.
.

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

Figure 26‑2

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

Figure 26‑3

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

    var body: some View {
      
            List {
                ForEach (carStore.cars) { car in
                    ListCell(car: car)
            }
        }
    }
}

struct ListCell: View {

    var car: Car
    var body: some View {

        HStack {
            Image(car.imageName)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 100, height: 60)
            Text(car.name)
        }
    }
}

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

1.8 Designing the Detail View

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

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

import SwiftUI

struct CarDetails: View {

    let selectedCar: Car

    var body: some View {
        Text"Hello World!")
    }
}

struct CarDetails_Previews: PreviewProvider {
    static var previews: some View {
        CarDetails(selectedCar: carData[0])
    }
}

For this layout, a Form container will be used to organize the views. This is a container view that allows views to be grouped together and divided into different sections. The Form also places a line divider between each child view. Within the body of the CarDetail.swift file, implement the layout as follows:

var body: some View {
        Form {
            Section(header: Text("Car Details")) {
                Image(selectedCar.imageName)
                    .resizable()
                    .cornerRadius(12.0)
                    .aspectRatio(contentMode: .fit)
                    .padding()

                    Text(selectedCar.name)
                        .font(.headline)

                    Text(selectedCar.description)
                        .font(.body)

                    HStack {
                        Text("Hybrid").font(.headline)
                        Spacer()
                        Image(systemName: selectedCar.isHybrid ? 
                                "checkmark.circle" : "xmark.circle" )
                    }
        }
    }
}

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

When previewed, the screen should match that shown in Figure 26‑4:

Figure 26‑4

1.9 Adding Navigation to the List

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

With the ContentView.swift file loaded into the code editor, locate the ListCell subview declaration and embed the HStack in a NavigationLink with the CarDetail view configured as the destination, making sure to pass through the selected car object:

struct ListCell: View {

    var car: Car

    var body: some View {

        NavigationLink(destination: CarDetail(selectedCar: car)) {
            HStack {
                Image(car.imageName)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 100, height: 60)
                Text(car.name)
            }
        }
    }
}

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

var body: some View {

    NavigationView {
            List {
                ForEach (carStore.cars) { car in
                    ListCell(car: car)
            }
        }
    }
}

Test that the navigation works by clicking on the Live Preview button in the preview canvas and selecting different rows, confirming each time that the detail view appears containing information that matches the selected car model.

1.10 Designing the Add Car View

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

import SwiftUI

struct AddNewCar: View {

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

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

struct DataInput: View {

    var title: String
    @Binding var userInput: String

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

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

var body: some View {

    Form {
        Section(header: Text("Car Details")) {
            Image(systemName: "car.fill")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .padding()

            DataInput(title: "Model", userInput: $name)
            DataInput(title: "Description", userInput: $description)

            Toggle(isOn: $isHybrid) {
                    Text("Hybrid").font(.headline)
            }.padding()        
        }

        Button(action: addNewCar) {
            Text("Add Car")
            }
        }
}

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

.
.
Button(action: addNewCar) {
            Text("Add Car")
            }
        }
    }

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

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

When rendered in the preview canvas, the AddNewCar view should match Figure 26‑5 below:

Figure 26‑5

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

1.11 Implementing Add and Edit Buttons

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

var body: some View {

    NavigationView {
            List {
                ForEach (carStore.cars) { car in
                    ListCell(car: car)
            }
        }
        .navigationBarTitle(Text("EV Cars"))
        .navigationBarItems(leading: NavigationLink(destination: 
                               AddNewCar(carStore: self.carStore)) {
            Text("Add")
                .foregroundColor(.blue)
        }, trailing: EditButton())
    }
}

The Add button is configured to appear at the leading edge of the navigation bar and is implemented as a NavigationLink configured to display the AddNewCar view, passing through a reference to the observable carStore binding.

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

Figure 26‑6

Using Live Preview mode, test that the Add button displays the new car screen and that entering new car details and clicking the Add Car button causes the new entry to appear in the list after returning to the content view screen.

1.12 Adding the Edit Button Methods

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

var body: some View {

    NavigationView {
            List {
                ForEach (carStore.cars) { car in
                    ListCell(car: car)
            }
            .onDelete(perform: deleteItems)
            .onMove(perform: moveItems)
        }
        .navigationBarTitle(Text("EV Cars"))
.
.

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

.
.
.navigationBarTitle(Text("EV Cars"))
            .navigationBarItems(leading: NavigationLink(destination: AddNewCar(carStore: self.carStore)) {
                Text("Add")
                    .foregroundColor(.blue)
            }, trailing: EditButton())
        }
    }

    func deleteItems(at offets: IndexSet) {
        carStore.cars.remove(atOffsets: offets)
    }

    func moveItems(from source: IndexSet, to destination: Int) {
        carStore.cars.move(fromOffsets: source, toOffset: destination)
    }
}

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

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

Figure 26‑7

1.13 Summary

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

SwiftUI Lists and Navigation

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

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

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

1.1 SwiftUI Lists

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

struct ContentView: View {

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

When displayed in the preview, the above list will appear as shown in Figure 25‑1:

Figure 25‑1

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

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

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

Figure 25‑2

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

1.2 SwiftUI Dynamic Lists

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

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

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

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

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

struct ContentView: View {

    var listData: [ToDoItem] = [
         ToDoItem(task: "Take out trash", imageName: "trash.circle.fill"),
         ToDoItem(task: "Pick up the kids", imageName: "person.2.fill"),
         ToDoItem(task: "Wash the car", imageName: "car.fill")
       ]

    var body: some View {
        List(listData) { item in
            HStack {
                Image(systemName: item.imageName)
                Text(item.task)
            }
        }
    }
}
.
.

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

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

struct ContentView: View {

    @State var toggleStatus = true
.
.    
    var body: some View {
        List {
            Toggle(isOn: $toggleStatus) {
                Text("Allow Notifications")
            }

            ForEach (listData) { item in
                HStack {
                    Image(systemName: item.imageName)
                    Text(item.task)
                }
            }
        }
    }
}

Note the appearance of the toggle button and the dynamic list items in Figure 25‑3:

Figure 25‑3

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

Figure 25‑4

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

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

    Section(header: Text("To Do Tasks")) {
        ForEach (listData) { item in
            HStack {
                Image(systemName: item.imageName)
                Text(item.task)
            }
        }
    }
}

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

1.3 SwiftUI NavigationView and NavigationLink

To make items in a list navigable, the first step is to embed the list within a NavigationView. Once the list is embedded, the individual rows must be wrapped in a NavigationLink control which is, in turn, configured with the destination view to which the user is to be taken when the row is tapped.

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

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

Remaining with the To Do list example, the following changes are necessary to implement navigation and add a navigation bar title:

var body: some View {

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

            Section(header: Text("To Do Tasks")) {
                ForEach (listData) { item in
                    HStack {
                        NavigationLink(destination: Text(item.task)) {
                            Image(systemName: item.imageName)
                            Text(item.task)
                        }
                    }
                }
            }
        }
        .navigationBarTitle(Text("To Do List"))
    }
}

In this example, the navigation link will simply display a new screen containing a Text view displaying the task string value. When tested in the canvas using live preview, the finished list will appear as shown in Figure 25‑5 with the title and chevrons on the far right of each row now visible. Tapping the links will navigate to the text view.

Figure 25‑5

1.4 Making the List Editable

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

Figure 25‑6

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

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

        Section(header: Text("To Do Tasks")) {
            ForEach (listData) { item in
                HStack {
                    NavigationLink(destination: Text(item.task)) {
                        Image(systemName: item.imageName)
                        Text(item.task)
                    }
                }
            }
            .onDelete(perform: deleteItem)
        }
    }
    .navigationBarTitle(Text("To Do List"))
}
.
.
func deleteItem(at offsets: IndexSet) {
    // Delete items from data source here
}

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

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

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

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

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

Figure 25‑7

1.5 Summary

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

List views are usually used as a way to allow the user to navigate to other screens. This navigation is implemented by wrapping the List declaration in a NavigationView and each row in a NavigationLink.

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