How to convert NSDate in to relative format as "Today","Yesterday","a week ago","a month ago","a year ago"?

Solution 1:

For simplicity I'm assuming that the dates you are formatting are all in the past (no "tomorrow" or "next week"). It's not that it can't be done but it would be more cases to deal with and more strings to return.


You can use components:fromDate:toDate:options: with whatever combination of date components you are looking for to get the number of years, months, weeks, days, hours, etc. between two dates. By then going though them in order from most significant (e.g. year) to least significant (e.g. day), you can format a string based only on the most significant component.

For example: a date that is 1 week, 2 days and 7 hours ago would be formatted as "1 week".

If you want to create special strings for a special number of a unit, like "tomorrow" for "1 day ago" then you can check the value of that component after you have determined that it is the most significant component.

The code would look something like this:

- (NSString *)relativeDateStringForDate:(NSDate *)date
{
    NSCalendarUnit units = NSCalendarUnitDay | NSCalendarUnitWeekOfYear | 
                           NSCalendarUnitMonth | NSCalendarUnitYear;

    // if `date` is before "now" (i.e. in the past) then the components will be positive
    NSDateComponents *components = [[NSCalendar currentCalendar] components:units
                                                                   fromDate:date
                                                                     toDate:[NSDate date]
                                                                    options:0];

    if (components.year > 0) {
        return [NSString stringWithFormat:@"%ld years ago", (long)components.year];
    } else if (components.month > 0) {
        return [NSString stringWithFormat:@"%ld months ago", (long)components.month];
    } else if (components.weekOfYear > 0) {
        return [NSString stringWithFormat:@"%ld weeks ago", (long)components.weekOfYear];
    } else if (components.day > 0) {
        if (components.day > 1) {
            return [NSString stringWithFormat:@"%ld days ago", (long)components.day];
        } else {
            return @"Yesterday";
        }
    } else {
        return @"Today";
    }
}

If your dates could also be in the future then you can check the absolute value of the components in the same order and then check if it's positive or negative to return the appropriate strings. I'me only showing the year below:

if ( abs(components.year > 0) ) { 
    // year is most significant component
    if (components.year > 0) {
        // in the past
        return [NSString stringWithFormat:@"%ld years ago", (long)components.year];
    } else {
        // in the future
        return [NSString stringWithFormat:@"In %ld years", (long)components.year];
    }
} 

Solution 2:

Please note that as of iOS 13 there is now RelativeDateTimeFormatter which does it all most of it for you! WWDC 2019 video here.

let formatter = RelativeDateTimeFormatter()
let dateString = formatter.localizedString(for: aDate, relativeTo: now)

// en_US: "2 weeks ago"
// es_ES: "hace 2 semanas"
// zh_TW: "2 週前"

I've left my previous answer below for posterity. Cheers!

⚠️ You will want to read through the previous answer for some key tips to avoid certain bugs. Hint: use the end of the current day's date/time for the relative date when comparing dates that are not today!


Here's my answer (in Swift 3!) and why it's better.

Answer:

func datePhraseRelativeToToday(from date: Date) -> String {

    // Don't use the current date/time. Use the end of the current day 
    // (technically 0h00 the next day). Apple's calculation of 
    // doesRelativeDateFormatting niavely depends on this start date.
    guard let todayEnd = dateEndOfToday() else {
        return ""
    }

    let calendar = Calendar.autoupdatingCurrent

    let units = Set([Calendar.Component.year,
                 Calendar.Component.month,
                 Calendar.Component.weekOfMonth,
                 Calendar.Component.day])

    let difference = calendar.dateComponents(units, from: date, to: todayEnd)

    guard let year = difference.year,
        let month = difference.month,
        let week = difference.weekOfMonth,
        let day = difference.day else {
            return ""
    }

    let timeAgo = NSLocalizedString("%@ ago", comment: "x days ago")

    let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.locale = Locale.autoupdatingCurrent
        formatter.dateStyle = .medium
        formatter.doesRelativeDateFormatting = true
        return formatter
    }()

    if year > 0 {
        // sample output: "Jan 23, 2014"
        return dateFormatter.string(from: date)
    } else if month > 0 {
        let formatter = DateComponentsFormatter()
        formatter.unitsStyle = .brief // sample output: "1mth"
        formatter.allowedUnits = .month
        guard let timePhrase = formatter.string(from: difference) else {
            return ""
        }
        return String(format: timeAgo, timePhrase)
    } else if week > 0 {
        let formatter = DateComponentsFormatter()
        formatter.unitsStyle = .brief; // sample output: "2wks"
        formatter.allowedUnits = .weekOfMonth
        guard let timePhrase = formatter.string(from: difference) else {
            return ""
        }
        return String(format: timeAgo, timePhrase)
    } else if day > 1 {
            let formatter = DateComponentsFormatter()
            formatter.unitsStyle = .abbreviated; // sample output: "3d"
            formatter.allowedUnits = .day
            guard let timePhrase = formatter.string(from: difference) else {
                return ""
            }
            return String(format: timeAgo, timePhrase)
    } else {
        // sample output: "Yesterday" or "Today"
        return dateFormatter.string(from: date)
    }
}

