Chapters

Hide chapters

SwiftUI by Tutorials

Fifth Edition · iOS 16, macOS 13 · Swift 5.8 · Xcode 14.2

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

21. 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!

SwiftUI does 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 also work through building a reusable view that can display other views in a grid. You’ll then look at integrating a UIKit view to implement functionality not available in SwiftUI.

Building Reusable Views

SwiftUI builds upon the idea of composing views from smaller views. Because of this, you often end up with 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.

Open the starter project for this chapter. Build and run the app. Tap on the Flight Timeline button to bring up the empty timeline view. Right now, it shows a scrollable list of the flights. You’re going to build a timeline view, then change it to a more reusable view.

Simple flight list
Simple flight list

It’s useful to keep a new solution simple in development instead of trying to do everything at once. You will initially build the timeline specific to your view. First, you’ll work on the cards.

Open FlightCardView.swift inside the Timeline folder and add the following view above FlightCardView:

struct DepartureTimeView: View {
  var flight: FlightInformation

  var body: some View {
    VStack {
      if flight.direction == .arrival {
        Text(flight.otherAirport)
      }
      Text(
        shortTimeFormatter.string(
          from: flight.departureTime)
      )
    }
  }
}

This view displays the departure and arrival times for the flight with the airport’s name above the other end’s time.

Add the following code after the just added view:

struct ArrivalTimeView: View {
  var flight: FlightInformation

  var body: some View {
    VStack {
      if flight.direction == .departure {
        Text(flight.otherAirport)
      }
      Text(
        shortTimeFormatter.string(
          from: flight.arrivalTime
        )
      )
    }
  }
}

Now to use those new views. Inside FlightCardView, add the following code at the end of the VStack:

HStack(alignment: .bottom) {
  DepartureTimeView(flight: flight)
  Spacer()
  ArrivalTimeView(flight: flight)
}

Run the app, view the Flight Timeline and you’ll see your changes.

Simple flight cards
Simple flight cards

Showing Flight Progress

Next, you’ll add an indicator of the progress of a flight to the card. The status of a flight will usually be either before departure or after landing. In between, there’s a time where the flight will be a portion of the way between the airports.

func minutesBetween(_ start: Date, and end: Date) -> Int {
  // 1
  let diff = Calendar.current.dateComponents(
    [.minute], from: start, to: end
  )
  // 2
  guard let minute = diff.minute else {
    return 0
  }
  // 3
  return abs(minute)
}
func flightTimeFraction(flight: FlightInformation) -> CGFloat {
  // 1
  let now = Date()
  // 2
  if flight.direction == .departure {
    // 3
    if flight.localTime > now {
      return 0.0
    // 4
    } else if flight.otherEndTime < now {
      return 1.0
    } else {
      // 5
      let timeInFlight = minutesBetween(
        flight.localTime, and: now
      )
      // 6
      let fraction =
        Double(timeInFlight) / Double(flight.flightTime)
      // 7
      return CGFloat(fraction)
    }
  } else {
    if flight.otherEndTime > now {
      return 0.0
    } else if flight.localTime < now {
      return 1.0
    } else {
      let timeInFlight = minutesBetween(
        flight.otherEndTime, and: now
      )
      let fraction =
        Double(timeInFlight) / Double(flight.flightTime)
      return CGFloat(fraction)
    }
  }
}

Adding Inline Drawings

In this section you’ll add a view to show the flight progress. Add the following view after the ArrivalTimeView view struct:

struct FlightProgressView: View {
  var flight: FlightInformation
  var progress: CGFloat

  var body: some View {
    // 1
    GeometryReader { proxy in
      Image(systemName: "airplane")
        .resizable()
        // 2
        .offset(x: proxy.size.width * progress)
        .frame(width: 25, height: 25)
        .foregroundColor(flight.statusColor)
      // 3
    }.padding([.trailing], 20)
  }
}
FlightProgressView(
  flight: flight,
  progress: flightTimeFraction(
    flight: flight
  )
)
Showing flight progress
Dmibedw gzacrk vgilpamy

Using a ViewBuilder

The timeline you’ve created always shows the same view. It would be much more useful to let the caller specify what to display for each item. That’s where the SwiftUI ViewBuilder comes in.

