Set 12 vs 24 hour with the new date formatting API in iOS 15

Using the new date formatting API available in iOS 15, I would like to respect the 12 vs 24 hour setting on a user-by-user basis.

With DateFormatter it can be done as follows, for example, to force a 24 hour time irrespective of the user locale.

let formatter = DateFormatter()
formatter.dateFormat = "HH:mm" // or hh:mm for 12 h

However, I cannot figure out how to force the new date formatting API in iOS 15 to do the same thing, i.e. force displaying 12 vs 24 h.

I'm using a format style like this:

var formatStyle = dateTime
formatStyle.calendar = calendar
if let timeZone = timeZone {
    formatStyle.timeZone = timeZone
}
return formatStyle

And applying it like this, for example, for a given Date instance:

date.formatted(formatStyle.hour(.defaultDigits(amPM: .abbreviated)))

However, this seems to always give a formatted string relevant to the locale, for example, it's always 12h for the US and always 24h for the UK. This is not the behaviour I really need though, and I'd like to allow each individual user to control what they see irrespective of the locale setting.

(Note that there is an in-app setting that allows the user to toggle between the 12 and 24 hour preference, which is the value I'd like to use to set the preference.)

With DateFormatter it can be done as follows, for example, to force a 24 hour time irrespective of the user locale.

let formatter = DateFormatter()
formatter.dateFormat = "HH:mm" // or hh:mm for 12 h

If you’re goal is to display a time to the user then this code is wrong. Not all countries use : to separate hour and minute. If you have a specific set of components you want to display, the API to use is setLocalizedDateFormatFromTemplate(_:). For example:

let df = DateFormatter()
df.locale = Locale(identifier: "fi_FI")
df.setLocalizedDateFormatFromTemplate("HHmm")
let d1 = Date(timeIntervalSinceReferenceDate: 668510852.0)
let s1 = df.string(from: d1)
print(s1) // 9.27
let d2 = Date(timeIntervalSinceReferenceDate: 668554052.0)
let s2 = df.string(from: d2)
print(s2) // 21.27

So, to confirm:

  • You’re displaying a time to the user.

  • You’re trying to display 24-hour time regardless of the user’s preferences.

Is that right?

Also:

  • Do you absolutely require a two digit hour? Or do you want the width of any hours before 10 to vary by locale?

  • In the 12-hour case, do you want the am/pm markers?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Actually, I do not want to use DateFormatter at all

Understood. However, my experience is that, if you can’t make it work with DateFormatter then you probably won’t be able to make it work with the new API [1].

And I’d still like to get a better understanding of what “it” is. I posed a number of questions in my previous post and I need answers to those before I can help you further.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] That’s not universally true, but it’s very common given that the new API is largely layered on top of the old one.

I’m sorry but I’m struggling to read your response here. Please repost it, using the reply box rather than as a comment.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Hi – here's my previous comments rewritten:

Thanks for getting back to me. So, let me try to explain what "it" is again. In a nutshell, I want to use the new date formatting API to display hours and minutes, but I want to control whether the hours are displayed in 12h format or 24h format. This can be done with DateFormatter but I'm currently trying to update some code: I want to replace all the DateFormatter usage with the new API. As an example, I have code using the new API such as date.formatted(formatStyle.hour(.defaultDigits(amPM: .abbreviated)).minute()) where formatStyle is something like in my original question and date is just a date instance.

However, I see no way to control whether the hour digits are provided as 12h or 24h format – they, understandably, come as what is relevant to the user locale by default. So, for example, a user in the US will see 12h digits, and a user in France will see 24h digits. But what I really want to do is respect a local user setting: I have a boolean toggle in my app that allows the user to select their preference (12h vs 24h clocks); if the user has selected that they prefer the 24h format, I want to force the new API to always give me 24h format, and similarly if the user selects the 12h format, I want the new API to always provide the 12h format, no matter what the device locale is. But I so far cannot find a way to do this.

Or, another way of putting it, let's look at the DateFormatter equivalent and see if this can be done with the new API: If we have a local user preference called isTwentyFourHourClock (a Bool) and I wanted to respect that setting I could use formatter.setLocalizedDateFormatFromTemplate(isTwentyFourHourClock ? "HHmm" : "hhmm"). Is there any way to achieve this with the date.formatted() method?

To answer your other questions: 

You’re displaying a time to the user? Yes. 

You’re trying to display 24-hour time regardless of the user’s preferences? I'm trying to respect the user's local in-app preference for 12h vs 24h formats. 

Do you absolutely require a two digit hour? Or do you want the width of any hours before 10 to vary by locale? I actually have a lot of different widgets in my app that display the current time. In some cases I display hours that are zero padded and some that are not necessarily padded. With the new API I see this can be done using .hour(.twoDigits(amPM: .abbreviated)) vs .hour(.defaultDigits(amPM: .abbreviated)).

In the 12-hour case, do you want the am/pm markers? Similarly for the am/pm markers, in some cases I show them, in others they are hidden. This is easily controlled with the Date.FormatStyle.Symbol.Hour.AMPMStyle options.

