New Date.FormatStyle: Anyway to do 24 hour?

Yes I'm trying to display 24-hour time format using the new date styles.

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

I use this to allow users to override the time display settings in the app instead of changing it in their device settings globally. I was hoping to use the new Date.FormatStyle like the original poster:

var dateStyle = Date.FormatStyle.dateTime
dateStyle.timeZone = .init(secondsFromGMT: 0) ?? .current
date.formatted(dateStyle.hour(.twoDigits(amPM: .omitted)).minute())
// 11:43

But it seems unresolved.. either to override the environment variable in SwiftUI or fallback to the DateFormatter again. Is there no way to achieve this using the new Date.FormatStyle?

Silently dropping AM/PM without the corresponding change to the digits returned looks weird indeed. Minimal example:

    import Foundation
    
    let date = Date(timeIntervalSince1970: 1645227803)
    var dateStyle = Date.FormatStyle.dateTime
    dateStyle.timeZone = TimeZone(identifier: "UTC")!
    let dateStyle12h = dateStyle.hour(.twoDigits(amPM: .abbreviated)).minute()
    let dateStyle24h = dateStyle.hour(.twoDigits(amPM: .omitted)).minute()
    print(date.formatted(dateStyle12h)) // 11:43 PM
    print(date.formatted(dateStyle24h)) // 11:43     huh???

Don't know how to fix this, but out of curiosity, what makes you pursue for the newer method and what's wrong with the older method that works solid for you?

The new FormatStyle is new and better replacement of old Formatter. The old Formatter can do 24 hour output, so should the new FormatStyle. If it cannot, then it's not a replacement and seems to be bug.

Would be wonderful if someone in the know can provide clarify.

The constraint on having to cache DateFormatters statically is really difficult in a timeline series when each timeline entry may need the date formatter adjusted.

For example, in my app the user can view details in another timezone.. in other words, they can impersonate being in other locations. The custom timezone would be stored in the users preferences/defaults so it can be used to generate the iOS widget or watchOS complication.

This means I cannot assume the date formatter will have the same configurations every time the timeline or entries are generated. I have to create a date formatter variable (not statically) per generation because preferences can change, and could diverge even per entry.

let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
formatter.timeZone = defaults.string(forKey: userInfo["timezone"]) ?? .current
formatter.string(from: date)
let time = formatter.string(from: entry.date) // for widget or complications

I had to overlook this until the new Date.FormatStyle was introduced and not having to cache the formatters anymore without worrying about resources was a big advantage for me because now I thought can improve my implementation and do this:

var dateStyle = Date.FormatStyle.dateTime
dateStyle.timeZone = .init(identifier: defaults.string(forKey: userInfo["timezone"])) ?? .current
let time = date.formatted(dateStyle.hour(.twoDigits(amPM: .omitted)).minute()) // for widget or complications

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

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.

// 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.

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