ForEach(flights) { flight in
  FlightCardView(flight: flight)
}
struct GenericTimeline<Content>: View where Content: View {
// 1
let flights: [FlightInformation]
let content: (FlightInformation) -> Content

// 2
init(
  flights: [FlightInformation],
  @ViewBuilder content: @escaping (FlightInformation) -> Content
) {
  self.flights = flights
  self.content = content
}

// 3
var body: some View {
  ScrollView {
    VStack {
      ForEach(flights) { flight in
        // 4
        content(flight)
      }
    }
  }
}
GenericTimeline(
  flights: FlightData.generateTestFlights(
    date: Date()
  )
) { flight in
  FlightCardView(flight: flight)
}
ScrollView {
  VStack {
    ForEach(flights) { flight in
      FlightCardView(flight: flight)
    }
  }
}
GenericTimeline(flights: flights) { flight in
  FlightCardView(flight: flight)
}
Timeline with enclosed view
Fegisebo mern irflujig giew

Making the Timeline 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 GenericTimeline<Content, T>: View where Content: View {
var events: [T]
let content: (T) -> Content
init(
  events: [T],
  @ViewBuilder content: @escaping (T) -> Content
) {
  self.events = events
  self.content = content
}
GenericTimeline(
  events: FlightData.generateTestFlights(
    date: Date()
  )
) { flight in
  FlightCardView(flight: flight)
}
ScrollView {
  VStack {
    ForEach(events) { flight in
      content(flight)
    }
  }
}
ScrollView {
  VStack {
    ForEach(events.indices, id: \.self) { index in
      content(events[index])
    }
  }
}
GenericTimeline(events: flights) { flight in
Generic timeline
Lopepoh nugolope

Using Keypaths

A KeyPath lets you refer to a property on an object. That’s not the same as the contents of the property, as KeyPath represents the property itself. You use them quite often in SwiftUI. In fact, you’re already using them. In the last section you used a KeyPath in the following code:

ForEach(events.indices, id: \.self) { index in
let timeProperty: KeyPath<T, Date>
init(
  events: [T],
  timeProperty: KeyPath<T, Date>,
  @ViewBuilder content: @escaping (T) -> Content
) {
  self.events = events
  self.content = content
  self.timeProperty = timeProperty
}
GenericTimeline(
  events: FlightData.generateTestFlights(
    date: Date()
  ),
  timeProperty: \.localTime
) { flight in
  FlightCardView(flight: flight)
}
var earliestHour: Int {
  let flightsAscending = events.sorted {
    // 1
    $0[keyPath: timeProperty] < $1[keyPath: timeProperty]
  }

  // 2
  guard let firstFlight = flightsAscending.first else {
    return 0
  }
  // 3
  let hour = Calendar.current.component(
    .hour,
    from: firstFlight[keyPath: timeProperty]
  )
  return hour
}
var latestHour: Int {
  let flightsAscending = events.sorted {
    $0[keyPath: timeProperty] > $1[keyPath: timeProperty]
  }

  guard let firstFlight = flightsAscending.first else {
    return 24
  }
  let hour = Calendar.current.component(
    .hour,
    from: firstFlight[keyPath: timeProperty]
  )
  return hour + 1
}
func eventsInHour(_ hour: Int) -> [T] {
  return events
    .filter {
      let flightHour =
        Calendar.current.component(
          .hour,
          from: $0[keyPath: timeProperty]
        )
      return flightHour == hour
    }
}
func hourString(_ hour: Int) -> String {
  let tcmp = DateComponents(hour: hour)
  if let time = Calendar.current.date(from: tcmp) {
    return shortTimeFormatter.string(from: time)
  }
  return "Unknown"
}
ScrollView {
  VStack(alignment: .leading) {
    // 1
    ForEach(earliestHour..<latestHour, id: \.self) { hour in
      // 2
      let hourFlights = eventsInHour(hour)
      // 3
      Text(hourString(hour))
        .font(.title2)
      // 4
      ForEach(hourFlights.indices, id: \.self) { index in
        content(hourFlights[index])
      }
    }
  }
}

Using the Timeline

First let’s give a nicer appearance to the cards. Open FlightCardView.swift and add the following at the end of the VStack:

.padding()
.background(
  Color.gray.opacity(0.3)
)
.clipShape(
  RoundedRectangle(cornerRadius: 20)
)
.overlay(
  RoundedRectangle(cornerRadius: 20)
    .stroke()
)
GenericTimeline(
  events: flights,
  timeProperty: \.localTime) { flight in
    FlightCardView(flight: flight)
}
Completed timeline
Wihzpuzeq banabiro

Integrating With Other Frameworks

SwiftUI continues to add new features, but it can’t do everything possible in UIKit or AppKit. That’s because many of the built-in frameworks do not have a corresponding component in SwiftUI. Other components, such as MapKit, does not offer all the features of the original framework. 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 look at using your generic timeline with MapKit.

import SwiftUI
import MapKit

struct FlightMapView: UIViewRepresentable {
  var startCoordinate: CLLocationCoordinate2D
  var endCoordinate: CLLocationCoordinate2D
  var progress: CGFloat
}
func makeUIView(context: Context) -> MKMapView {
  MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
  // 1
  let startPoint = MKMapPoint(startCoordinate)
  let endPoint = MKMapPoint(endCoordinate)

  // 2
  let minXPoint = min(startPoint.x, endPoint.x)
  let minYPoint = min(startPoint.y, endPoint.y)
  let maxXPoint = max(startPoint.x, endPoint.x)
  let maxYPoint = max(startPoint.y, endPoint.y)

  // 3
  let mapRect = MKMapRect(
    x: minXPoint,
    y: minYPoint,
    width: maxXPoint - minXPoint,
    height: maxYPoint - minYPoint
  )
  // 4
  let padding = UIEdgeInsets(
    top: 10.0,
    left: 10.0,
    bottom: 10.0,
    right: 10.0
  )
  // 5
  view.setVisibleMapRect(
    mapRect,
    edgePadding: padding,
    animated: true
  )
  // 6
  view.mapType = .mutedStandard
  view.isScrollEnabled = false
}
struct MapView_Previews: PreviewProvider {
  static var previews: some View {
    FlightMapView(
      startCoordinate: CLLocationCoordinate2D(
        latitude: 35.655, longitude: -83.4411
      ),
      endCoordinate: CLLocationCoordinate2D(
        latitude: 36.0840, longitude: -115.1537
      ),
      progress: 0.67
    )
    .frame(width: 300, height: 300)
  }
}
Wrapped mapview
Jgijfiz jolmueh

Connecting Delegates, Data Sources and More

If you’re familiar with MKMap in iOS, you might wonder how you provide the delegate to add overlays to this MKMapView. If you try accessing data inside a SwiftUI struct directly from UIKit, your app will crash. Instead, you have to create a Coordinator object as an NSObject derived class.

class MapCoordinator: NSObject {
  var mapView: FlightMapView
  var fraction: CGFloat

  init(
    _ mapView: FlightMapView,
    progress: CGFloat = 0.0
  ) {
    self.mapView = mapView
    self.fraction = progress
  }
}
func makeCoordinator() -> MapCoordinator {
  MapCoordinator(self, progress: progress)
}
let startOverlay = MKCircle(
  center: startCoordinate,
  radius: 10000.0
)
let endOverlay = MKCircle(
  center: endCoordinate,
  radius: 10000.0
)
let flightPath = MKGeodesicPolyline(
  coordinates: [startCoordinate, endCoordinate],
  count: 2
)
view.addOverlays([startOverlay, endOverlay, flightPath])
extension MapCoordinator: MKMapViewDelegate {
  func mapView(
    _ mapView: MKMapView,
    rendererFor overlay: MKOverlay
  ) -> MKOverlayRenderer {
    if overlay is MKCircle {
      let renderer = MKCircleRenderer(overlay: overlay)
      renderer.fillColor = UIColor.black
      renderer.strokeColor = UIColor.black
      return renderer
    }

    if overlay is MKGeodesicPolyline {
      let renderer = MKPolylineRenderer(overlay: overlay)
      renderer.strokeColor = UIColor(
        red: 0.0,
        green: 0.0,
        blue: 1.0,
        alpha: 0.3
      )
      renderer.lineWidth = 3.0
      renderer.strokeStart = 0.0
      renderer.strokeEnd = fraction
      return renderer
    }

    return MKOverlayRenderer()
  }
}
func makeUIView(context: Context) -> MKMapView {
  let view = MKMapView(frame: .zero)
  view.delegate = context.coordinator
  return view
}
FlightMapView(
  startCoordinate: flight.startingAirportLocation,
  endCoordinate: flight.endingAirportLocation,
  progress: flightTimeFraction(
    flight: flight
  )
)
.frame(width: 300, height: 300)
Timeline with map
Mizetobu ninb jom

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.
  • You can use a ViewBuilder to pass views into another view when doing iterations.
  • Generics let your views work without hard-coding specific types.
  • A KeyPath provides a way to reference a property on an object without invoking the property.

Where to Go From Here?

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