DISCOVERY

February 28th, 2022

Creating SwiftUI Components Within a UIKit iOS Application

SwiftUI

Swift

iOS

SwiftUI is a new framework that creates user interfaces for iOS and other Apple operating system applications. SwiftUI is completely programmatic (user interfaces in SwiftUI are built exclusively by writing Swift code). This is in stark contrast to previous frameworks such as UIKit which use a combination of Swift or Objective-C code and storyboards. Storyboards are graphical interfaces which allow engineers or designers to build user interfaces without writing code.

As someone who prefers solutions that involve writing code exclusively, SwiftUI is a welcome addition to the iOS landscape and a positive change in direction from Apple. Using GUIs such as storyboard to build interfaces have many downsides, such as difficulties when working in large groups on a single UI file and confusing merge conflicts when using version control systems. Since I'm very passionate about programming (as most software engineers are), altering values in a GUI to change a UI is a lot less exciting than coding!

This article introduces SwiftUI and walks through a page in an existing iOS application that I converted to SwiftUI from UIKit and Storyboard. I also describe the steps needed to integrate SwiftUI components into a UIKit application. All the code discussed in this article comes from my SaintsXCTF iOS application and lives in a saints-xctf-ios repository.

SwiftUI is a new approach for creating user interfaces in Apple operating system applications, such as iPhone and Apple Watch apps. Released in September 2019, SwiftUI allows UIs to be built entirely in Swift code. Apple has excellent tutorials for getting started with SwiftUI and for learning how to code SwiftUI views in XCode.

SwiftUI is a declarative UI framework, similar to React.js. Declarative programming frameworks involve coding the desired end state instead of coding iterative commands to achieve an end state. Behind the scenes, SwiftUI is able to translate the desired end state into iterative commands which build the UI. Engineers do not need to worry about these details as SwiftUI abstracts them away. Declarative frameworks are often easier to work with and faster to code in. A comparison in the front-end programming world is how the declarative library React.js is easier to work with then the iterative library JQuery for building web pages.

In my SaintsXCTF iOS application, the page I converted from UIKit and Storyboard to SwiftUI is the create exercise log page. When loaded, this page appears like so:

Users enter details about their exercise, such as the time taken and mileage.

After entering information about an exercise, users can create the exercise log, which displays it on the application home page and their profile page.

User's can also edit existing exercise logs and resubmit them.

SwiftUI applications are split into many views (components) that make up the user interface. For my exercise log page, there are two top level views. The first, CreateExerciseLogView, is for creating a new exercise log. The second, EditExerciseLogView, is for editing an existing exercise log.

CreateExerciseLogView, which is located in a CreateExerciseLogView.swift file, has the following content:

import SwiftUI struct CreateExerciseLogView: View { @StateObject var log = ExerciseLog() @StateObject var meta = ExerciseLogMeta(isExisting: false) @StateObject var createLog = CreateExerciseLog() @StateObject var form = ExerciseLogForm() var body: some View { ExerciseLogView(log: log, meta: meta, createLog: createLog, form: form) } }

EditExerciseLogView, which is located in a EditExerciseLogView.swift file, is coded very similarly.

import SwiftUI struct EditExerciseLogView: View { @ObservedObject var log = ExerciseLog() @StateObject var meta = ExerciseLogMeta(isExisting: true) @StateObject var createLog = CreateExerciseLog() @StateObject var form = ExerciseLogForm() var body: some View { ExerciseLogView(log: log, meta: meta, createLog: createLog, form: form) .onAppear(perform: { if !meta.existingLogInitialized { form.displayedTime = log.time } }) } }

Both CreateExerciseLogView and EditExerciseLogView are structs of type View. The View protocol defines a struct that is part of a user interface. When a custom view is created that follows the View protocol, the body property of type some View must be implemented. Both CreateExerciseLogView and EditExerciseLogView have bodies containing an ExerciseLogView view. ExerciseLogView is the main view holding the create/edit exercise log form. It takes four objects as arguments which help customize the views appearance: log, meta, createLog, and form.

