显示标签为“Date”的博文。显示所有博文
显示标签为“Date”的博文。显示所有博文

2011年12月6日星期二

Tutorial: How to Sort and Group UITableView by Date

Tutorial: How to Sort and Group UITableView by Date:

Screenshot of the Appointment List sample application. The list of calendar events ids grouped by date.



The Appointment List sample application. The list of calendar events ids grouped by date.




Let me follow up on last month’s little series about date and time handling in Cocoa with a practical example.



Say you want to implement a list of your future appointments similar to the List view in Apple’s Calendar app on the iPhone. Calendar events should be listed in a table view, with each day getting its own section. So we have to group the dates by day, which is an interesting task to get familiar with the date handling classes.



Project Setup



I am not going to cover the basics here. We need a fresh Xcode project (the navigation-based app template is a good start) with a view controller that displays a UITableView. Since we are going to work with the calendar store on the device, we also need to link our app with the EventKit.framework and import the framework’s header file: #import <EventKit/EventKit.h>.



Getting Events from the Calendar Store



The first thing we have to do is get a list of future events from the calendar store. Our interface to the store is the EKEventStore class. The event store lets us generate a predicate that we can then use to retrieve all events matching the predicate. All we need to do is specify a start and end date for the predicate.



Constructing Start and End Date



Say we want to list all events between today and one year from today. A natural start date for our query would be [NSDate date], which gives us the current date and time. But what about appointments that were scheduled for earlier today? I think we should include them in our list even though they lie in the past. It’s better than to confuse the user by showing only part of today’s events.



So our start date should be the beginning of the current day. How do we determine that date, given that all we have is the current date and time? Think about it this way: we want a date that represents a specific time (00:00) on a given day. The easiest way to do that is to selectively convert the date components of the given date into an NSDateComponents instance, then set the specific time components manually, and convert the whole thing back into an NSDate:



- (NSDate *)dateAtBeginningOfDayForDate:(NSDate *)inputDate
{
// Use the user's current calendar and time zone
NSCalendar *calendar = [NSCalendar currentCalendar];
NSTimeZone *timeZone = [NSTimeZone systemTimeZone];
[calendar setTimeZone:timeZone];

// Selectively convert the date components (year, month, day) of the input date
NSDateComponents *dateComps = [calendar components:NSYearCalendarUnit | NSMonthCalendarUnit | NSDayCalendarUnit fromDate:inputDate];

// Set the time components manually
[dateComps setHour:0];
[dateComps setMinute:0];
[dateComps setSecond:0];

// Convert back       
NSDate *beginningOfDay = [calendar dateFromComponents:dateComps];
return beginningOfDay;
}


This method gives us an NSDate representing midnight in the current user’s local time for the specified input date.



For the end date, we want to add exactly one year to the start date. The trivial way to do that would be to add 365 * 24 * 60 * 60 seconds1 to the start date but this naive approach takes neither leap year nor different calendars into account. The better way is again the one via NSCalendar and NSDateComponents:



- (NSDate *)dateByAddingYears:(NSInteger)numberOfYears toDate:(NSDate *)inputDate
{
// Use the user's current calendar
NSCalendar *calendar = [NSCalendar currentCalendar];

NSDateComponents *dateComps = [[NSDateComponents alloc] init];
[dateComps setYear:numberOfYears];

NSDate *newDate = [calendar dateByAddingComponents:dateComps toDate:inputDate options:0];
return newDate;
}


The dateByAddingComponents:toDate:options: method is just great. It adds the specified date components to the input date and takes care of everything for us, including leap years and overflows from one unit to the next. For instance, if you were to add 5 months to a date in November, the method is smart enough to return a result in April of next year.



Querying the Calendar Store



Having start and end date, we can construct our search predicate. Add the following code to your view controller’s viewDidLoad method:



- (void)viewDidLoad
{
[super viewDidLoad];

NSDate *now = [NSDate date];
NSDate *startDate = [self dateAtBeginningOfDayForDate:now];
NSDate *endDate = [self dateByAddingYears:1 toDate:startDate];

EKEventStore *eventStore = [[EKEventStore alloc] init];
NSPredicate *searchPredicate = [eventStore predicateForEventsWithStartDate:startDate endDate:endDate calendars:nil];
NSArray *events = [eventStore eventsMatchingPredicate:searchPredicate];
}