First, thanks for re-posting.

Second, I’m going to drop a link to Unicode Technical Standard #35 Unicode Locale Data Markup Language (LDML) Part 4: Dates, just for the benefit of Future Quinn™.

Third, I apologise in advance for harping on about the old API, but you’ll understand why in a second.

You wrote:

I could use formatter.setLocalizedDateFormatFromTemplate(isTwentyFourHourClock ? "HHmm" : "hhmm")

Unfortunately that doesn’t work, even for ‘simple’ cases like US English. Consider the code at the end of this post. Put that code into a test app and run it on an device. Now configure your device as follows:

  • Set Settings > General > Language & Region > Region to United States.

  • Set Settings > General > Date & Time > 24-Hour Time to On.

Now run the code. It prints this:

locale: en_US (current)
format: X .standard      X .preferred     X .force12Hour   X .force24Hour   X
    AM: X 09:42          X 09:42          X 09:42          X 09:42          X
    PM: X 21:42          X 21:42          X 21:42          X 21:42          X

locale: en_US (fixed)
format: X .standard      X .preferred     X .force12Hour   X .force24Hour   X
    AM: X 9:42 AM        X 9:42 AM        X 9:42 AM        X 09:42          X
    PM: X 9:42 PM        X 9:42 PM        X 9:42 PM        X 21:42          X

Note I’m testing with Xcode 13.2.1 on iOS 15.3.1.

The second group (en_US (fixed)) is as you expect but what about the first group? It’s using 24-hour time even in the .force12Hour case. This is a consequence of how the system deals with the 12-/24-hour override.

Honestly, I’m not sure how best to deal with this. I think it’s clear that I need to research it in more depth, and I don’t have time for that in the context of DevForums. If you’d like to get a definitive answer here, please open a DTS tech support incident. That’ll let me allocate the time to research this properly.

And this is all before we even get to the new API (-:

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

enum Format: CaseIterable {
    case standard
    case preferred
    case force12Hour
    case force24Hour
    
    func formatter(for locale: Locale) -> DateFormatter {
        // Creating and discarding formatters like this is /super/
        // inefficient but I don’t care in this test project.
        let df = DateFormatter()
        df.locale = locale
        df.timeZone = TimeZone(identifier: "GMT")!
        switch self {
        case .standard:
            df.timeStyle = .short
        case .preferred:
            df.setLocalizedDateFormatFromTemplate("jjmm")
        case .force12Hour:
            df.setLocalizedDateFormatFromTemplate("hhmm")
        case .force24Hour:
            df.setLocalizedDateFormatFromTemplate("HHmm")
        }
        return df
    }
}

func test(locale: Locale) {
    let dAM = Date(timeIntervalSinceReferenceDate: 668511720.0)
    let dPM = Date(timeIntervalSinceReferenceDate: 668554920.0)

    let results = Format.allCases.map { f in
        (
            label: ".\(f)",
            am: f.formatter(for: locale).string(from: dAM),
            pm: f.formatter(for: locale).string(from: dPM)
        )
    }

    func pad(_ s: String) -> String {
        "\(s)\(String(repeating: " ", count: max(0, 14 - s.count)))"
    }

    // I’m using `X` as a field separator because it‘s strongly
    // left-to-right.  Some of my testing involve Arabic, a right-to-left
    // language, and Xcode’s console does weird things if I use a separator,
    // like `|`, that’s not strongly left-to-right.

    print("locale: \(locale)")
    print("format: X \(results.map { pad($0.label) }.joined(separator: " X ")) X")
    print("    AM: X \(results.map { pad($0.am)    }.joined(separator: " X ")) X")
    print("    PM: X \(results.map { pad($0.pm)    }.joined(separator: " X ")) X")
    print()
}

func test() {
    test(locale: .current)
    test(locale: Locale(identifier: "en_US"))
}

Thanks a lot for posting that code – I see exactly what you mean.

I'm now thinking the best thing to do is to stick with DateFormatter after all and use df.setLocalizedDateFormatFromTemplate("jjmm") since that respects the 12/24h system setting, and get rid of my local in-app isTwentyFourHour Bool setting. Then I can use DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: self)?.contains("a") to determine the system setting wherever it's required. Unless there are any other pitfalls you might know about?

I noticed that when using the new date.formatted() API that the output does not respect the 12/24h system setting at all, which is not going to work well for my app. For example, if I set the device region to United States, it always provides a 12h format even when the [Settings > General > Date & Time > 24-Hour Time] system setting is turned on.

It seems you have to override the environment locale and switch between one that defaults to am/pm (like GB) and one that is 24hr (like US), e.g.

@State var is12h = false
let date = Date()
...
Text(date, format: .dateTime.hour(.twoDigits(amPM: .abbreviated)).minute(.twoDigits))
    .environment(\.locale, Locale(identifier: is12h ? "en_GB" : "en_US"))
...
11:01PM
23:01
Set 12 vs 24 hour with the new date formatting API in iOS 15
 
 
Q