These four variables are initialized with the @StateObject annotation in both CreateExerciseLogView and EditExerciseLogView; except for log in EditExerciseLogView, which is initialized with @ObservedObject. These annotations are property wrappers which label variables that hold state in Swift views. Swift views are controlled by application state; every time the state within a view changes, the view re-renders. For a more in-depth analysis on these annotations, check out this article on SwiftUI property wrappers1. In short, @ObservedObject holds an object in application state with a value passed from another view. @StateObject also holds an object in application state, but it is initialized within the current view. In other words, @ObservedObject watches a state object that is owned by another view, while @StateObject creates a state object in the current view.

In the EditExerciseLogView view, log is annotated with @ObservedObject because its parent view sends information about a log that needs to be edited. All the other state objects are created in and owned by CreateExerciseLogView and EditExerciseLogView.

Objects annotated with @StateObject and @ObservedObject must conform to a ObservableObject protocol. For example, the log object is a class of type ExerciseLog, which is defined in ExerciseLog.swift.

import SwiftUI class ExerciseLog: ObservableObject { @Published var id: Int? = nil @Published var name = "" @Published var location = "" var date = Date() @Published var exerciseType = ExerciseType.run @Published var distance = "" @Published var metric = Metric.miles @Published var time = "" @Published var feel = 6.0 @Published var description = "" func reset() { name = "" location = "" date = Date() exerciseType = ExerciseType.run distance = "" metric = Metric.miles time = "" feel = 6.0 description = "" } }

ExerciseLog contains properties representing the attributes of an exercise log. Some of these properties, such as id, are annotated with @Published. When values of properties with the @Published annotation change, the UI updates accordingly2. Properties without @Published do not cause UI re-renders when their values change.

reset() is simply a helper method to revert properties to their default values.

The other three state objects in CreateExerciseLogView and EditExerciseLogView are coded similarly to ExerciseLog. meta of type ExerciseLogMeta (located in ExerciseLogMeta.swift), createLog of type CreateExerciseLog (located in CreateExerciseLog.swift), and form of type ExerciseLogForm (located in ExerciseLogForm.swift) are shown below.

// ExerciseLogMeta.swift import SwiftUI class ExerciseLogMeta: ObservableObject { init(isExisting: Bool) { self.isExistingLog = isExisting } var isExistingLog = false var existingLogInitialized = false }
// CreateExerciseLog.swift import SwiftUI class CreateExerciseLog: ObservableObject { @Published var creating = false @Published var created = false @Published var error = false @Published var errorMessage: String? = nil func createExerciseLog(exerciseLog: ExerciseLog, completion: @escaping () -> Void) -> Void { creating = true created = false error = false errorMessage = nil let log = convertToLog(exerciseLog) APIClient.logPostRequest(withLog: log, fromController: nil) { (newlog) -> Void in self.creating = false if newlog != nil { self.created = true completion() } else { self.error = true self.errorMessage = "Failed to Create New Exercise Log" } } } func updateExerciseLog(newLog: ExerciseLog, existingLog: Log, completion: @escaping () -> Void) -> Void { creating = true created = false error = false errorMessage = nil let log = convertToLog(newLog, existingLog) log.log_id = existingLog.log_id APIClient.logPutRequest(withLogID: log.log_id!, andLog: log, fromController: nil) { (newlog) -> Void in self.creating = false if newlog != nil { self.created = true completion() } else { self.error = true self.errorMessage = "Failed to Create New Exercise Log" } } } func convertToLog(_ exerciseLog: ExerciseLog, _ existingLog: Log = Log()) -> Log { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" let formattedDate = dateFormatter.string(from: exerciseLog.date) let timeFormat = "00:00:00" let end = timeFormat.index(timeFormat.endIndex, offsetBy: 0 - exerciseLog.time.count - 1) let formattedTime = "\(timeFormat[timeFormat.startIndex...end])\(exerciseLog.time)" let user = SignedInUser.user let log = existingLog log.name = exerciseLog.name log.username = user.username log.first = user.first log.last = user.last log.location = exerciseLog.location log.date = formattedDate log.type = exerciseLog.exerciseType.rawValue log.distance = Double(exerciseLog.distance) log.metric = exerciseLog.metric.rawValue log.time = formattedTime log.feel = Int(exerciseLog.feel) log.log_description = exerciseLog.description return log } }
// ExerciseLogForm.swift class ExerciseLogForm: ObservableObject { @Published var isEditingName = false @Published var nameStatus = InputStatus.initial @Published var isEditingLocation = false @Published var isEditingDate = false @Published var distanceStatus = InputStatus.initial @Published var isEditingDistance = false @Published var displayedTime = "" @Published var timeStatus = InputStatus.initial @Published var isEditingTime = false @Published var isEditingDescription = false @Published var showCanceling = false func reset() { nameStatus = InputStatus.initial distanceStatus = InputStatus.initial displayedTime = "" timeStatus = InputStatus.initial } }

