New Date.FormatStyle: Anyway to do 24 hour?

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

5 Likes