This gives us a list of events inside our desired timeframe.



Grouping Events by Day



Next, we have to group the list of events into sections, each section representing a single day. The way we approach this task is this:



  1. Iterate over all events.
  2. Reduce the event’s start date to its date components, i.e. strip off the time (like we did above to determine the start date of our search predicate).
  3. Use the reduced date as key in a sections dictionary.
  4. Each value in the sections dictionary should be an array containing the events that belong to the day represented by the corresponding key.
  5. Create a separate array in which we sort the keys of the sections dictionary. We need this to display the sections in the correct order.

Make sense? Here is the code:



@property (strong, nonatomic) NSMutableDictionary *sections;
@property (strong, nonatomic) NSArray *sortedDays;

...

@synthesize sections;
@synthesize sortedDays;

...

- (void)viewDidLoad
{
...

self.sections = [NSMutableDictionary dictionary];
for (EKEvent *event in events)
{
// Reduce event start date to date components (year, month, day)
NSDate *dateRepresentingThisDay = [self dateAtBeginningOfDayForDate:event.startDate];

// If we don't yet have an array to hold the events for this day, create one
NSMutableArray *eventsOnThisDay = [self.sections objectForKey:dateRepresentingThisDay];
if (eventsOnThisDay == nil) {
eventsOnThisDay = [NSMutableArray array];

// Use the reduced date as dictionary key to later retrieve the event list this day
[self.sections setObject:eventsOnThisDay forKey:dateRepresentingThisDay];
}

// Add the event to the list for this day
[eventsOnThisDay addObject:event];
}

// Create a sorted list of days
NSArray *unsortedDays = [self.sections allKeys];
self.sortedDays = [unsortedDays sortedArrayUsingSelector:@selector(compare:)];
}


(The method is getting pretty long here. In practice, I wouldn’t place all this code in viewDidLoad but it should suffice for the example.)



Creating Date Formatters for Output



That’s it! The last thing we need are two NSDateFormatter objects to format the output for the section headers and cells in the table view. We could create those directly in the methods where we need them, but since creating a date formatter is a relatively expensive operation, we are better off creating them once and reusing them. Note that we don’t use specific date and time formats but instead rely on the predefined date formatter styles, which take the user’s preferences into account:



@property (strong, nonatomic) NSDateFormatter *sectionDateFormatter;
@property (strong, nonatomic) NSDateFormatter *cellDateFormatter;

...

@synthesize sectionDateFormatter;
@synthesize cellDateFormatter;

...

- (void)viewDidLoad
{
...

self.sectionDateFormatter = [[NSDateFormatter alloc] init];
[self.sectionDateFormatter setDateStyle:NSDateFormatterLongStyle];
[self.sectionDateFormatter setTimeStyle:NSDateFormatterNoStyle];

self.cellDateFormatter = [[NSDateFormatter alloc] init];
[self.cellDateFormatter setDateStyle:NSDateFormatterNoStyle];
[self.cellDateFormatter setTimeStyle:NSDateFormatterShortStyle];
}


Populating the Table View



With the groundwork done, populating the table view is simple. Note that I am using iOS 5 for the sample project so we can configure our prototype table cell directly in Interface Builder:




Configuring the settings of the prototype cell in Interface Builder.



Configuring the settings of the prototype cell in Interface Builder.




The UITableViewDataSource protocol methods we have to implement are straightforward:



- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
return [self.sections count];
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
NSDate *dateRepresentingThisDay = [self.sortedDays objectAtIndex:section];
NSArray *eventsOnThisDay = [self.sections objectForKey:dateRepresentingThisDay];
return [eventsOnThisDay count];
}

- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
{
NSDate *dateRepresentingThisDay = [self.sortedDays objectAtIndex:section];
return [self.sectionDateFormatter stringFromDate:dateRepresentingThisDay];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
NSString *reuseIdentifier = @"EventTitleCell";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseIdentifier];

NSDate *dateRepresentingThisDay = [self.sortedDays objectAtIndex:indexPath.section];
NSArray *eventsOnThisDay = [self.sections objectForKey:dateRepresentingThisDay];
EKEvent *event = [eventsOnThisDay objectAtIndex:indexPath.row];

cell.textLabel.text = event.title;
if (event.allDay) {
cell.detailTextLabel.text = @"all day";
} else {
cell.detailTextLabel.text = [self.cellDateFormatter stringFromDate:event.startDate];
}
return cell;
}


