10

iOS Date() returns date with at least microsecond precision.
I checked this statement by calling Date().timeIntervalSince1970 which results in 1490891661.074981

Then I need to convert date into string with microsecond precision.
I am using DateFormatter in following way:

let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZZZZZ"
print(formatter.string(from: date))

which results in
"2017-03-30T16:34:21.075000Z"

Now if we compare two results:
1490891661.074981 and "2017-03-30T16:34:21.075000Z"
we can notice that DateFormatter rounds date to millisecond precision while still presenting zeros for microseconds.

Does anybody know how to configure DateFormatter so I can keep microseconds and get correct result: "2017-03-30T16:34:21.074981Z"?

rmaddy
  • 314,917
  • 42
  • 532
  • 579
Vlad Papko
  • 13,184
  • 4
  • 41
  • 57
  • 1
    [Here's a solution](http://stackoverflow.com/a/27412262/1822214) for converting from a `String` to `Date` with microsecond precision, maybe it will help – Forest Kunecke Mar 30 '17 at 17:00
  • The precision of (NS)DateFormatter is limited to milliseconds, compare [NSDateFormatter milliseconds bug](http://stackoverflow.com/questions/23684727/nsdateformatter-milliseconds-bug). – Martin R Mar 30 '17 at 17:18
  • @MartinR Thanks for reference. Seems like I need to implement custom solution. – Vlad Papko Mar 30 '17 at 17:38

4 Answers4

11

Thanks to @MartinR for solving first half of my problem and to @ForestKunecke for giving me tips how to solve second half of the problem.

Based on their help I created ready to use solution which converts date from string and vice versa with microsecond precision:

public final class MicrosecondPrecisionDateFormatter: DateFormatter {

    private let microsecondsPrefix = "."
    
    override public init() {
        super.init()
        locale = Locale(identifier: "en_US_POSIX")
        timeZone = TimeZone(secondsFromGMT: 0)
    }
    
    required public init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override public func string(from date: Date) -> String {
        dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
        let components = calendar.dateComponents(Set([Calendar.Component.nanosecond]), from: date)

        let nanosecondsInMicrosecond = Double(1000)
        let microseconds = lrint(Double(components.nanosecond!) / nanosecondsInMicrosecond)
        
        // Subtract nanoseconds from date to ensure string(from: Date) doesn't attempt faulty rounding.
        let updatedDate = calendar.date(byAdding: .nanosecond, value: -(components.nanosecond!), to: date)!
        let dateTimeString = super.string(from: updatedDate)
        
        let string = String(format: "%@.%06ldZ",
                            dateTimeString,
                            microseconds)

        return string
    }
    
    override public func date(from string: String) -> Date? {
        dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
        
        guard let microsecondsPrefixRange = string.range(of: microsecondsPrefix) else { return nil }
        let microsecondsWithTimeZoneString = String(string.suffix(from: microsecondsPrefixRange.upperBound))
        
        let nonDigitsCharacterSet = CharacterSet.decimalDigits.inverted
        guard let timeZoneRangePrefixRange = microsecondsWithTimeZoneString.rangeOfCharacter(from: nonDigitsCharacterSet) else { return nil }
        
        let microsecondsString = String(microsecondsWithTimeZoneString.prefix(upTo: timeZoneRangePrefixRange.lowerBound))
        guard let microsecondsCount = Double(microsecondsString) else { return nil }
        
        let dateStringExludingMicroseconds = string
            .replacingOccurrences(of: microsecondsString, with: "")
            .replacingOccurrences(of: microsecondsPrefix, with: "")
        
        guard let date = super.date(from: dateStringExludingMicroseconds) else { return nil }
        let microsecondsInSecond = Double(1000000)
        let dateWithMicroseconds = date + microsecondsCount / microsecondsInSecond
        
        return dateWithMicroseconds
    }
}

Usage:

let formatter = MicrosecondPrecisionDateFormatter()
let date = Date(timeIntervalSince1970: 1490891661.074981)
let formattedString = formatter.string(from: date) // 2017-03-30T16:34:21.074981Z
Vlad Papko
  • 13,184
  • 4
  • 41
  • 57
2

The resolution of (NS)DateFormatter is limited to milliseconds, compare NSDateFormatter milliseconds bug. A possible solution is to retrieve all date components (up to nanoseconds) as numbers and do a custom string formatting. The date formatter can still be used for the timezone string.

Example:

let date = Date(timeIntervalSince1970: 1490891661.074981)

let formatter = DateFormatter()
formatter.dateFormat = "ZZZZZ"
let tzString = formatter.string(from: date)

let cal = Calendar.current
let comps = cal.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond],
                               from: date)
let microSeconds = lrint(Double(comps.nanosecond!)/1000) // Divide by 1000 and round

let formatted = String(format: "%04ld-%02ld-%02ldT%02ld:%02ld:%02ld.%06ld",
                       comps.year!, comps.month!, comps.day!,
                       comps.hour!, comps.minute!, comps.second!,
                       microSeconds) + tzString

print(formatted) // 2017-03-30T18:34:21.074981+02:00
Community
  • 1
  • 1
Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
  • That was fast. Thanks. To convert it back from string to date I would need to parse `2017-03-30T18:34:21:074981+02:00` right? – Vlad Papko Mar 30 '17 at 18:00
  • @VladPapko: You somehow have to extract the microsecond part and parse it separately. I think that is what the code in the link from Forest Kunecke does (in Objective-C). – Just note that double precision means 15-17 decimal digits, so converting to a string and back might not give an *identical* result. – Martin R Mar 30 '17 at 18:09
2

Solution by @Vlad Papko has some issue:

For dates like following:

2019-02-01T00:01:54.3684Z

it can make string with extra zero:

2019-02-01T00:01:54.03684Z

Here is fixed solution, it's ugly, but works without issues:

override public func string(from date: Date) -> String {
        dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
        let components = calendar.dateComponents(Set([Calendar.Component.nanosecond]), from: date)

        let nanosecondsInMicrosecond = Double(1000)
        let microseconds = lrint(Double(components.nanosecond!) / nanosecondsInMicrosecond)

        // Subtract nanoseconds from date to ensure string(from: Date) doesn't attempt faulty rounding.
        let updatedDate = calendar.date(byAdding: .nanosecond, value: -(components.nanosecond!), to: date)!
        let dateTimeString = super.string(from: updatedDate)

        let stingWithMicroseconds = "\(date.timeIntervalSinceReferenceDate)"
        let dotIndex = stingWithMicroseconds.lastIndex(of: ".")!
        let hasZero = stingWithMicroseconds[stingWithMicroseconds.index(after: dotIndex)] == "0"
        let format = hasZero ? "%@.%06ldZ" : "%@.%6ldZ"

        let string = String(format: format,
                            dateTimeString,
                            microseconds)

        return string
    }
1

It is a bit of a hack, but not that complex and 100% Swift:

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.'MICROS'xx"
dateFormatter.locale = Locale(identifier: "en_US_POSIX")

// Get the number of microseconds with a precision of 6 digits
let now = Date()
let dateParts = Calendar.current.dateComponents([.nanosecond], from: now)
let microSeconds = Int((Double(dateParts.nanosecond!) / 1000).rounded(.toNearestOrEven))
let microSecPart = String(microSeconds).padding(toLength: 6, withPad: "0", startingAt: 0)

// Format the date and add in the microseconds
var timestamp = dateFormatter.string(from: now)
timestamp = timestamp.replacingOccurrences(of: "MICROS", with: microSecPart)
Yvo
  • 18,681
  • 11
  • 71
  • 90