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.
CodePudding user response:
Your extension makes no sense.
Consider this example:
let lhs = DateComponents(hour: 13, minute: 2)
let rhs = DateComponents(hour: 12, minute: 12)
print(lhs < rhs)
That prints true, even though 13:02 is clearly after (aka greater than) 12:12.
You would have to do math to calculate a total number of minutes and compare those minutes values. For hours and minutes that manual calculation wouldn't be too bad, but when you get into days, weeks, and months, it gets messy. (How many days in a month? Which month? How many days in a year? Is it a leap year?)
Adding dateComponents to some date does make at least some sense. When you add 2 different sets of date components to a fixed date, the resulting Date (which is Comparable) will be greatest when you add a DateComponents object that describes the longest span of time.
Bear in mind that adding components to a Date has gotchas as well, depending on what units you are working with, and the date you use as your "base date". (e.g. I add a month to the current date. How many days is that? Depends. Is it February, with 29 Days, or January, with 31 days?)