Reading time: 2 min

This recipe shows how to implement a timeline list in SwiftUI. Timeline list has a line, usually on the side, that connects rows / cells tied to the same date. The end result looks like this:

Screenshot%202021-11-18%20at%2013.38.17

The example list in this recipe deals with a simple structure that represents an appointment, defined by its date and message:

struct Appointment {
  let date: Date
  let message: String
}

Below is the code for TimelineList that renders a List of appointments with a timeline on the left-hand side. First appointment in the day has a blue dot on its line.

To learn how to remove list separators, check out this recipe!

struct TimelineList: View {
  // change these to visually style the timeline
  private static let lineWidth: CGFloat = 2
  private static let dotDiameter: CGFloat = 8

  let items: [Appointment]

  private let dateFormatter: DateFormatter

  init(_ items: [Appointment]) {
    self.items = items
    dateFormatter = DateFormatter()
    // the format of the dates on the timeline
    dateFormatter.dateFormat = "EEE\ndd" 
  }

  var body: some View {
    List(Array(items.enumerated()), id: \.offset) { index, item in
      rowAt(index, item: item)
        // removes spacing between the rows
        .listRowInsets(EdgeInsets())
        // hides separators on SwiftUI 3, for other versions
        // check out https://swiftuirecipes.com/blog/remove-list-separator-in-swiftui
        .listRowSeparator(.hidden)
    }
  }

  @ViewBuilder private func rowAt(_ index: Int, item: Appointment) -> some View {
    let calendar = Calendar.current
    let date = item.date
    let hasPrevious = index > 0
    let hasNext = index < items.count - 1
    let isPreviousSameDate = hasPrevious 
        && calendar.isDate(date, inSameDayAs: items[index - 1].date)

    HStack {
      ZStack {
        Color.clear // effectively centers the text
        if !isPreviousSameDate {
          Text(dateFormatter.string(from: date))
            .font(.system(size: 14))
            .multilineTextAlignment(.center)
        }
      }
      .frame(width: 30)

      GeometryReader { geo in
        ZStack {
          Color.clear
          line(height: geo.size.height,
               hasPrevious: hasPrevious,
               hasNext: hasNext,
               isPreviousSameDate: isPreviousSameDate)
        }
      }
      .frame(width: 10)

      Text(item.message)
    }
  }

  // this methods implements the rules for showing dots in the
  // timeline, which might differ based on requirements
  @ViewBuilder private func line(height: CGFloat,
                                 hasPrevious: Bool,
                                 hasNext: Bool,
                                 isPreviousSameDate: Bool) -> some View {
    let lineView = Rectangle()
      .foregroundColor(.black)
      .frame(width: TimelineList.lineWidth)
    let dot = Circle()
      .fill(Color.blue)
      .frame(width: TimelineList.dotDiameter,
             height: TimelineList.dotDiameter)
    let halfHeight = height / 2
    let quarterHeight = halfHeight / 2
    if isPreviousSameDate && hasNext {
      lineView
    } else if hasPrevious && hasNext {
      lineView
      dot
    } else if hasNext {
      lineView
        .frame(height: halfHeight)
        .offset(y: quarterHeight)
      dot
    } else if hasPrevious {
      lineView
        .frame(height: halfHeight)
        .offset(y: -quarterHeight)
      dot
    } else {
      dot
    }
  }
}

Finally, here's how you can put it to use:

let today = Date()
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
let twoDaysAgo = Calendar.current.date(byAdding: .day, value: -2, to: today)!
let threeDaysAgo = Calendar.current.date(byAdding: .day, value: -3, to: today)!

struct TimelineTest: View {
  var body: some View {
    TimelineList([
      Appointment(date: today, message: "Dentist"),
      Appointment(date: today, message: "Business meeting"),
      Appointment(date: today, message: "Gym"),
      Appointment(date: yesterday, message: "Client meeting"),
      Appointment(date: yesterday, message: "Groceries"),
      Appointment(date: twoDaysAgo, message: "Gym"),
      Appointment(date: threeDaysAgo, message: "Board meeting"),
      Appointment(date: threeDaysAgo, message: "Meeting follow-up"),
    ])
  }
}

Next Post Previous Post