Download the Sample Project



In this tutorial about date handling, I illustrated how to combine Cocoa’s date handling classes, NSDate, NSCalendar, NSDateComponents, NSDateFormatter, to do date calculations, to derive new dates from existing ones, to group dates according to your own criteria, and to format dates for output on screen. And incidentally, we also learned to query the device’s calendar store.



I uploaded the small sample app to GitHub, please download it from there. Note that you won’t see anything but an empty table view when you run the app in the iOS Simulator since the simulator does not have a calendar store.




  1. The number of seconds in a normal (non-leap) year. The result is 31,536,000 seconds.



2011年11月22日星期二

Working with Date and Time in Cocoa (Part 2)

Working with Date and Time in Cocoa (Part 2):
In part 2 of my little series on date and time handling in Cocoa I am going to talk about date parsing and formatting. In other words: how to convert strings into date objects and vice versa. You should read part 1 first if you haven’t yet to get an overview of the classes used by Cocoa’s date and time system.


NSDateFormatter



When working with date and time, two very common requirements are, (1) displaying dates in your UI, and (2) reading in date/time values from external sources like a web service or text file. Since humans are not very good at interpreting the second-based timestamps that NSDate uses to store dates internally, both of these tasks usually make it necessary to convert between NSDate and NSString or vice versa.


In the Foundation framework, the class to use for this task (in either direction) is NSDateFormatter. Let me show you how it works.


1. Turning Dates Into Strings



Let’s start with the easier (because less error-prone) of the two directions: turn an NSDate instance into a readable string. Usage of the NSDateFormatter class always involves three steps: (1) create the date formatter; (2) configure it; (3) send it a stringFromDate: message to get the result. Obviously, the configuration step is where the interesting stuff happens. We should differentiate between two separate use cases: do we want to create a human-readable output or do we need to create a string according to a specific format to be read by another API?


Formatting for Humans: Let the User Decide



When displaying dates in your app’s UI, you should always take the user’s preferences into account. Fortunately, that is easy with NSDateFormatter. To simply convert an NSDate into an NSString use code like this:


NSDate *myDate = [NSDate date];
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
[dateFormatter setDateStyle:NSDateFormatterMediumStyle];
[dateFormatter setTimeStyle:NSDateFormatterMediumStyle];
NSString *myDateString = [dateFormatter stringFromDate:myDate];
NSLog(@"%@", myDateString);


With my current locale settings (German), the output looks like this: 22.11.2011 18:33:19, but that’s just me. By default, NSDateFormatter observes the current user’s locale settings so other users might see results like Nov 22, 2011 6:33:19 PM or 2011-11-22 下午6:33:19 or even २२-११-२०११ ६:३३:१९ अपराह्, all for the same input and with the same code.


As a developer, you are not supposed to care about the actual output. Just use the setDateStyle: and setTimeStyle: methods to control how short or long the output should be. Possible values are NSDateFormatterShortStyle, NSDateFormatterMediumStyle, NSDateFormatterLongStyle and NSDateFormatterFullStyle; you can also use NSDateFormatterNoStyle to suppress the date or the time component in the resulting string.


The class method +localizedStringFromDate:dateStyle:timeStyle: provides a shorter way to achieve the same result as the code snippet above.


If you want to have more control over the output format, you can set a specific format using the setDateFormat: method. Note, though, that Apple specifically discourages that approach for human-readable dates since there is no date and time format that is accepted worldwide. NSDateFormatter understands the date format specifiers of the Unicode spec for date formats. If you want to go this route, have a look at the +dateFormatFromTemplate:options:locale: class method. It lets you specify a string of date format specifiers that your output string should include and returns an appropriate date format string for the specified locale.


Formatting for Machines: Controlled Environment Needed



It is a whole other matter if you need to create a date string according to the specification of a certain file format or API. In such a case, you usually have to follow a very strict spec to make sure the other party can read the string you are generating.1


It should be clear that we must use the setDateFormat: method here. But that is not enough. Remember from part 1 that you can represent the same point in time very differently, depending on the calendar and time zone. By default, NSDateFormatter uses the user’s current calendar and time zone, which are possibly different from the requirements. Most file formats and web APIs use the western, Gregorian calendar, so we need to make sure that our date formatter uses it, too:


NSDate *myDate = [NSDate dateWithTimeIntervalSinceReferenceDate:343675999.713839];
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
[dateFormatter setCalendar:calendar];


We must also make sure to set the date formatter’s locale to a generic value so as not to run into conflict’s with the user’s locale settings, which can influence the naming of weekdays and months as well as the clocks 12/24 hour setting. The date formatter’s setLocale: method expects an instance of the NSLocale class. To create one, we need to specify a locale identifiers. These usually consist of a combination of a language and a country code, such as @"en_US". For our needs, however, there exists the special locale identifier @"en_US_POSIX" that is guaranteed to not change in the future.


Note that a locale also includes a calendar setting so setting the calendar explicitly as we did above is no longer necessary (but does not hurt).


NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"];
[dateFormatter setLocale:locale];


The date’s time zone can possibly be included in the formatted output string. But as I also mentioned in part 1, time zone identifiers such as “+01:00”, “PST” or “CET” are notoriously ambiguous. In most cases, it’s best to stick with UTC:


NSTimeZone *timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0];
[dateFormatter setTimeZone:timeZone];


Now, we are finally ready to set our date format and create the result string. For example, to format a date according to the common RFC 3339 (ISO 8601) standard:


[dateFormatter setDateFormat:@"yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'"];
NSString *myDateString = [dateFormatter stringFromDate:myDate];
// => 2011-11-22T17:33:19Z


Again, see the Unicode standard mentioned above for a list of possible format specifiers. Pay special attention to the year format specifier @"yyyy". It is different than the capitalized @YYYY, which represents the year of the date’s week and not the year of the day. 99% of the time, you probably want to use @”yyyy”. I have seen this bug so many times in production code that it’s not funny anymore so make sure your unit tests catch it.2


Also note that I am using the literal character 'Z' to represent the UTC time zone we set on the date formatter before. If you need to include the time zone in your format string, make sure to experiment with the possible time zone format specifiers (z, Z, v, V, each with 1-4 characters) and different time zones to really understand what you’re getting yourself into.3 As I said, dealing with time zones is no fun, especially when it comes to ambiguous abbreviations or daylight savings time. It’s best to avoid if at all possible.


2. Turning Strings Into Dates



Let’s move on to the other side of NSDateFormatter: parsing a string representation of a date and/or time and converting it to an NSDate instance. Your main use case for this should be the parsing of dates you read in from a web service API or a text file.


Parsing Machine-Generated Dates



In this case, you use the class much like in the reverse case that we just discussed:



  1. Create an NSDateFormatter.

  2. Create a controlled environment by setting the formatter’s locale and possibly time zone as specified by the input format. In most cases, this means the en_US_POSIX locale and the UTC time zone.

  3. Set the formatter’s date format string to the specified format.

  4. Call dateFromString:.
For example, here is how to parse a date from an RSS feed entry of the form Mon, 06 Sep 2009 16:45:00 -0900 as specified in RFC 822:


NSString *myDateString = @"Mon, 06 Sep 2009 16:45:00 -0900";

NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"];
[dateFormatter setLocale:locale];
[dateFormatter setDateFormat:@"EEE, dd MMM yyyy HH:mm:ss Z"];

NSDate *myDate = [dateFormatter dateFromString:myDateString];
NSLog(@"%@", myDate);
// => 2009-09-07 01:45:00 +0000


Note how we did not set the date formatter’s time zone explicitly here. Instead, the Z character in the format string is now a format specifier for the time zone rather than the literal character it was in the example above. Also note that the output format of NSLog() shows date and time in UTC but it really represents the exact same point in time as the input string.


If a date formatter cannot parse the string, dateFromString: returns nil. Your code must be able to deal with this case gracefully.


Parsing Free-Form Date Strings



What if you don’t know the exact format of the string, e.g. because you want to let the user enter a date and time in a free-form text field4? I am afraid that NSDateFormatter will probably not be a big help then. The class does have a setLenient: method that enables heuristics when parsing a string. However, even in lenient mode you are still required to specify an exact date format. In lenient mode, the formatter correctly parses a date string containing slashes (@"03/11/2011 11:03:45") when the date format specifies blanks (@"dd MMM yyyy HH:mm:ss") but that seems approximately to be the extent of what it can do.


