May 25, 2016 - Updated: December 06, 2016 - Swift 3.0
Lots of web APIs give us dates but coercing them into a usable format can be a pain. We get something in our JSON like "2014-12-10T16:44:31.486000Z"
or even just 1464192120
but we certainly can’t display those kinds of values to users. Today we’ll look at how to convert the dates that we get from APIs into Date
objects then how to get nice display strings from those Date
objects.
This tutorial has been updated using Swift 3.0.1, Alamofire 4.0.1, and Xcode 8.1.
Why would we want to store an Date
object instead of the string the server gives us? Having a real object (or struct) is pretty much always better than using a chunk of JSON throughout our app. Keep networking and parsing code separate from data models and UI code. Using Date
(and DateFormatter
when we want to display the dates) lets us handle a few things much more easily that storing the server’s date string (or shudder JSON):
- No worries about time zones, it’ll automatically use the user’s time zone (unless we specify one)
- Display in different formats (e.g., relative in table view, full date in detail view)
- Localization
We need to do 3 things:
- Get some JSON that includes a date string
- Turn that date string into a
Date
- Get a pretty display string for that
Date
After we’ve done it once, we’ll figure out how to parse few more common date formats that you might get from servers.
We’ve used the SWAPI API for a few previous tutorials. If we use the /people/1
endpoint we’ll get some data about Luke Skywalker:
{
"name": "Luke Skywalker",
"height": "172",
"mass": "77",
"hair_color": "blond",
"skin_color": "fair",
"eye_color": "blue",
"birth_year": "19BBY",
"gender": "male",
"homeworld": "http://swapi.co/api/planets/1/",
"films": [
"http://swapi.co/api/films/6/",
"http://swapi.co/api/films/3/",
"http://swapi.co/api/films/2/",
"http://swapi.co/api/films/1/",
"http://swapi.co/api/films/7/"
],
// ...
"created": "2014-12-09T13:50:51.644000Z",
"edited": "2014-12-20T21:17:56.891000Z",
"url": "http://swapi.co/api/people/1/"
}
Let’s grab the created
date and turn it into a Date
. This format is a fairly common one that you’ll run into when playing with REST APIs. Assuming you’ve created project and added Alamofire using Cocoapods, we can grab that date like this:
class APIController {
class func fetchLukeSkywalkerCreatedDate(completionHandler: @escaping (Date?) -> Void) {
let path = "https://swapi.co/api/people/1"
Alamofire.request(path)
.responseJSON { response in
guard let value = response.result.value else {
print("GET call on \(path) failed")
completionHandler(nil)
return
}
guard let json = value as? [String: AnyObject] else {
completionHandler(nil)
return
}
guard let createdDateString = json["created"] as? String else {
completionHandler(nil)
return
}
let dateFormatter = DateFormatter()
// TODO: implement dateFromSwapiString
let createdDate = dateFormatter.date(fromSwapiString: createdDateString)
completionHandler(createdDate)
}
}
}
Notice that we have a function that we still need to implement: dateFromSwapiString
. We’ll implement that as an extension to DateFormatter
:
extension DateFormatter {
func date(fromSwapiString dateString: String) -> Date? {
// SWAPI dates look like: "2014-12-10T16:44:31.486000Z"
self.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
self.timeZone = TimeZone(abbreviation: "UTC")
self.locale = Locale(identifier: "en_US_POSIX")
return self.dateFromString(dateString)
}
}
We need to do 3 things to convert a date from a server into a Date
. You might get away with skipping some of these steps sometimes but if you start supporting users in different places you’ll run into problems.
Set the Date Format
The toughest part is usually figuring out the date format string to use for your date string. The documentation from Apply isn’t very clear on that: Apple docs on Date Format Patterns. If you read it really carefully you’ll find this little bit:
To specify a custom fixed format for a date formatter, you use setDateFormat:. The format string uses the format patterns from the Unicode Technical Standard #35. The version of the standard varies with release of the operating system: OS X v10.9 and iOS 7 use version tr35-31.
Which links to that Unicode Technical Standard. Which actually has a table of that shows what substrings to put together to create your date format specifier. It’s kind of ugly though. NSDateFormatter.com is a lot prettier but it’s missing a few items (like sub-seconds).
Here’s what our date format to parse looks like:
"2014-12-10T16:44:31.486000Z"
Step through it starting at the beginning:
- 4 digit year:
2014
-
- 2 digit month:
12
-
- 2 digit day of month:
10
'T'
- Hours:
16
:
- Minutes:
44
:
- Seconds:
31
.
- Fractional seconds:
486000
- Time zone:
Z
Then look up each of those items in the Unicode standard table to find the substring for each one. Make sure you get the case correct, for example, dd
and DD
mean different things (day of the month vs day of the year). The literal strings -
, 'T'
, :
, and .
stay just as they are in the date string. So our date format string will be made up of:
- 4 digit year:
yyyy
-
- 2 digit month:
MM
-
- 2 digit day of month:
dd
'T'
- Hours:
HH
:
- Minutes:
mm
:
- Seconds:
ss
.
- Fractional seconds:
SSSSSS
(We could use fewer digits here if we don’t need the accuracy, e.g.,S
would give us just tenths of a second) - Time zone:
Z
Pulling all of that together we can set the dateFormat
string:
self.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
Set the Time Zone
An iOS device always has a local time zone but your server didn’t know about that when it sent the date. For your date formatter you should set the server’s date, usually GMT/UTC/Zulu. To tell the date formatter what the time zone is use TimeZone
:
self.timeZone = TimeZone(abbreviation: "UTC")
Set the Server Locale
Users have a locale set on their device that doesn’t necessarily match the one your server is using. For more info on locales, see the Apple Docs on Locale Settings. So we need to tell the date formatter what locale the server is using. Often it’s en_US_POSIX
. Check your API docs to see if it’s specified there if your date looks different from the SWAPI one:
self.locale = Locale(localeIdentifier: "en_US_POSIX")
Put It All Together
Assembling those bits, here’s our implementation of dateFromSwapiString
:
func dateFromSwapiString(dateString: String) -> Date? {
// SWAPI dates look like: "2014-12-10T16:44:31.486000Z"
self.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
self.timeZone = TimeZone(abbreviation: "UTC")
self.locale = Locale(localeIdentifier: "en_US_POSIX")
return self.dateFromString(dateString)
}
Finally, we can call our APIController
function that will fetch the date and convert it to a Date
:
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
APIController.fetchLukeSkywalkerCreatedDate {
date in
guard let date = date else {
print("Could not retrieve created date for Luke Skywalker")
return
}
print(date)
}
}
Displaying the Date
We probably don’t want to just stick with the ugly format that gives us:
2014-12-09 13:50:51 +0000
You might think that we need to make a new date format string for display but that’s actually not a good idea. What if our user is used to a different date format than we are? (I’m looking at you, USians, MM/dd/yyyy
is just plain odd). Fortunately iOS provides us with some handy pre-specified ways to display dates & times in short, medium, long, and full styles. To use them, create an DateFormatter
, set the dateStyle
& timeStyle
, then use string(from: date)
.
Adding that to our demo code:
APIController.fetchLukeSkywalkerCreatedDate {
date in
guard let date = date else {
print("Could not retrieve created date for Luke Skywalker")
return
}
print(date)
let dateFormatter = DateFormatter()
// short format like "12/9/14, 9:50 AM"
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short
// will default to user's time zone & locale
let displayString = dateFormatter.string(from: date)
print(displayString)
}
Prints:
12/9/14, 8:50 AM
(Or whatever is correct for your time zone, I’m in EST.)
Since we’re not specifying a time zone or locale for the date formatter, they’ll default to the user’s.
Play with the various styles to see what other formats you can get:
// short & short:
12/9/14, 8:50 AM
// full date, no time:
Tuesday, December 9, 2014
// medium date, long time:
Dec 9, 2014, 8:50:51 AM EST
Switching the iPhone’s language to French and changing nothing else still works (because we set the locale & time zone for the server DateFormatter
but not for the display one). In that case we’ll get different strings to display:
// Medium date, long time:
9 déc. 2014 à 08:50:51 UTC−5
Don’t forget to change your phone’s language back from French!
DateFormatters Are Expensive
Creating a DateFormatter
or changing format settings is an expensive operation. If you can, share a date formatter. For example, if you’re displaying dates in table view cells then use a single date formatter. Don’t create a new one for each cell.
Other Formats
Now that we’ve gone through the basic steps to parse a date from JSON and display it nicely, let’s try a few more.
GitHub Gists: 2015-02-23T08:00:00Z
The date format from the GitHub Gists API is almost the same as the SWAPI one, without the fractional seconds. We can just tweak the date format string to handle that:
func date(fromGitHubString dateString: String) -> Date? {
// looks like: "2015-02-23T08:00:00Z"
self.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
self.timeZone = TimeZone(abbreviation: "UTC")
self.locale = Locale(identifier: "en_US_POSIX")
return self.date(from: dateString)
}
You’ll also need to adjust the path in the JSON to get the created_at
date for a gist:
class func fetchMyFirstGitHubGistDate(completionHandler: @escaping (Date?) -> Void) {
let path = "https://api.github.com/users/cmoulton/gists"
Alamofire.request(path)
.responseJSON { response in
guard let value = response.result.value else {
print("GET call on \(path) failed")
completionHandler(nil)
return
}
guard let json = value as? [[String: AnyObject]] else {
completionHandler(nil)
return
}
guard let firstGist = json.first else {
completionHandler(nil)
return
}
guard let createdDateString = firstGist["created_at"] as? String else {
completionHandler(nil)
return
}
let dateFormatter = DateFormatter()
let createdDate = dateFormatter.date(fromGitHubString: createdDateString)
completionHandler(createdDate)
}
}
The JSON in this case contains an array of dictionaries:
guard let json = value as? [[String: AnyObject]] else {
return
}
We need to grab the first gist:
guard let firstGist = json.first else {
completionHandler(nil)
return
}
Then get the created date for that gist:
guard let createdDateString = firstGist["created_at"] as? String else {
completionHandler(nil)
return
}
Finally, try calling it:
APIController.fetchMyFirstGitHubGistDate {
date in
guard let date = date else {
print("Could not retrieve created date for my first gist")
return
}
print(date)
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short
// will default to user's time zone & locale
let displayString = dateFormatter.string(from: date)
print(displayString)
}
Date Only
You might also run into APIs that only return a date, like the NASA APOD API. To match that format we just leave out all of the components that we don’t have: the hours, minutes, seconds, and time zone.
func date(fromNASAString dateString: String) -> Date? {
// looks like: "2016-05-25"
self.dateFormat = "yyyy-MM-dd"
self.timeZone = TimeZone(abbreviation: "UTC")
self.locale = Locale(identifier: "en_US_POSIX")
return self.date(from: dateString)
}
class func fetchNASADate(completionHandler: @escaping (Date?) -> Void) {
let path = "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY"
Alamofire.request(path)
.responseJSON { response in
guard let value = response.result.value else {
print("GET call on \(path) failed")
completionHandler(nil)
return
}
guard let json = value as? [String: AnyObject] else {
completionHandler(nil)
return
}
guard let dateString = json["date"] as? String else {
completionHandler(nil)
return
}
let dateFormatter = DateFormatter()
let createdDate = dateFormatter.date(fromNASAString: dateString)
completionHandler(createdDate)
}
}
When we display that date we’ll need to make sure we don’t try to display a time:
APIController.fetchNASADate {
date in
guard let date = date else {
print("Could not retrieve NASA date")
return
}
print(date)
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .full
dateFormatter.timeStyle = .none
dateFormatter.doesRelativeDateFormatting = true
// will default to user's time zone & locale
let displayString = dateFormatter.string(from: date)
print(displayString)
}
We added a new bit there: dateFormatter.doesRelativeDateFormatting = true
. That’ll use relative date format like Today
or Yesterday
if it can. It doesn’t do relative time though (like 3 hours ago
). If you want relative time you have to do that yourself. Our code prints:
Yesterday
Unix Time
Finally, you’ll see APIs that return unix time: the number of seconds since midnight GMT on 1 Jan 1970. The Forecast.io API returns dates in that format. You’ll need to sign up to get an API key for Forecast.io but it’s free unless you make a lot of calls.
To parse these dates we don’t even need a DateFormatter
. Date
has the functionality we need built right in. To turn the unix time into an Date
all we need to do is call Date(timeIntervalSince1970: unixTime)
:
class func fetchForecastIODate(completionHandler: @escaping (Date?) -> Void) {
let API_KEY = "MY_API_KEY"
let path = "https://api.forecast.io/forecast/\(API_KEY)/37.8267,-122.423"
Alamofire.request(path)
.responseJSON { response in
guard let value = response.result.value else {
print("GET call on \(path) failed")
completionHandler(nil)
return
}
guard let json = value as? [String: AnyObject] else {
completionHandler(nil)
return
}
guard let currentWeatherJSON = json["currently"] as? [String: AnyObject] else {
completionHandler(nil)
return
}
// unixTime: seconds since midnight GMT on 1 Jan 1970
guard let unixTime = currentWeatherJSON["time"] as? Double else {
completionHandler(nil)
return
}
// look ma, no date formatter!
let createdDate = Date(timeIntervalSince1970: unixTime)
completionHandler(createdDate)
}
}
To test that:
APIController.fetchForecastIODate {
date in
guard let date = date else {
print("Could not retrieve date")
return
}
print(date)
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .short
let displayString = dateFormatter.string(from: date)
print(displayString)
}
And That’s All
That’s how you take a date string from a web service API, turn it into an Date
, and display it in your app in Swift. If you’re fighting with a different format just comment below and I’ll help out. If you’re stuck then plenty of other people probably are too.
If you’d like more Swift tutorials on topics like this one, sign up below to get them sent directly to your inbox.