New Date.FormatStyle: Anyway to do 24 hour?

Why do you cache date formatters, is it too slow without caching?

Creating and changing DateFormatter is expensive, it's not required but is common practice to cache if it used multiple times. The new FormatStyle is fast so no need to cache.

Watch What's new in Foundation starting at 14:30

44 microseconds per one date to string conversion is not exactly slow, and tradeoff is increased app complexity (maintaining cached formatters, invalidating them when needed), so it is not a clear win-win. Among other things it depends on a number of dates to convert: if to only do this for visible on screen elements (like 100 of them) caching seems unnecessary as it will be quick enough to be noticeable. I'd only start optimising this is profiler tells me that this is a bottleneck.

Note that the test quoted has a loop of:

    for _ in (0..<numberOfIterations) {
        let date = Date()
        let dateString = df.string(from: date)
    }

which is not entirely clean test: the dates will be very close to each other (many calls with the same value to a second precision) and data formatter might optimise this case internally ("same second as before? no need to calculate anything, return the same string as before").

Performance of the "old" DateFormatter is a little off topic. We were discussing the API's of a new feature that was built for convenience (and performance?). Surely there are millions of use cases that just can't capture "just use the old DateFormatter". Even in the example I gave, a timeline series can have hundreds of entries each requiring specific date formatting. The Foundation team nailed with the new FormatStyle and hoping our feedback can help improve it.

Very true.

Setting explicit date style's locale to one of the 24h country's locale fixes the issue:

    let date = Date(timeIntervalSince1970: 1645227803)
    var dateStyle = Date.FormatStyle.dateTime
    dateStyle.timeZone = TimeZone(identifier: "UTC")!
    dateStyle = dateStyle.hour(.twoDigits(amPM: .omitted)).minute()
    print(date.formatted(dateStyle)) // 11:43
    dateStyle.locale = Locale(identifier: "en_US")
    print(date.formatted(dateStyle)) // 11:43
    dateStyle.locale = Locale(identifier: "en_UK")
    print(date.formatted(dateStyle)) // 23:43

The full init() for Date.FormatStyle is:

init(date:time:locale:calendar:timeZone:capitalizationContext:)

