15.
Complex Interfaces
Written by Bill Morefield
SwiftUI represents an exciting new paradigm for UI design. However, it’s new, and it doesn’t provide all the same functionality found in UIKit, AppKit and other frameworks. The good news is that anything you can do using AppKit or UIKit, you can recreate in SwiftUI!
If you were building apps before SwiftUI came along, you likely have custom controls that you’ve already written yourself, or existing ones that you’ve integrated into your apps. SwiftUI can work with UIKit or AppKit to reuse both native and existing views and view controllers.
SwiftUI does, though, provide the ability to build upon an existing framework and extend it to add missing features. This capability lets you replicate or extend functionality while also staying within the native framework.
In this chapter, you’ll first add an open-source custom control within a UIKit view in a SwiftUI app. You’ll also work through building a reusable view that can display other views in a grid.
Integrating with other frameworks
You’ll likely need to integrate with pre-SwiftUI frameworks in any moderately complex app. That’s because many of the built-in frameworks, such as MapKit, do not have a corresponding component in SwiftUI. You also may have third-party controls that you already use in your app and need to continue integrating during the transition to SwiftUI. In this section, you’ll take a simple open-source timeline view built for a UITableView and integrate it into a SwiftUI app.
You’ll use Zheng-Xiang Ke’s TimelineTableViewCell control to display a timeline of all the day’s flights. This control is open source and available on GitHub at https://github.com/kf99916/TimelineTableViewCell. Since the project supports Swift Package Manager, you can easily add it to your project. The starter project for this chapter already includes the package.
Since Swift Package Manager doesn’t currently support bundling resources — including the custom nib file used by the TimelineTableViewCell control — you’ll see a copy of the nib file in the UI group in the main project. Hopefully, a future version of Swift Package Manager will make this step unnecessary.
To work with UIViews and UIViewControllers in SwiftUI, you must create types that conform to the UIViewRepresentable and UIViewControllerRepresentable protocols. SwiftUI will manage the life cycle of these views, so you only need to create and configure the views and the underlying frameworks will take care of the rest. Open the starter project and create a new Swift file — not SwiftUI view — named FlightTimeline.swift in the MountainAirport group.
Replace the contents of FlightTimeline.swift with:
import SwiftUI
import TimelineTableViewCell
struct FlightTimeline: UIViewControllerRepresentable {
var flights: [FlightInformation]
}
This code first imports the TimelineTableViewCell package for this file. You next create the type that will wrap the UITableViewController. SwiftUI includes several protocols that allow integration to views, view controllers and other app framework components. You will pass in an array of FlightInformation values as you would to a SwiftUI view. There are two methods in the UIViewControllerRepresentable protocol you will need to implement: makeUIViewController(context:), and updateUIViewController(_:context:). You’ll create those now.
Add the following code to the struct below the flights parameter:
func makeUIViewController(context: Context) ->
UITableViewController {
UITableViewController()
}
SwiftUI will call makeUIViewController(context:) once when it is ready to display the view. Here, you create a UITableViewController programmatically and return it. Any UIKit ViewController would work here; there are similar protocols for AppKit, WatchKit and other views and view controllers on the appropriate platform.
Now add this code to the end of the struct to implement the second method:
func updateUIViewController(_ viewController:
UITableViewController, context: Context) {
let timelineTableViewCellNib =
UINib(nibName: "TimelineTableViewCell", bundle: Bundle.main)
viewController.tableView.register(timelineTableViewCellNib,
forCellReuseIdentifier: "TimelineTableViewCell")
}
SwiftUI calls updateUIViewController(_:context:) when it wants you to update the configuration for the presented view controller. Much of the setup you would typically do in viewDidLoad() in a UIKit view will go into this method. For the moment, you load the nib for the timeline cell and register it in the UITableView using the viewController passed into this method. Note that you’re using the Nib you included in the main app bundle. Hopefully, the next version of Swift Package Manager will fix this limitation.
Connecting delegates, data sources and more
If you’re familiar with UITableView in iOS, you might wonder how you provide the data source and delegates to this UITableViewController. You have the required data inside the struct, but if you try accessing that data directly from UIKit, your app will crash. Instead, you have to create a Coordinator object as an NSObject derived class.
This class acts as a transition or bridge between the data inside SwiftUI and the external framework. You can see context passed in as the second parameter in the updateUIViewController(_:context:) method. Add the following code for the new class at the top of FlightTimeline, outside the struct:
class Coordinator: NSObject {
var flightData: [FlightInformation]
init(flights: [FlightInformation]) {
self.flightData = flights
}
}
You’re creating the class along with a custom initializer to pass in the flight information to the class. This Coordinator will allow you to connect the delegate and data source for the UITableView. You could also use it to deal with user events.
You need to tell SwiftUI about the Coordinator class. Add the following code to the top of the FlightTimeline struct:
func makeCoordinator() -> Coordinator {
Coordinator(flights: flights)
}
This creates the coordinator and returns it to the SwiftUI framework to pass in where necessary. SwiftUI will call makeCoordinator() before makeUIViewController(context:) so it’s available during the creation and configuration of your non-SwiftUI components.
You can now implement UITableViewDelegate and UITableViewDataSource in the Coordinator class for your UITableView. You won’t use UITableViewDelegate for this UITableView, but you will implement UITableViewDataSource. Add the following class extension after the current Coordinator class definition:
extension Coordinator: UITableViewDataSource {
func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
flightData.count
}
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath)
-> UITableViewCell {
let timeFormatter = DateFormatter()
timeFormatter.timeStyle = .short
timeFormatter.dateStyle = .none
let flight = self.flightData[indexPath.row]
let scheduledString =
timeFormatter.string(from: flight.scheduledTime)
let currentString =
timeFormatter.string(from: flight.currentTime ??
flight.scheduledTime)
let cell = tableView.dequeueReusableCell(
withIdentifier: "TimelineTableViewCell",
for: indexPath) as! TimelineTableViewCell
var flightInfo = "\(flight.airline) \(flight.number) "
flightInfo = flightInfo +
"\(flight.direction == .departure ? "to" : "from")"
flightInfo = flightInfo + " \(flight.otherAirport)"
flightInfo = flightInfo + " - \(flight.flightStatus)"
cell.descriptionLabel.text = flightInfo
if flight.status == .cancelled {
cell.titleLabel.text = "Cancelled"
} else if flight.timeDifference != 0 {
var title = "\(scheduledString)"
title = title + " Now: \(currentString)"
cell.titleLabel.text = title
} else {
cell.titleLabel.text =
"On Time for \(scheduledString)"
}
cell.titleLabel.textColor = UIColor.black
cell.bubbleColor = flight.timelineColor
return cell
}
}
These two methods provide data for the UITableView. tableView(_, numberOfRowsInSection) returns the number of items in the array as the number of items in the table. In tableView(_:cellForRowAt:), you use the TimelineTableViewCell registered in the updateUIViewController(_:context:) method to create a timeline cell for that flight and return it. Note that this class and control know nothing about SwiftUI. The code you’ve used here works as it does in UIKit.
Now that you’ve implemented a UITableViewDataSource, you can set it for the UITableView. Add the following line to the top of updateUIViewController(_:context:):
viewController.tableView.dataSource = context.coordinator
Now you can add the new view to the app. Open ContentView.swift and add the following code before the NavigationLink to the Awards view:
NavigationLink(destination:
FlightTimeline(flights: self.flightInfo)) {
Text("Flight Timeline")
}
Build and run the app. Tap on the Flight Timeline button, and you’ll see the new timeline in action:
It doesn’t take a lot of work to integrate pre-existing Apple frameworks into your SwiftUI app. Over time, you’ll likely move more of your app’s functionality to SwiftUI when possible. The ability to integrate SwiftUI in your legacy apps gives you a tidy way to begin using SwiftUI, without having to start from scratch.
In the next section, you’ll build a more complex SwiftUI view that will reflect more of the work you’ll be doing as part of that transition.
Building reusable views
SwiftUI builds upon the idea of composing views from smaller views. Because of this, you can often end up with huge blocks of views within views within views, as well as SwiftUI views that span screens of code.
Splitting components into separate views makes your code cleaner. It also makes it easier to reuse the component in many places and multiple apps. In this chapter, you’re going to rework the Awards view from Chapter 13: “Drawing and Custom Graphics”, from its current iteration as a single, vertical list, into a grid. Don’t worry if you haven’t worked through that chapter, as the starter app for this chapter contains everything you need.
Build and run the app. Tap on the Awards button to bring up the award view, and you’ll see a single scrolling list of the awards. As you might guess, it’s built from a SwiftUI List(). It would be nice to have the awards display in a grid, instead of a list, to save the user from excess scrolling.
In a UIKit app, you’d likely use a UICollectionView to create the grid. Unfortunately, the current version of SwiftUI doesn’t have an equivalent to this popular view. So you’re going to create your own grid in SwiftUI in place of the UICollectionView.
You’ll first need to create an array holding information on all the current awards. The first three awards are those you built in Chapter 13: “Drawing and Custom Graphics”. The remaining bits draw a curve from passed parameters. You also provide a title and description for each award and for testing you set all awards awarded. The second property filters the full array to only the awarded items to show in the view.
Now you can update the view to use this list.
You’re not going to build a full replacement for UICollectionView, but instead you’ll create a grid view similar to a UICollectionView grid.
Create a new SwiftUI View in the MountainAirport group. Name the file GridView.swift.
It’s useful to keep a new solution simple in development instead of trying to do everything at once. The initial grid will only store and display integers. That doesn’t seem too exciting, but you’ll expand it to be more flexible later in the chapter.
In GridView.swift, add a property to hold the array of integers to the top of the struct:
var items: [Int]
Now change the body view to:
ScrollView {
VStack {
ForEach(0..<items.count) { index in
Text("\(self.items[index])")
}
}
}
Make sure the preview is available. Update the preview to pass an array of integers for the preview:
GridView(items: [11, 3, 7, 17, 5, 2])
Next, you’ll change this list into a grid.
Displaying a grid
There are several ways to organize a grid, but the most common one is to create a set of rows that consist of several columns. The items in the grid begin at the first row and first column and continue horizontally across the first row. Then, the next row picks up where the first row stops. This repeats until you reach the end of the items to display.
If there are fewer items than needed to fill the final row, the grid leaves them empty. In SwiftUI terms, you can build a grid as a VStack consisting of HStack views for each row. The contents of the HStack correspond to the columns of the grid.
Add a new parameter to set the number of columns before the items property:
var columns: Int
The original loop inside the VStack goes through each element of the array. You’ll change the loop to go through rows instead as the VStack will now wrap each row of the grid.
Add the following property after items to calculate the number of rows:
var numberRows: Int {
guard items.count > 0 else {
return 0
}
return (items.count - 1) / columns + 1
}
This code first checks to make sure there are elements in the array. If not, then it returns zero. Otherwise, you calculate that one row is needed for each columns elements in the array along with an extra row for any remaining elements.
Note: A common point of confusion when working with arrays is that while you usually think of numbers as starting at one, arrays start counting at zero. An array of three items indexes those items as zero, one and two.
Change the ForEach loop to:
// 1
ForEach(0..<self.numberRows) { row in
HStack {
// 2
ForEach(0..<self.columns) { column in
// 3
Text("\(self.items[row * self.columns + column])")
}
}
}
Here are the changes:
-
As noted, you’re now looping through rows inside the
VStack. Note that you number the rows starting at zero through one less than the number of rows calculated. As with an array, you number three rows as row zero, one and two. -
You know the number of columns, so you loop through each column again starting at zero and ending at the number one less than the number of columns.
-
To display the element of the array, you calculate the index that corresponds to the row and column.
Because an array starts at zero, this calculation is more straightforward when you start counting rows and columns at zero. For the start of each row, you multiply the row number by the number of columns in each row.
The first row of any grid starts with zero. You then add the number of the column, so the first column of that row is at index zero, the second at index one, and so on. The next row begins at the index matching the next element.
Update the preview to add the columns field:
GridView(columns: 2, items: [11, 3, 7, 17, 5, 2, 1])
The results make a pretty good grid.
There is a hidden problem, though. Add another element to the array by changing the preview to the following:
GridView(columns: 2, items: [11, 3, 7, 17, 5, 2, 1])
You’ll now get a rather unhelpful error in the preview, so you’ll need to run the view in debug mode for more useful information. Hold down Ctrl and click the Play button, and select Debug Preview from the menu.
You’ll now get a much more useful error: Fatal error: Index out of range. The last time through the loop row will be 3, and column will be 1. The loop then attempts to access index 7. As the array only has seven elements — zero through six — you’re trying to access an element that doesn’t exist in the array. You can add a function to perform this calculation and determine if the element doesn’t exist. Unless the number of elements is a multiple of the number of columns, you’ll have a few empty columns on the last row.
To deal with this situation, add the following function below the numberRows property:
func elementFor(row: Int, column: Int) -> Int? {
let index = row * self.columns + column
return index < items.count ? index : nil
}
This function takes the current row and column and performs the same calculation as before. It then checks that the index is a valid position in the array. If not, it returns nil to indicate this fact. Otherwise, it returns a valid index.
If you’re experienced in Swift, you might think you can now change the code at comment //3 to the following:
if let index = self.elementFor(row: row, column: column) {
Text("\(self.items[index])")
}
Go ahead; make the change and see what happens. You’ll get an error message: Closure containing control flow statement cannot be used with function builder 'ViewBuilder'. Unfortunately, the current implementation of SwiftUI doesn’t deal with optionals simply. You can use a conditional, just not one that also unwraps an optional. Change the code to:
if self.elementFor(row: row, column: column) != nil {
Text(
"\(self.items[self.elementFor(row: row, column: column)!])")
}
It’s a more tedious way to write the same code, but this works in SwiftUI. You first check if the result of the function is not nil and if so, then you display the element at that index by calling the function and forcing the unwrap.
This looks closer to what you want, but the last row doesn’t look quite right as it doesn’t line up with the rest of the grid. When you reach the end of that last row, the code needs to add some spacing to fill in the otherwise empty space.
You’ll need to add an else after the if statement to display an empty text field. Change the code to match the following:
if self.elementFor(row: row, column: column) != nil {
Text(
"\(self.items[self.elementFor(row: row, column: column)!])")
} else {
Text("")
}
You’ve built a nice adaptable grid of integers, and you’ve already seen that it will adapt to the list changing underneath. Change the number of columns by changing the preview to:
GridView(columns: 3, items: [11, 3, 7, 17, 5, 2, 1])
You’ll see the grid change to three columns. Now that you have the underlying grid in place, you’ll let the caller specify the view inside the grid.
Using a ViewBuilder
The grid in the current form always shows a Text view. You could create a series of grids for each needed view: GridTextView, GridImageView and so on. However, it would be much more useful to let the caller specify what to display in each cell of the grid. That’s where the SwiftUI ViewBuilder comes in.
Recall the initial code for this list below:
ForEach(0..<items.count) { index in
Text("\(self.items[index])")
}
This provides a view inside the ForEach loop that you passed in. ForEach uses a ViewBuilder to create a parameter for the view-producing enclosure. You’ll now update the GridView so it can take such an enclosure to define the contents of each cell in the grid.
Change the definition of the GridView to the following:
struct GridView<Content>: View where Content: View {
Now add a parameter after items to store the Content you just defined:
let content: (Int) -> Content
You also need to create a custom initializer for the View. Add the following initializer below the content parameter:
init(columns: Int, items: [Int],
@ViewBuilder content: @escaping (Int) -> Content) {
self.columns = columns
self.items = items
self.content = content
}
This new initializer accepts an enclosure named Content along with the previous number of columns and an array of integers. It also defines that the enclosure will receive a single Int parameter.
With these changes, you can now specify an enclosure for the GridView. Your loop can then display the enclosure for each element in the grid. You can use the parameter to pass the current element of the array into the enclosure.
You’ll now change the grid to use the content parameter. Change the ForEach loop under the //1 comment to:
ForEach(0..<self.numberRows) { row in
HStack {
ForEach(0..<self.columns) { column in
Group {
if self.elementFor(row: row, column: column) != nil {
self.content(
self.items[
self.elementFor(row: row, column: column)!])
} else {
Spacer()
}
}
}
}
}
Instead of including views within the loop, you call the content view which contains the enclosure, and you pass the current element of the integer array as a parameter to the enclosure. Note that you need to wrap the conditional inside a Group so the two cases act as a single element.
Change the preview to see the new grid in action:
GridView(columns: 3, items: [11, 3, 7, 17, 5, 2, 1]) { item in
Text("\(item)")
}
You’ll notice that the layout for the page looks a little off, as the last element doesn’t line up with the rest of the grid. To correct that, you’ll fix the grid’s spacing.
Spacing the grid
For this grid, you’ll divide the size of the view among the columns, and you can use a GeometryReader to get the view’s size. Wrap the ScrollView of your GridView with a geometry reader by adding this code around the ScrollView:
GeometryReader { geometry in
ScrollView {
// Omitted code
}
}
Now change the self.content() enclosure to the following:
self.content(
self.items[self.elementFor(row: row, column: column)!])
.frame(width: geometry.size.width / CGFloat(self.columns),
height: geometry.size.width / CGFloat(self.columns))
Here, you divide the width of the view given by the GeometryReader object by the number of columns for the grid. This evenly distributes the width among the columns. You then apply a frame to the view with the height and width set to that value. When using the grid, you’ll need to make sure that the number of columns for the grid provides enough space for the contents.
You have a pretty capable grid, but it still only works with an array of integers. The solution for this problem comes in a feature of Swift for just the case when you need to write code independent of specific data types — generics.
Making the grid generic
Generics allow you to write code without being specific about the type of data you’re using. You can write a function once, and use it on any data type.
First change the declaration of the view to:
struct GridView<Content, T>: View where Content: View {
You’re saying here that you want to use a generic type in the struct. Instead of specifying Int, String or another type, you can now specify T. You can now change the instances of the Int array into an array of type T instead. Change the declaration of the items property to:
var items: [T]
You also need to change the type for the parameter passed into the enclosure. Change the definition of the Content property to:
let content: (T) -> Content
You’ll also need to make the change to the custom initializer. Change it to:
init(columns: Int, items: [T],
@ViewBuilder content: @escaping (T) -> Content) {
And you’re done. Seriously! Generics let you pivot from a specific reference of an Int to the generic represented by T. Swift handles the rest. You’ll see that your grid still works.
Using the grid
Now that you’ve written the grid view, you can update the award view to use it. Open AirportAwards.swift and change the view to:
VStack {
Text("Your Awards (\(activeAwards.count))")
.font(.title)
GridView(columns: 2, items: activeAwards) { item in
VStack {
item.awardView
Text(item.title)
}.padding(5)
}
}
The same grid you used to show integers in the preview shows the awards here. That’s the power of SwiftUI, Swift and generics. In this section, you’ve taken a plain list and encapsulated the views on that page into an array. You then built a view that can display any Array as a grid where you can specify how to display the grid. Great work!
Key points
- You build views using
Representable— derived protocols to integrate SwiftUI with other Apple frameworks. - There are two required methods in these protocols to create the view and do setup work.
- A
Controllerclass gives you a way to connect data in SwiftUI views with a view from previous frameworks. You can use this to manage delegates and related patterns. - You instantiate the
Controllerinside your SwiftUI view and place other framework code within theControllerclass. - Combining
VStack,HStackandZStackwill let you create more complex layouts. - You can use a
ViewBuilderto pass views into another view when doing iterations. - Generics let your views work without hard-coding specific types.
Challenge
As written, the GridView calculates an even split for each column and sets each element to a square of that size.
You could, instead, pass the calculated size of the grid cell to the enclosure and let it determine the layout. Change the GridView to do this and update the Awards View to use the updated grid.
Solution
You can add more parameters to pass into the enclosure. You add the calculated width — a CGFloat — as a new parameter. Change the definition of content to:
let content: (CGFloat, T) -> Content
Then update the initializer to include the new parameter:
init(columns: Int, items: [T],
@ViewBuilder content: @escaping (CGFloat, T) -> Content) {
self.columns = columns
self.items = items
self.content = content
}
You change the call to self.content inside the loop to pass the calculated width to the enclosure instead of applying it to the enclosure.
self.content(geometry.size.width / CGFloat(self.columns),
self.items[self.elementFor(row: row, column: column)!])
You then can use the width inside your enclosure. For the preview, you would change the enclosure to:
GridView(columns: 3, items: [11, 3, 7, 17, 5, 2, 1]) { gridWidth, item in
Text("\(item)")
.frame(width: gridWidth, height: gridWidth)
}
and change the call to the GridView in AirportAwards to:
GridView(columns: 2, items: activeAwards) { gridWidth, item in
VStack {
item.awardView
Text(item.title)
}.frame(width: gridWidth, height: gridWidth)
}