Slightly different than the others, CreateExerciseLog not only contains state data in properties with @Published annotations, but also has functions for making API calls. createExerciseLog() and updateExerciseLog() make API calls to create and update exercise logs, respectively.

As previously mentioned, both CreateExerciseLogView and EditExerciseLogView are wrappers around an ExerciseLogView view, which displays a form to create or edit an exercise log. ExerciseLogView, which is located in an ExerciseLogView.swift file, is shown below.

import SwiftUI struct ExerciseLogView: View { @EnvironmentObject var existingLog: ExistingLog @ObservedObject var log: ExerciseLog @ObservedObject var meta: ExerciseLogMeta @ObservedObject var createLog: CreateExerciseLog @ObservedObject var form: ExerciseLogForm var body: some View { ScrollView { VStack(alignment: .leading) { Text(meta.isExistingLog ? "Edit Exercise Log" : "Create Exercise Log") .font(.title) .foregroundColor(.black) .bold() .alert(isPresented: $createLog.error) { Alert( title: Text( meta.isExistingLog ? "An unexpected error occurred while updating the exercise log." : "An unexpected error occurred while creating an exercise log." ), primaryButton: .default( Text("Cancel"), action: { createLog.error = false } ), secondaryButton: .cancel( Text("Try Again"), action: { createLog.error = false if meta.isExistingLog { createLog.updateExerciseLog(newLog: log, existingLog: existingLog.log ?? Log()) {} } else { createLog.createExerciseLog(exerciseLog: log) { log.reset() form.reset() } } } ) ) } ExerciseLogFormView(log: log, meta: meta, createLog: createLog, form: form) } .padding() .padding(.top, 20) } .progressViewStyle( CircularProgressViewStyle(tint: Color(UIColor(Constants.saintsXctfRed))) ) } }

In ExerciseLogView, properties log, meta, createLog, and form are all annotated with @ObservedObject because they are owned by parent views and are passed as parameters to the current view. This view introduces a new property existingLog with an @EnvironmentObject annotation. Properties annotated with @EnvironmentObject are accessible to all other views in the application3. You can think of it as SwiftUI's mechanism for global state. If the user is editing an existing exercise log, the existingLog property is populated with its contents.

The body property of ExerciseLogView initializes some elements in the user interface. The outermost element, ScrollView, creates a view with scrollable content3. The view inside ScrollView is defined as VStack(alignment: .leading). VStack is a view whose children are arranged vertically4. The alignment: .leading argument sets the alignment of children in VStack on the x and y axes5. Inside VStack is a Text view, which is the header of the page. It either contains the text "Create Exercise Log" or "Edit Exercise Log".

