26
Nov
2021
SwiftUI Timeline list
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:
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"),
])
}
}