Building Engaging User Interfaces with SwiftUI

Mar 12 2025 · Swift 5.9, iOS 17.0, XCode 15.0

Lesson 02: Implementing Complex UI Layouts

Making the Timeline Generic

Episode complete

Play next episode

Next
Transcript

While you’ve allowed the user to pass in any view they wish, you still hard code the FlightInformation type in the view. Generics allow you to write code without being specific about the data type you’re using. You can write a function once and use it on any data type.

In GenericTimelineView.swift, change the declaration of the view to:

struct GenericTimelineView<Content, T>: View where Content: View, T: Identifiable {

You already used a generic in the last section when you placed Content inside angle brackets (< and >). Adding the T inside the angle brackets tells Swift that the struct will have a second generic parameter. Instead of specifying an Int, FlightInformation, or another type, you tell SwiftUI to refer to the type as T. There is no type T, but T represents whatever type you pass to the struct upon instantiation. The only constraint is it must conform to Identifiable to be used in ForEach. Next, you must revise the references to FlightInformation into the generic type T. Change the declaration of the flights property to:

let events: [T]

You’re also changing the name to reflect that this value no longer ties only to flights but would work with any event that meets the right criteria (more on that later). You also need to change the type of the parameter passed into the closure. Change the definition of the content property to:

let content: (T) -> Content

Instead of always passing in a FlightInformation object, SwiftUI knows you’ll pass in an object matching the type passed to the struct. You’ll also need to change the custom initializer to use T instead of the FlightInformation type and match the change of the flights property’s name to events. Change the init() method to:

init(
  events: [T],
  @ViewBuilder content: @escaping (T) -> Content
) {
  self.events = events
  self.content = content
}

Now, you must change flights references in the view to events. First, change the preview to use the new parameter name:

return GenericTimelineView(events: testFlights) { flight in
  FlightCardView(flight: flight)
}

Now, change the view to:

ScrollView {
  VStack {
    ForEach(events) { flight in
      content(flight)
    }
  }
}

Now, back in FlightTimelineView.swift, change the parameter on GenericTimelineView from flights to events:

GenericTimelineView(events: flights) { flight in

You’re done. Generics let you pivot from a specific reference to the generic represented by T in this case. Swift handles the rest.

Run the app to see that your timeline still works. Although you’ve changed your view to handle any type passed into it, you still have some assumptions about the object’s properties. You’ll fix that using another useful Swift feature when building reusable views — key paths.

Using Key Paths

A key path lets you refer to a property on an object. That’s not the same as the value of the property, as a key path represents the property itself. You use them quite often in SwiftUI. You’ve probably already used them if you’ve ever written code similar to:

ForEach(stores.indices, id: \.self) { index in

When using ForEach with a collection of objects, the object should implement Identifiable. If not, you pass a key path to a Hashable unique identifier of each object in the collection. The key path gives SwiftUI a property identifying each element uniquely. Here, \.self is a special key path referring to the object. Using it tells SwiftUI that the object uniquely identifies itself.

Because your timeline takes a generic type, meaning you could pass in any object, you need a way to specify the name of a property. In this case, you want to let the view know the name of the property containing the local time information. That’s a perfect use for a key path.

First, in GenericTimelineView.swift, add the following property after content:

let timeProperty: KeyPath<T, Date>

Declaring a KeyPath takes two parameters. The first is the type of object for it. In this case, you use the same T generic type you added in the previous section. The second parameter tells Swift that the parameter the KeyPath points to will be of type Date.

You also must update the init method to add the new property:

init(
  events: [T],
  timeProperty: KeyPath<T, Date>,
  @ViewBuilder content: @escaping (T) -> Content
) {
  self.events = events
  self.content = content
  self.timeProperty = timeProperty
}

Next, update the preview to pass in the new parameter:

return GenericTimelineView(
  events: testFlights,
  timeProperty: \.localTime
) { flight in
  FlightCardView(flight: flight)
}

This key path tells SwiftUI to use the localTime property of the FlightInformation object to determine each object’s time. Now that you can specify a key path, you can use it.

And now that you can indicate the time property, you can change the view to look more like a timeline. Add the following code after the init method:

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
}

This method takes the events and sorts them in ascending order by the specified property using the key path:

  1. The method first sorts the objects using the key path. The $0 syntax in the sorted method’s closure indicates one of the objects under evaluation. To access a property of it defined using a key path, you use the [keyPath: timeProperty] syntax.
  2. The first element should be the earliest. If there’s no first element — that is, if the array is empty — return the earliest possible hour in a day.
  3. You then get the hour component of the first element and return it. You again access the time property using firstFlight[keyPath: timeProperty].

Now, add a similar method after this one to get the latest hour in the events:

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
}

This method does the same thing, except it sorts from latest to earliest, so the first element will be the hour of the latest event. You then add an additional hour to create an open range for the loop. If there are no events, the method returns the latest possible hour in a day: 24.

Next, add a method to get the events within a specified hour:

func eventsInHour(_ hour: Int) -> [T] {
  return events
    .filter {
      let flightHour =
        Calendar.current.component(
          .hour,
          from: $0[keyPath: timeProperty]
        )
      return flightHour == hour
    }
}

Like the other two methods, this one uses the key path to filter only flights where the hour component of the time matches that passed into the method.

Add one more method:

func hourString(_ hour: Int) -> Date {
  let tcmp = DateComponents(hour: hour)
  guard let time = Calendar.current.date(from: tcmp) else { return Date() }
  return time
}

This one takes the passed hour and creates a Date object, reflecting it as a time. If it cannot create a Date object, it returns the current time.

Now, you’ll update the view using these new methods. Change the body for the GenericTimelineView to:

ScrollView {
  VStack(alignment: .leading) {
    // 1
    ForEach(earliestHour..<latestHour, id: \.self) { hour in
      // 2
      let hourFlights = eventsInHour(hour)
      // 3
      Text(hourString(hour), style: .time)
        .font(.title2)
      // 4
      ForEach(hourFlights.indices, id: \.self) { index in
        content(hourFlights[index])
      }
    }
  }
}

You’ve added a few more features to the timeline. Here are the new items:

  1. You now loop through the hours of events using the earliestHour and latestHour properties.
  2. You use the eventsInHour(_:) method to get only the events occurring during the hour of this pass through the loop.
  3. Each hour shows a header with the time using the hourString method and formatted as the .time style.
  4. You only loop through the hourFlights indices because you’re splitting the overall events into hours.

With a generic timeline done, you can now use it in your view. Back in FlightTimelineView.swift, update the GenericTimelineView to:

GenericTimelineView(
  events: flights,
  timeProperty: \.localTime) { flight in
    FlightCardView(flight: flight)
}

Run the app to see your improved timeline.

In this segment, you’ve built a timeline and encapsulated it so you can pass any object and display the results. Great work! That’s the power of Swift, SwiftUI, generics, and key paths. You can extend these concepts as far as you need for your use case.

See forum comments
Cinema mode Download course materials from Github
Previous: Using a View Builder Next: Conclusion