Method calls can be chained on views to customize them. For example, the Text view has font(), foregroundColor(), bold(), and alert() method calls attached to it. The first three methods customize the appearance of the text. The fourth method, alert(), displays an alert in case an error occurs while creating or editing an exercise log.

ExerciseLogView contains another custom view, ExerciseLogFormView. It exists in a ExerciseLogFormView.swift file. This view along with ExerciseLogActionsView in the ExerciseLogActionsView.swift file make up the remainder of the create/edit exercise log page. Feel free to view them and use their code in your own projects.

Integrating SwiftUI views into a UIKit application is relatively straightforward. The main approach is to wrap SwiftUI views within a UIViewController class. For the create/edit exercise log page, I have two UIViewController classes that wrap SwiftUI views - CreateExerciseLogViewController and EditExerciseLogViewController. Both are shown below and are available in CreateExerciseLogViewController.swift and EditExerciseLogViewController.swift files, respectively.

// CreateExerciseLogViewController.swift import UIKit import SwiftUI class CreateExerciseLogViewController: UIViewController { @IBOutlet weak var container: UIView! override func viewDidLoad() { super.viewDidLoad() let childView = UIHostingController(rootView: CreateExerciseLogView()) addChild(childView) childView.view.frame = container.bounds container.addSubview(childView.view) childView.didMove(toParent: self) } }
// EditExerciseLogViewController.swift import UIKit import SwiftUI class EditExerciseLogViewController: UIViewController { @IBOutlet weak var container: UIView! var log: Log? = nil override func viewDidLoad() { super.viewDidLoad() let exerciseLog = ExerciseLog() if let logObject = log { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" let dateObject = dateFormatter.date(from: logObject.date) ?? Date() exerciseLog.id = logObject.log_id exerciseLog.name = logObject.name exerciseLog.location = logObject.location ?? "" exerciseLog.date = dateObject exerciseLog.exerciseType = ExerciseType(rawValue: logObject.type!) ?? ExerciseType.run exerciseLog.distance = logObject.distance != nil ? String(logObject.distance!) : "" exerciseLog.metric = Metric(rawValue: logObject.metric!) ?? Metric.miles exerciseLog.time = logObject.time ?? "" exerciseLog.feel = Double(logObject.feel) exerciseLog.description = logObject.log_description ?? "" } let existingLog = ExistingLog(log ?? Log()) let childView = UIHostingController(rootView: EditExerciseLogView(log: exerciseLog).environmentObject(existingLog)) addChild(childView) childView.view.frame = container.bounds container.addSubview(childView.view) childView.didMove(toParent: self) } }

Within both the CreateExerciseLogViewController and EditExerciseLogViewController views, a variable container of type UIView exists. This container view holds SwiftUI views. Configuring container to hold SwiftUI views is done within the viewDidLoad() method.

The UIHostingController() class is used to create a UIKit view controller for SwiftUI views6. Once a SwiftUI view is wrapped within a UIHostingController() instance, it can be added as a subview to a UIKit view.

The main difference between CreateExerciseLogViewController and EditExerciseLogViewController is that CreateExerciseLogViewController wraps SwiftUI views within UIKit using UIHostingController(rootView: CreateExerciseLogView()), and EditExerciseLogViewController wraps SwiftUI views within UIKit using UIHostingController(rootView: EditExerciseLogView(log: exerciseLog).environmentObject(existingLog)). The SwiftUI view CreateExerciseLogView takes no arguments while EditExerciseLogView takes a single log argument. The exerciseLog object passed to EditExerciseLogView() is assigned to a property annotated with @ObservedObject. Similarly, the exerciseLog object passed to EditExerciseLogView().environmentObject() is assigned to a property annotated with @EnvironmentObject.

With the addition of SwiftUI, I'm excited to create user interfaces in Apple applications again! Configuring user interfaces in code using Swift is fun and I can't wait to learn more. All the code discussed in this article is available in my saints-xctf-ios repository on GitHub.