Building and Testing a Custom UIDatePicker

September 02, 2014
When building the new Add Funds flow for iOS, we needed a date picker to select the start date for a recurring deposit. Apple’s pre-built date picker (UIDatePicker) does not allow for visual customization. Having mostly dark backgrounds and light foregrounds, we needed to build our own date picking component (WFDatePicker). We already had a great design to work with, shown above. The three-segment interface was similar enough to the standard UIDatePicker to be intuitive, but the design fit with the rest of the app. At Wealthfront, we build new UI elements entirely separate from the rest of the app, to ensure they are properly encapsulated, tested extensively, and reusable.

Dissecting UIDatePicker

First we were curious how Apple’s UIDatePicker appeared to have infinite or cyclic rows. For those playing along at home, you can find a UIDatePicker in Calendar > + event > Starts/Ends. How do they do this? (Scroll for a minute and you’ll hit the edge).
An easy way to dig into the current view hierarchy is to get the main window and call –recursiveDescription on it. This recursively logs the description of all subviews. I have the following mapped to rd in ~/.lldbinit.
(lldb) e [[[UIApplication sharedApplication] keyWindow] recursiveDescription]

The view hierarchy is denoted by indentation so you may want to find the view you are focusing on and call -layoutSubviews on it again. You can send messages to raw memory addresses in lldb.

Some insights from this:

  • UIDatePicker uses 3 table views (UIPickerTableView) per section. That means 9 table views for a date picker and 12 for date and time. These three table views are portioned vertically,  one for the grey cells above the selection area (red), one for the selection area (purple), and one below (green).I like to set the background color on various views with lldb to get a sense of what they do.
    (lldb) e [(UIView*)0xa22fe00 setBackgroundColor:[UIColor colorWithRed:0 green:1.0 blue:0 alpha:0.5]];
  • Each of these table views uses 10,000 rows.  (We found a table view at 0xe94ac00 earlier)
    (lldb) p (NSInteger)[0xe94ac00 numberOfRowsInSection:0]
    (NSInteger) $8 = 10000
  • The cylindrical visual affect is caused by an affine transform on each cell’s layer in the top and bottom tableviews (one of these is pink in the image).
    (lldb) p (CGAffineTransform)[[0x8cc8230 layer] transform
    (CGAffineTransform) $13 = (a = 1, b = 0, c = 0, d = 0, tx = 0, ty = 0.945817232)

What happens if you scroll to the end of the 10,000 cells? I assume they start you out near row 5,000, except for the year segment where you start at row 2014. When you scroll 5,000 cells up or down and get close to 0 or 9,999, it kindly scrolls you back to the midpoint. We implemented similar behavior in WFDatePicker.

Memory Implications of Infinite Rows

Why 10,000? Since cells are created lazily, it makes sense there wouldn’t be any allocation on a per-row basis. Why not try NSIntegerMax?
Terminating app due to uncaught exception ‘NSInternalInconsistencyException’, reason: ‘Failed to allocate data stores for 9223372036854775807 rows in section 0. Consider using fewer rows’

Although table view cells are created lazily, and reused when possible, UITableView allocates some space for each row. In Instruments, filter by UISectionRowData in Allocations List to see these.

UIDatePicker allocates one block of 78.5 KB and another of 39.5 KB per table view (of 10,000 rows). With 9 of these, that’s about 1 MB per UIDatePicker. Not too much for an iPhone to handle, but you should think twice before using more than 10,000. We found it is around 8 + 4 bytes per row. 100 million cells causes a noticeable lag and 1 billion cells caused my test app to become unresponsive.

10,000 is enough to take considerable time to scroll to the end,  but not use too much memory.

Our implementation uses three table views of 10,000 cells each, coming in around 350 KB. We didn’t need 9 table views because we don’t need any transformations for visual effects.

Implementation of Infinite Cells

Infinite cells only make sense in cyclic time units, such as days or months. The year segment stops at 2014, because you cannot deposit in the past.
The basic technique is returning a large number of rows (10,000), starting the table view near the middle, and having the cell text based off some modular math of the row. All corrections are done relative to where you are in the table view. If you choose February 30, it finds the nearest proper date, regardless of where you are.

Sticky Scrolling

Another feature we implemented was sticky scrolling, where the table view always aligns its cells with the view. This involves covering two scroll velocity cases, zero or nonzero. These come via the UIScrollViewDelegate method:
– (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
                     withVelocity:(CGPoint)velocity
              targetContentOffset:(inout CGPoint *)targetContentOffset 

The zero case is fairly simple. We find the amount of “error” and scroll to the first or second top most visible cell, depending on the amount of error.

The non-zero case is greatly simplified by targetContentOffset being an inout parameter. All we have to do is adjust targetContentOffset to align with the border of a cell. UIKit will adjust the scroll velocity and deceleration so it ends where we set.

Testing

This date picker makes heavy use of UITableView. Since we don’t want or need to test UITableView itself, we can mock instances of it with OCMock and verify we are calling its API properly. This usually goes both ways, where we mock out UITableView methods that give WFDatePicker data, or expect methods that WFDatePicker needs to call to affect UITableView. The tests are usually near 1 to 1 with the WFDatePicker’s methods.

Here is the test for the method shown above, scrollToMiddleCell, which is called whenever a scroll view “lands” misaligned with a cell border.
A quick introduction to our testing methodology, which is discussed more here: we use OCMock and XCTest to verify accuracy of behavior and results in our components. The three cases I decided to cover here are when we land on a content offset such that we need to scroll up, when we need to scroll down, and when we don’t need to scroll. If a cell is 44pt, the first case would be something like 44 * 12 + 34, and the second might be 44 * 13 + 11. The last case could be 44 * 7.

Scrolling up or down is done by scrolling to the second or first visible cell (with UITableViewScrollPositionTop), respectively. In the third case we don’t want to do much except tell the component that a date is selected. In the first two cases this is done when the scroll animation finishes (via a UIScrollViewDelegate call).

Many of our tests follow this expectation-execute-verify paradigm. OCMock lets us verify the methods called on the UITableView object, and the accuracy of the parameters (line 13 and 22). We can also provide data that the UITableView object should, so we can test under specific conditions (line 11, 20, 30). On line 28 and 30 we create a partial mock of the object we are testing, so we can verify some internal behavior (userHasSelectedDateWithScrollView) in this case. Alternatively, we could have tested the results of the internal method (probably just a delegate call), but we prefer to keep the scope of each test very narrow in the event userHasSelectedDateWithScrollView changes. For this test, we just want to know it was called with the proper parameters.

Building UI components involves a fair dose of design, investigation, implementing, and testing. All of these things are important to iOS development here at Wealthfront.