Chapters

Hide chapters

SwiftUI by Tutorials

Second Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section II: Building Blocks of SwiftUI

Section 2: 6 chapters
Show chapters Hide chapters

15. Complex Interfaces
Written by Bill Morefield

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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.

class Coordinator: NSObject {
  var flightData: [FlightInformation]

  init(flights: [FlightInformation]) {
    self.flightData = flights
  }
}
func makeCoordinator() -> Coordinator {
  Coordinator(flights: flights)
}
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
  }
}
    viewController.tableView.dataSource = context.coordinator
NavigationLink(destination:
  FlightTimeline(flights: self.flightInfo)) {
  Text("Flight Timeline")
}

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.

var items: [Int]
ScrollView {
  VStack {
    ForEach(0..<items.count) { index in
      Text("\(self.items[index])")
    }
  }
}
GridView(items: [11, 3, 7, 17, 5, 2])

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.

var columns: Int
var numberRows: Int {
  guard  items.count > 0 else {
    return 0
  }
  
  return (items.count - 1) / columns + 1
}
// 1
ForEach(0..<self.numberRows) { row in
  HStack {
    // 2
    ForEach(0..<self.columns) { column in
      // 3
      Text("\(self.items[row * self.columns + column])")
    }
  }
}
GridView(columns: 2, items: [11, 3, 7, 17, 5, 2, 1])

GridView(columns: 2, items: [11, 3, 7, 17, 5, 2, 1])
func elementFor(row: Int, column: Int) -> Int? {
  let index = row * self.columns + column
  return index < items.count ? index : nil
}
if let index = self.elementFor(row: row, column: column) {
  Text("\(self.items[index])")
}
if self.elementFor(row: row, column: column) != nil {
  Text(
    "\(self.items[self.elementFor(row: row, column: column)!])")
}

if self.elementFor(row: row, column: column) != nil {
  Text(
    "\(self.items[self.elementFor(row: row, column: column)!])")
} else {
  Text("")
}

GridView(columns: 3, items: [11, 3, 7, 17, 5, 2, 1])

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.

ForEach(0..<items.count) { index in
  Text("\(self.items[index])")
}
struct GridView<Content>: View where Content: View {
let content: (Int) -> Content
init(columns: Int, items: [Int],
     @ViewBuilder content: @escaping (Int) -> Content) {
  self.columns = columns
  self.items = items
  self.content = content
}
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()
        }
      }
    }
  }
}
GridView(columns: 3, items: [11, 3, 7, 17, 5, 2, 1]) { item in
  Text("\(item)")
}

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

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.

struct GridView<Content, T>: View where Content: View {
var items: [T]
let content: (T) -> Content
init(columns: Int, items: [T],
     @ViewBuilder content: @escaping (T) -> Content) {

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

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 Controller class 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 Controller inside your SwiftUI view and place other framework code within the Controller class.
  • Combining VStack, HStack and ZStack will let you create more complex layouts.
  • You can use a ViewBuilder to 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.

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
init(columns: Int, items: [T],
     @ViewBuilder content: @escaping (CGFloat, T) -> Content) {
  self.columns = columns
  self.items = items
  self.content = content
}
self.content(geometry.size.width / CGFloat(self.columns),
      self.items[self.elementFor(row: row, column: column)!])
GridView(columns: 3, items: [11, 3, 7, 17, 5, 2, 1]) { gridWidth, item in
  Text("\(item)")
    .frame(width: gridWidth, height: gridWidth)
}
GridView(columns: 2, items: activeAwards) { gridWidth, item in
  VStack {
    item.awardView
    Text(item.title)
  }.frame(width: gridWidth, height: gridWidth)
}
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now