The 2nd parameter type is TimeStyle, the doc is "No overview available.", but "Go to definition" in Xcode show:

    /// Predefined time styles varied in lengths or the components included. The exact format depends on the locale.
    public struct TimeStyle : Codable, Hashable {

        /// Excludes the time part.
        public static let omitted: Date.FormatStyle.TimeStyle

        /// For example, `04:29 PM`, `16:29`.
        public static let shortened: Date.FormatStyle.TimeStyle

        /// For example, `4:29:24 PM`, `16:29:24`.
        public static let standard: Date.FormatStyle.TimeStyle

        /// For example, `4:29:24 PM PDT`, `16:29:24 GMT`.
        public static let complete: Date.FormatStyle.TimeStyle

The comment:

The exact format depends on the locale.

"For example" in the comments seems to indicate locale determine AM/PM or 24-hour. If this is true, then it's not good because you may want to use 24-hour format in any locale.

One odd thing I see is the .omitted style doesn't actually omit:

date.formatted(Date.FormatStyle(date: .omitted, time: .omitted))  // 2/18/2022, 9:51 PM

Yep, it looks unpolished. I'd say, file the bugs and meanwhile use one of the workarounds discussed above.

Need way to force 24-hour time format regardless of Locale: FB9915353
DateStyle.omitted, TimeStyle.omitted: do not actually omit: FB9915360

1 Like

Scanning through the headers found something relevant:

/// Minimum digits to show the numeric hour. For example, `1`, `12`.
/// Or `23` if using the `twentyFourHour` clock.
/// - Note: This format does not take user's locale preferences into account. Consider using `defaultDigits` if applicable.
public static func defaultDigits(clock: Date.FormatStyle.Symbol.VerbatimHour.Clock, hourCycle: Date.FormatStyle.Symbol.VerbatimHour.HourCycle) -> Date.FormatStyle.Symbol.VerbatimHour

/// Numeric two-digit hour, zero padded if necessary.
/// For example, `01`, `12`.
/// Or `23` if using the `twentyFourHour` clock.
/// - Note: This format does not take user's locale preferences into account. Consider using `defaultDigits` if applicable.
public static func twoDigits(clock: Date.FormatStyle.Symbol.VerbatimHour.Clock, hourCycle: Date.FormatStyle.Symbol.VerbatimHour.HourCycle) -> Date.FormatStyle.Symbol.VerbatimHour

However no idea how to use it. These new style formatting APIs look very complex to me.

2 Likes
// this does 24-hour style:
Text(date, format: Date.VerbatimFormatStyle(format: "\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .oneBased)):\(minute: .twoDigits)", timeZone: .current, calendar: .current))

Look in:

Date.FormatString
    StringInterpolation

you can use StringInterplate to create a Date.FormatStyle to exactly how you want/

Don’t know about localization.

Thanks for giving me the clue !

Good night!

Good you solved it. AFAIU, the question of @BasemEmara still stands as (s)he wants to do this in a standalone manner without Text or other SwiftUI stuff.

BTW, this:

Date.VerbatimFormatStyle(format: "\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .oneBased)):\(minute: .twoDigits)"

is not nice in my book. There must be a simpler / cleaner way. Something as simple and short as what we used to have, just type safe. E.g. this (bike shedding):

date.string(format: .HH.mm.ss)

But maybe that's just old-school me.

1 Like

It's very close to your way for basic formatting with builder syntax:

date.formatted(.dateTime.hour().minute().second())

I just wish we can use this basic "builder" and can set the hour output to 24-hour and not going through Date.VerbatimFormatStyle. Because this take care of localization. VerbatimFormatStyle cannot be localized.

I wonder what this mean:

/// - Note: This format does not take user's locale preferences into account. Consider using defaultDigits if applicable.

It's already defaultDigits:

    /// Minimum digits to show the numeric hour. For example, `1`, `12`.
    /// Or `23` if using the `twentyFourHour` clock.
    /// - Note: This format does not take user's locale preferences into account. Consider using `defaultDigits` if applicable.
    public static func defaultDigits(clock: Date.FormatStyle.Symbol.VerbatimHour.Clock, hourCycle: Date.FormatStyle.Symbol.VerbatimHour.HourCycle) -> Date.FormatStyle.Symbol.VerbatimHour

Edit: So I'm pretty sure "Verbatim" means "verbatim": it cannot be localized. Looks like VerbatimFormatStyle is the only way to do 24-hour. So to allow for localization, you have to use Date.FormatStyle for other parts and use VerbatimFormatStyle for the hours and minutes in a format that is suitable for all locale.

It would be great if Date.FormatStyle can be in 24-hour and take care of localization.

Using a DateFormatter and caching its instance has always been solid:

let date = Date(timeIntervalSince1970: 1645227803)
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
formatter.timeZone = .init(secondsFromGMT: 0)
formatter.string(from: date)
// 23:43

Ah, um, that code is definitely not correct. If you’re setting up a user-visible date formatter, you can’t hard code format strings. This is the point I made upthread. The correct code for this is:

let date = Date(timeIntervalSince1970: 1645227803)
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate("jmm")
formatter.timeZone = .init(secondsFromGMT: 0)
formatter.string(from: date)
// 23:43

You get the same result but you don’t have to hard code the colon. For example, if you set the local to Finish:

formatter.locale = Locale(identifier: "fi_FI")

your code prints 23:43 whereas my code prints the correct 23.43.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

3 Likes

Thx @eskimo, I'd really like to use localized templates and use it everywhere else, but unfortunately I could not use it in this case. In North America for example, this doesn't give me 24hr notion:

let date = Date(timeIntervalSince1970: 1645227803)
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate("jmm")
formatter.timeZone = .init(secondsFromGMT: 0)
formatter.locale = .init(identifier: "en_US")
formatter.string(from: date)
// 11:43 PM

But I'm more interested in getting this to work with the new Date.FormatStyle. Is this possible or a way to create a custom one to achieve this?

I'm pretty sure there is no way to force Date.FormatStyle to show 24-hour format only. It follows whatever the locale's conventions.

To do 24-hour format, you have to use Date.VerbatimFormatStyle. See my post up thread. But unfortunately "verbatim" means format is hardcoded to whatever your format string (with the hour/minute separator hardcoded). It's not localized.

Or if there is a way to know what the hour/minute separator, you can insert that in the Date.VerbatimFormatStyle format string and make it localized. I don't see anyway to get this separator in Locale. or DateFormatter.

It's would be better if we can make Date.FormatStyle do 24-hour.

I file a feedback: FB9915353. You should also file feedback to ask for Date.FormatStyle 24-hour option.

Edit: Locale specific 24-hour time format:

extension Date.FormatString.StringInterpolation {
    public mutating func appendInterpolation(_ string: String) {
        appendLiteral(string)
    }
    public mutating func appendInterpolation(_ character: Character) {
        appendLiteral(String(character))
    }
}

extension Date {
    func twentyFourHourAndMinute(timeZone: TimeZone = .current, calendar: Calendar = .current) -> String {
        // get the locale specific hour/minute separator
        let separator = formatted(.dateTime.hour().minute()).first(where: { $0.isPunctuation })!
        return formatted(Date.VerbatimFormatStyle(format: "\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased))\(separator)\(minute: .twoDigits)",
                                                  timeZone: timeZone, calendar: calendar))
    }
    func twentyFourHourAndMinuteAndSecond(timeZone: TimeZone = .current, calendar: Calendar = .current) -> String {
        // get the locale specific hour/minute separator
        let separator = formatted(.dateTime.hour().minute()).first(where: { $0.isPunctuation })!
        return formatted(Date.VerbatimFormatStyle(format: "\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased))\(separator)\(minute: .twoDigits)\(separator)\(second: .twoDigits)",
                                                  timeZone: timeZone, calendar: calendar))
    }
}

...

    Text(date.twentyFourHourAndMinute())
    Text(date.twentyFourHourAndMinuteAndSecond())

But I'm more interested in getting this to work with the new
Date.FormatStyle.

Understood. However, my experience is that, if something is impossible with DateFormatter, there’s not much point message around with the new stuff.

In North America for example, this doesn't give me 24hr notion:

Indeed. That was caused by my misreading of TRS#35 combined with a failure to test correctly. Sorry for the confusion.

Consider this code:

import Foundation

let d1 = Date(timeIntervalSince1970: 1645227803 - 15 * 60 * 60)
let d2 = Date(timeIntervalSince1970: 1645227803)

func test(localeIdentifier: String) {
    let formatter = DateFormatter()
    formatter.locale = .init(identifier: localeIdentifier)
    formatter.setLocalizedDateFormatFromTemplate("Hmm")
    formatter.timeZone = .init(secondsFromGMT: 0)
    print("\(localeIdentifier):")
    print("  \(formatter.string(from: d1))")
    print("  \(formatter.string(from: d2))")
}

print("system: \(Locale.current.identifier)")
print("force 12: \(UserDefaults.standard.bool(forKey: "AppleICUForce12HourTime"))")
print("force 24: \(UserDefaults.standard.bool(forKey: "AppleICUForce24HourTime"))")
test(localeIdentifier: "en_US")
test(localeIdentifier: "fi_FI")
test(localeIdentifier: "en_GB")

IMPORTANT AppleICUForce12HourTime and AppleICUForce24HourTime are set by the System Preferences > Language & Region > General > Time Format checkbox when you override the locale’s 12-/24-hour default. These are not considered API. I’m including them here purely to clarify my testing.

I used Xcode 13.2.1 to put this in a macOS > Command Line Tool project and ran it on macOS 12.2.1. Here’s the results from multiple runs, changing the system locale in between:

system: en_GB
force 12: false
force 24: false
en_US:
  08:43
  23:43
fi_FI:
  8.43
  23.43
en_GB:
  08:43
  23:43

system: en_GB
force 12: true          -- overrides the UK’s default 24-hour time
force 24: false
en_US:
  08:43
  23:43
fi_FI:
  8.43
  23.43
en_GB:
  08:43
  23:43

system: en_US
force 12: false
force 24: false
en_US:
  08:43
  23:43
fi_FI:
  8.43
  23.43
en_GB:
  08:43
  23:43

force 12: false
force 24: true          -- overrides the US’s default 12-hour time
en_US:
  08:43
  23:43
fi_FI:
  8.43
  23.43
en_GB:
  08:43
  23:43

The results are consistent regardless of the system locale, including overrides to the locale’s 12-/24-hour default. This was the flaw in my previous testing.

The results in Finnish are weird. Every other locale pads the hour to two digits, but Finnish does not. That’s true even if you specific HH instead of H.

Is this lack of a leading zero a deal breaker for you?

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

2 Likes

I file a feedback: FB9915353.

Thank you.

Or if there is a way to know what the hour/minute separator

IMO a better way to do this would be to render the string to an attributed string and then find the span of the .hour field. You can then replace that field with whatever you want.

Pasted in below is a small snippet that shows the basics of how to work with date/time attributes.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

import Foundation

func prettyPrint(_ s: AttributedString) {
    print(String(s.characters))
    for r in s.runs {
        var marker: String
        switch s[r.range].dateField {
        case .hour?: marker = "h"
        case .minute?: marker = "m"
        case _?: marker = "?"
        case nil: marker = " "
        }
        let count = s[r.range].characters.count
        print(String(repeating: marker, count: count), terminator: "")
    }
    print()
}

let d1 = Date(timeIntervalSince1970: 1645227803 - 15 * 60 * 60)
let d2 = Date(timeIntervalSince1970: 1645227803)
let gmt = TimeZone(secondsFromGMT: 0)!

var fs = Date.FormatStyle(time: .shortened, timeZone: gmt).attributed

let s1 = d1.formatted(fs)
let s2 = d2.formatted(fs)
prettyPrint(s1)
// 8:43
// h mm
prettyPrint(s2)
// 23:43
// hh mm

Do you mean look into the AttributedString, find the AM/PM range, if it's "PM", change the hour range to twenty four hour value. Finally remove the AM/PM range? (but what about any space in from of AM/PM? How to get at that and also remove it?)

I dump out all the runs, the separator and space is Foundation.AttributedString._InternalRun, so just remove the one preceding AM/PM.

Edit: here is what I come up with:

// VerbatimHour option is Foundation private, so do this get at its option settings for all twentyFourHour options
extension Date.FormatStyle.Symbol.VerbatimHour {
    var isTwentyFourHour: Bool {
        [Date.FormatStyle.Symbol.VerbatimHour.twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased),
         .twoDigits(clock: .twentyFourHour, hourCycle: .oneBased),
         .defaultDigits(clock: .twentyFourHour, hourCycle: .zeroBased),
         .defaultDigits(clock: .twentyFourHour, hourCycle: .oneBased)
        ].contains(self)
    }
}

extension Date {
    /// Format this Date in twenty four hour format
    /// - Parameters:
    ///   - formatStyle: FormatStyle to format this Date. Should have hour part. If in AM/PM style, the hour is changed to 24-hour
    ///   - hour: how the hour should be formatted in 24-hour format
    /// - Returns: the result in an AttributedString
    func twentyFourHourFormatted(_ formatStyle: Date.FormatStyle, hour: Date.FormatStyle.Symbol.VerbatimHour) -> AttributedString {
        func scan(_ s: AttributedString) -> (Range<AttributedString.Index>?, Range<AttributedString.Index>?, Range<AttributedString.Index>?) {
            var hourRange: Range<AttributedString.Index>?
            // remember the range preceding AM/PM to remove only if it's a space
            var precedingAMPMRange: Range<AttributedString.Index>?
            var amPMRange: Range<AttributedString.Index>?
            var previousRun: AttributedString.Runs.Run?
            for r in s.runs {
                if s[r.range].dateField == .hour {
                    hourRange = r.range
                } else if s[r.range].dateField == .amPM {
                    amPMRange = r.range
                    // only remove if preceding run is not a `dateField`, (only remove space which is `Foundation.AttributedString._InternalRun`)
                    if previousRun?.dateField == nil {
                        precedingAMPMRange = previousRun?.range
                    }
                    // hour field always precede amPM?, so all done?
                    break
                }
                previousRun = r
            }
            return (hourRange, precedingAMPMRange, amPMRange)
        }

        assert(hour.isTwentyFourHour)
        var result = formatted(formatStyle.attributed)
        let (hourRange, precedingAMPMRange, amPMRange) = scan(result)
        let twentyFourHour = formatted(Date.VerbatimFormatStyle(format: "\(hour: hour)",
                                                                timeZone: formatStyle.timeZone,
                                                                calendar: formatStyle.calendar).attributed
        )
        if let hourRange = hourRange {
            // if there is AM/PM, remove it and its preceding delimiter
            // must remove in the order from end to start, otherwise index out-of-range!
            if let amPMRange = amPMRange {
                result.removeSubrange(amPMRange)
            }
            if let precedingAMPMRange = precedingAMPMRange {
                result.removeSubrange(precedingAMPMRange)
            }

            // fix up the hour field to twenty four hour format:
            result.replaceSubrange(hourRange, with: twentyFourHour)
        }
        return result
    }
}

Usage:

date.twentyFourHourFormatted(.dateTime.hour(.defaultDigits(amPM: .narrow)).minute().second(),
                                                      hour: .defaultDigits(clock: .twentyFourHour, hourCycle: .zeroBased))

Thank you @eskimo and @young!! All the help and code is so good :star_struck:

Do you mean look into the AttributedString

Sorry to be misleading here. I didn’t mean to suggest a concrete path to solving your specific problem, but rather point out a technique for dealing with a formatter’s output that doesn’t involve rummaging through the string looking at character classes. The latter, IME, does not end well.

Honestly, if I were in your shoes I’d stick with DateFormatter until Date.FormatStyle gets the features you need (FB9915353).

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

4 Likes