Home > other >  How to make hours and minutes of Swift's DateComponents comparable if not this way?
How to make hours and minutes of Swift's DateComponents comparable if not this way?

Time:09-30

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?)

  • Related