Abstract
The requirements of the app I'm working on make me use DateComponents
as a bridge when working with time - specifically hours and minutes. Another requirement made me make DateComponents: Comparable
and that's when I found some nasty behavior, I can't explain.
The idea
For the sake of the argument, assume that there is a component in which the user may choose the hour of his liking. He can either pick a pre-defined time of tweak it.
As app's interface needs to react to changes, it shall pick the enum closest to the hour set.
Enum
enum TimeOfDay: String {
case morning
case evening
}
Extension with function
extension TimeOfDay {
private static let morningHours =
DateComponents(hour: 0, minute: 0)...DateComponents(hour: 15, minute: 00)
static func appropriateTimeOfDay(for time: DateComponents) -> Self {
var timeOfDay: Self?
if morningHours.contains(time) {
timeOfDay = .morning
} else {
timeOfDay = .evening
}
return timeOfDay!
}
}
The problem
Since I want to use func contains(_ element: Bound) -> Bool
, my Bound
, being DateComponents
, must conform to Comparable
. I took a sheet of paper, ran some examples through it, and this implementation seemed pretty reasonable.
In my code I made sure that the DateComponents.hour
and DateComponents.minute
are never nil
wherever they should be compared, hence forced unpacking.
Comparable extension
extension DateComponents: Comparable {
public static func < (lhs: DateComponents, rhs: DateComponents) -> Bool {
return lhs.hour! < rhs.hour! || lhs.minute! < rhs.minute!
}
}
However, I ran into problems. The tests shown that, although the contains
worked on full hours in range, the time was not contained in range whenever minutes came into play.
Test
Sample test for those to wish to check it themselves.
func testDateComponentsWithMinutesContainedInRange() {
let range = DateComponents(hour: 0, minute: 0)...DateComponents(hour: 15, minute: 0)
let hours = [
DateComponents(hour: 00, minute: 1),
DateComponents(hour: 12, minute: 30),
DateComponents(hour: 14, minute: 59),
]
// conditions for ClosedRange.contains
// all passed
for hour in hours {
XCTAssert(range.lowerBound < hour)
XCTAssert(hour < range.upperBound)
XCTAssertNotEqual(hour, range.lowerBound)
XCTAssertNotEqual(hour, range.upperBound)
}
// ClosedRange.contains
for time in hours {
XCTAssert(range.contains(time), "failed for \(time.hour!):\(time.minute!)")
}
}
The solution
After a few good hours of scratching my head, I've found this wonderful answer that solved the problem. Code, for the convenience of readers, pasted below.
// [post][1]
// [author][2]
extension DateComponents: Comparable {
public static func < (lhs: DateComponents, rhs: DateComponents) -> Bool {
let now = Date()
let calendar = Calendar.current
return calendar.date(byAdding: lhs, to: now)! < calendar.date(byAdding: rhs, to: now)!
}
}
My question remains - what was wrong with my attempt? I was interested in checking the hours and minutes of DateComponents
only, and those are both Int?
.
My wild guess is that the fact that the minute has 60 (effectively, 59) seconds made the comparator in contains
go bonkers.