func dateEndOfToday() -> Date? {
    let calendar = Calendar.autoupdatingCurrent
    let now = Date()
    let todayStart = calendar.startOfDay(for: now)
    var components = DateComponents()
    components.day = 1
    let todayEnd = calendar.date(byAdding: components, to: todayStart)
    return todayEnd
}

Remember to reuse your formatters to avoid any performance hit! Hint: extensions on DateFormatter and DateComponentsFormatter are good ideas.

Why it's better:

  • Utilizes DateFormatter's "Yesterday" and "Today". This is already translated by Apple, which saves you work!
  • Uses DateComponentsFormatter's already translated "1 week" string. (Again less work for you, courtesy of Apple.) All you have to do is translate the "%@ ago" string. 🙂
  • The other answers incorrectly calculate the time when the day switches from "today" to "yesterday" to etc. Fixed constants are a big NO-NO because reasons. Also, the other answers use the current date/time when they should use the end of the current day's date/time.
  • Uses autoupdatingCurrent for Calendar & Locale which ensures your app is immediately up to date with the user's calendar and language preferences in Settings.app

This answer was inspired by DateTools on GitHub.

Solution 3:

Swift update, thanks to objective-c answer of David Rönnqvist, it will work for the past dates.

func relativeDateStringForDate(date : NSDate) -> NSString {

        let todayDate = NSDate()
        let units: NSCalendarUnit = [.Hour, .Day, .Month, .Year, .WeekOfYear]
        let components = NSCalendar.currentCalendar().components(units, fromDate: date , toDate: todayDate, options: NSCalendarOptions.MatchFirst )

        let year =  components.year
        let month = components.month
        let day = components.day
        let hour = components.hour
        let weeks = components.weekOfYear
        // if `date` is before "now" (i.e. in the past) then the components will be positive

        if components.year > 0 {
            return NSString.init(format: "%d years ago", year);
        } else if components.month > 0 {
            return NSString.init(format: "%d months ago", month);
        } else if components.weekOfYear > 0 {
            return NSString.init(format: "%d weeks ago", weeks);
        } else if (components.day > 0) {
            if components.day > 1 {
                return NSString.init(format: "%d days ago", day);
            } else {
                return "Yesterday";
            }
        } else {
            return NSString.init(format: "%d hours ago", hour);
        }
    }

Solution 4:

FOR: SWIFT 3

Here's a Swift 3 version, for past dates, that handles all units and singular or plural in the returned String.

Example Use:

let oneWeekAgo = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: Date())!

print(relativePast(for: oneWeekAgo)) // output: "1 week ago"

I based it on a riff off of Saurabh Yadav's. Thanks.

func relativePast(for date : Date) -> String {

    let units = Set<Calendar.Component>([.year, .month, .day, .hour, .minute, .second, .weekOfYear])
    let components = Calendar.current.dateComponents(units, from: date, to: Date())

    if components.year! > 0 {
        return "\(components.year!) " + (components.year! > 1 ? "years ago" : "year ago")

    } else if components.month! > 0 {
        return "\(components.month!) " + (components.month! > 1 ? "months ago" : "month ago")

    } else if components.weekOfYear! > 0 {
        return "\(components.weekOfYear!) " + (components.weekOfYear! > 1 ? "weeks ago" : "week ago")

    } else if (components.day! > 0) {
        return (components.day! > 1 ? "\(components.day!) days ago" : "Yesterday")

    } else if components.hour! > 0 {
        return "\(components.hour!) " + (components.hour! > 1 ? "hours ago" : "hour ago")

    } else if components.minute! > 0 {
        return "\(components.minute!) " + (components.minute! > 1 ? "minutes ago" : "minute ago")

    } else {
        return "\(components.second!) " + (components.second! > 1 ? "seconds ago" : "second ago")
    }
}