For really lenient parsing with NSDateFormatter, you would probably have to try multiple formats and check for success after each attempt. The Unicode standard includes some suggestions for lenient parsing if you want to go that route.


NSDataDetector to the Rescue!



A more promising approach might be the relatively new NSDataDetector class. Although not a classic member of the date and time handling classes in Cocoa, I want to mention it here for its ability to match, among other things, dates and times in free-form strings such as e-mail messages.


Because NSDataDetector is a special kind of regular expression, its API is completely different:


NSString *myDateString = @"24.11.2011 15:00";
NSError *error = nil;
NSDataDetector *detector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeDate error:&error];
NSArray *matches = [detector matchesInString:myDateString options:0 range:NSMakeRange(0, [myDateString length])];
for (NSTextCheckingResult *match in matches) {
   NSLog(@"Detected Date: %@", match.date);           // => 2011-11-24 14:00:00 +0000
   NSLog(@"Detected Time Zone: %@", match.timeZone);  // => (null)
   NSLog(@"Detected Duration: %f", match.duration);   // => 0.000000
}


In this case, the detection worked great5, and the detector can also deal with relative strings such as @"next Monday at 7 pm" or @"tomorrow at noon". NSDataDetector always seems to use the current locale and time zone to interpret dates in strings.


Miscellaneous Findings



Use Thread-Local Storage for NSDateFormatter



The -[NSDateFormatter init] method is quite expensive. If you need the same date formatter repeatedly, you should cache it, either in a static variable as in this example in Apple’s Technical Q&A QA1480 (see Listing 2) or, even better, by using Thread-Local Storage as explained by Alex Curylo in his article Threadsafe Date Formatting.


More Efficient Date Parsing



If you still encounter performance problems with NSDateFormatter, note this suggestion in the same QA1480:



Finally, if you’re willing to look at solutions outside of the Cocoa space, it’s very easy and efficient to parse and generate fixed-format dates using the standard C library functions strptime_l and strftime_l. Be aware that the C library also has the idea of a current locale. To guarantee a fixed date format, you should pass NULL to the loc parameter of these routines. This causes them to use the POSIX locale (also known as the C locale), which is equivalent to Cocoa’s “en_US_POSIX” locale.



For a data point, see Sam Soffes’s article how he improved the performance of his date parsing code by a factor of more than 20× by switching from NSDateFormatter to C-based date parsing.


GMT != UTC



Cédric Luthi discovered a seemingly weird NSDateFormatter behavior last weekend. See the following code:


NSString *dateString = @"0001-01-01 00:00:00 GMT";
NSDateFormatter *df = [[NSDateFormatter alloc] init];
[df setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]];
[df setDateFormat:@"yyyy-MM-dd HH:mm:ss zzz"];
NSDate *myDate = [df dateFromString: dateString];
NSLog(@"%@", myDate);


The result of the log statement: 0001-01-01 01:27:24 +0000. Hm, 01:27:24? Where can such a weird result come from? It turns out the answer is the time zone GMT. Do the same with UTC as the time zone and the result is the expected 0001-01-01 00:00:00 +0000.


So it seems that when dealing with historical dates, UTC and GMT are not identical in Cocoa. Instead, the system seems to use past definitions of GMT that were valid at the date in question. When I investigated this further, I found out that only for dates later than 9 April, 1968, GMT and UTC are identical in Cocoa. So beware of the difference if your app deals with the past. Use UTC as your time zone if you want to interpret all dates in today’s time system.





  1. Wouldn’t it be great if all web services used Unix timestamps to represent dates? I could omit this entire section as the conversion from and to NSDate would be trivial. For some reason, however, most APIs use string-based dates, which at least have the advantage of being human-readable.



  2. For example, the year-of-week for 1 January 2005 is 2004 because that date belongs to the last calendar week of 2004 rather than the first calendar week of 2005. Use NSDate *testDate = [NSDate dateWithTimeIntervalSinceReferenceDate:126273600.0] in your unit test and assert that you get the correct result for both format strings @"yyyy" and @"yyyy".



  3. By the way, CodeRunner, which I reviewed recently here on the blog is an awesome little app to experiment with date formatters. I used it constantly while writing this article.



  4. There are a number of apps that let you do just that, for example iCal in Lion, the great Fantastical app and Google Calendar.



  5. My time zone is one hour earlier than UTC, hence the time difference between input and output string.