Swift DateComponentsFormatter drop leading zeroes but keep at least one digit in Minutes place
Asked Answered
G

4

11

I am using the following DateComponentsFormatter in Swift:

let formatter = DateComponentsFormatter()
formatter.unitsStyle   = .positional
formatter.allowedUnits = [.hour, .minute, .second]
formatter.zeroFormattingBehavior = [.default]

This produces results like 2:41 (for 2 minutes and 41 seconds) and 08 (for 8 seconds). I would, however, like to keep at least one digit in the Minutes place, returning 0:08 instead of 08. Is this possible with the DateComponentsFormatter? I would prefer a solution that returns a localized result.

Grenadier answered 12/2, 2019 at 1:8 Comment(0)
Q
17

With iOS 16 or later you can use the new Duration.TimeFormatStyle.Pattern.

Xcode 14.1 • Swift 5.7.1 • iOS 16 or later

extension TimeInterval {
    var duration: Duration { .seconds(self) }
    var positionalTime: String {
        duration.formatted(
            .time(pattern: self >= 3600 ? .hourMinuteSecond : .minuteSecond)
        )
    }
}

8.0.positionalTime     //    "0:08"
161.0.positionalTime   //    "2:41"
3600.0.positionalTime  // "1:00:00"

For older Xcode/Swift versions

Yes. This can be easily accomplished adding a ternary conditional based on the time interval when setting the date components formatter allowed units:


Xcode 11 • Swift 5.1

extension Formatter {
    static let positional: DateComponentsFormatter = {
        let positional = DateComponentsFormatter()
        positional.unitsStyle = .positional
        positional.zeroFormattingBehavior = .pad
        return positional
    }()
}

extension TimeInterval {
    var positionalTime: String {
        Formatter.positional.allowedUnits = self >= 3600 ?
                                            [.hour, .minute, .second] :
                                            [.minute, .second]
        let string = Formatter.positional.string(from: self)!
        return string.hasPrefix("0") && string.count > 4 ?
            .init(string.dropFirst()) : string
    }
}

Usage

8.0.positionalTime     //    "0:08"
161.0.positionalTime   //    "2:41"
3600.0.positionalTime  // "1:00:00"
Quits answered 12/2, 2019 at 1:59 Comment(7)
Thanks Leo, exactly what I was looking for!Grenadier
@jhk I will check itQuits
weird it returns "01:00:00" instead of "1:00:00"Quits
Yeah - I was going crazy until I looked at old screenshots of the app!Baran
I think you will have to manually deal with it if you don't want that leading zeroQuits
Beware that dodifying the static Formatter.positional is not thread safe. If this matters, either create the DateComponentsFormatter every time, or have two statics: Formatter.positionalHHMMSS and Formatter.positionalMMSS.Zeiger
@SimonPickup sure but it is beyond the scope of this question.Quits
R
2

It is possible to create an appropriate positional time string by modifying the allowedUnits and zeroFormattingBehavior based on the duration. Note that this will pad two leading zeros for minutes when it's desirable to only have one, so that case is handled afterwards in a safe manner.

let formatter = DateComponentsFormatter()
if duration < 60 {
    formatter.allowedUnits = [.minute, .second]
    formatter.zeroFormattingBehavior = .pad //pad seconds and minutes ie 00:05
} else if duration >= 60*60 {
    formatter.allowedUnits = [.hour, .minute, .second]
}

var formattedDuration = formatter.string(from: duration) ?? "0"
if formattedDuration.hasPrefix("00") {
    formattedDuration = String(formattedDuration.dropFirst()) //remove leading 0 ie 0:05
}

Here's a list of durations and formattedDurations:

0 ▶️ 0:00
5 ▶️ 0:05
30 ▶️ 0:30
60 ▶️ 1:00
65 ▶️ 1:05
3600 ▶️ 1:00:00
3665 ▶️ 1:00:05
3660 ▶️ 1:01:00

Alternate Solution

You can achieve the same result without checking for two leading zeros by utilizing the .dropTrailing zeroFormattingBehavior, interestingly enough. I will caution I don't understand exactly why this works and do not know what impact it may have on localization. Note that if duration is 0, the result is "0", so we can use .pad in this case to get a result of "00:00" instead. With that you end up in the same situation noted above but maybe you will feel that is an edge case that may not need handling.

let formatter = DateComponentsFormatter()
if duration >= 60 {
    formatter.allowedUnits = [.hour, .minute, .second]
    formatter.zeroFormattingBehavior = .default
} else if duration == 0 {
    formatter.allowedUnits = [.minute, .second]
    formatter.zeroFormattingBehavior = .pad
    //is 00:00 but should be 0:00
} else {
    formatter.allowedUnits = [.minute, .second]
    formatter.zeroFormattingBehavior = .dropTrailing //magic here
}
let formattedDuration = formatter.string(from: duration) ?? "0"
Reincarnation answered 3/7, 2020 at 21:54 Comment(0)
D
2

Came up with this solution, employing a regex, that seemingly covers all cases:

let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
formatter.unitsStyle = .positional
formatter.zeroFormattingBehavior = .pad

func durationString(for duration: TimeInterval) -> String {
    formatter.string(from: duration)!
        .replacingOccurrences(of: #"^00[:.]0?|^0"#, with: "", options: .regularExpression)
}

durationString(for: 0)      // 0:00
durationString(for: 1)      // 0:01
durationString(for: 10)     // 0:10
durationString(for: 60)     // 1:00
durationString(for: 600)    // 10:00
durationString(for: 3600)   // 1:00:00
durationString(for: 36000)  // 10:00:00
Diamagnet answered 22/6, 2021 at 18:41 Comment(0)
I
0

Check out a different zeroFormattingBehavior property value. I use this to get the result I think you're seeking, but experiment as necessary...

    formatter.zeroFormattingBehavior = [.pad]
Irfan answered 12/2, 2019 at 1:34 Comment(1)
This would display the hours as well "0:00:08" instead of "0:08". And even if you set the DateComponentsFormatter maximumUnitCount property to 2 won't solve the issue when using positional unit style.Quits

© 2022 - 2024 — McMap. All rights reserved.