Creating iOS 5 Apps: Developing Views and View Controllers
- Entering Weight Data
- Changing Weight Units
- Showing Weight History
- Showing Detail Views
- Wrapping Up
In this chapter, we will flesh out the enter weight and history views for our Health Beat application. As we proceed, we will gain more experience graphically laying out views and linking user interface elements to their view controllers. We will also learn how to populate, monitor, and control a table view; how to set up static table views; and the differences between using a navigation controller and simply presenting modal views.
This chapter also includes a discussion of advanced techniques. We use Core Animation to modify a view’s appearance, which lets us create rounded buttons with custom background colors.
Entering Weight Data
Let’s start by modifying our enter weight view. Open the MainStoryboard.storyboard file, and zoom in on the enter weight scene.
Drag a label from the library and drop it anywhere in the view. Double-click the label, and change the text to Enter Today’s Weight. In the Attributes inspector, change the font to System Bold 24.0. You can tap the icon to bring up the Fonts window. Next, choose Editor > Size to Fit Content from the menu bar. Finally, center the label along the top of the view using the guidelines (Figure 4.1).
Figure 4.1 Centering the label
Drag out a second label and position it below the first. Change its text to Current Date and Time. Don’t worry, that’s just a placeholder. We will replace this text at runtime. Of course, we don’t know exactly how long the date and time string will be, but we still want it centered under our title. The easiest way to do this is to set the label’s Alignment attribute to Centered, and then stretch the label so that it fills the view from margin to margin (Figure 4.2).
Figure 4.2 Stretching a label to fill the view
Next, place a text field under the Current Date label. Stretch it so that it also fills the view from margin to margin. Set the attributes as shown in Table 4.1.
Table 4.1 Text Field Attributes
Attribute Name |
Value |
Alignment |
Centered |
Capitalization |
None |
Correction |
No |
Keyboard |
Numbers and Punctuation |
Return Key |
Done |
Auto-enable Return Key |
On |
Whew, that’s a lot of settings. Let’s step through them one at a time. We want to restrict the input to valid decimal numbers. Also, since we’re only allowing numbers, it doesn’t make sense to enable autocorrection and capitalization. We may as well turn them off. The Numbers and Punctuation keyboard gives us all the keys we need (all the numbers, a Return key, and—for US English—a period). It actually allows too many characters, so we’ll need to filter our input. We’ll cover that in a bit.
The last two settings will help us create a more streamlined user interface. Essentially, we want to automatically create a new WeightEntry object and switch to the graph view as soon as the user presses the Return key. This reduces the total number of taps needed to enter a new weight. To help support this, we change the Return key’s label to Done to better communicate our intent. More importantly, auto-enabling the Return key means it will be disabled as long as the text field is empty. The system automatically enables the Return key once the user has entered some text. This—when paired with our input filtering—will guarantee that we have a valid weight entry whenever the Return key is pressed.
The interface should now match Figure 4.3. The basics are in place, but we’re going to spice it up a bit.
Figure 4.3 Completed enter weight view controller scene
Set Autorotating and Autosizing
We will want our view to rotate into any orientation. There are two steps to this. First, we must modify the controller to allow autorotation.
Open EnterWeightViewController.m and navigate to the shouldAutorotateToInterfaceOrientation: method. This method should return YES if the controller’s view can rotate to the given interfaceOrientation. In the default implementation, it allows rotation only into the right-side-up portrait orientation.
To allow the app to rotate into any orientation, simply have the method return YES, as shown here:
- (BOOL)shouldAutorotateToInterfaceOrientation:
(UIInterfaceOrientation)interfaceOrientation {
return YES;
}
However, if you run the app you may notice that autorotation still isn’t working. No matter how you twist the device, the interface remains locked in the portrait orientation.
That’s because there’s a trick. By default, tab views only allow autorotation if all of the contained view controllers support autorotation. Make the same change to the HistoryViewController and the GraphViewController, and then run the app again.
And...it’s still not working. OK, I lied. There were two tricks. When we created a custom tab view, it turns out that we inadvertently overrode the tab bar’s default shouldAutorotateToInterfaceOrientation: implementation. Open TabBarController.m, and delete the shouldAutorotateToInterfaceOrientation: method. This will restore the default behavior. Run it one last time. It should finally rotate as expected. Unfortunately, we have another problem (Figure 4.4). The UI elements are not properly resizing or repositioning themselves in the new, wider view.
Figure 4.4 Autorotation works, but autosizing does not.
To fix this, open MainStoryboard.storyboard again. Select the Enter Today’s Weight label, and open the Size inspector. The Autosizing control allows us to lock the object’s position relative to the top, bottom, left, or right sides. It also allows us to automatically stretch and shrink our control horizontally or vertically.
By default, the view is locked to the left and top. We want it locked to the top—but we want it to remain centered in the view. Click the red I-bar on the left side of the Autosizing control to turn it off. After you make changes in the Autosizing control, check the Example preview to make sure the control will behave as you expect. In this case, it should remain centered at the top of the view as the size changes (Figure 4.5).
Figure 4.5 Locking the control to the top of the view
We want the Current Date and Time label to stretch so it fills the view from margin to margin. Select the label and then lock it to the top, left, and right sides. Also, turn on the horizontal resizing as shown in Figure 4.6. Use the same settings for the text field as well.
Figure 4.6 Stretching the control to fill the view horizontally
Run the app again. Now when it rotates into landscape mode, the app should position and resize the controls appropriately (Figure 4.7).
Figure 4.7 Correct autorotation and autosizing
Adding Outlets and Actions
Let’s start by opening EnterWeightViewController.h. We want our controller to conform to the UITextFieldDelegate protocol. This will let us respond to changes in our text field. Modify the header as shown here:
@interface EnterWeightViewController : UIViewController
<UITextFieldDelegate>
{
}
Switch back to MainStoryboard.storyboard and open the Assistant editor. Make sure EnterWeightViewController.h is shown in the second panel. Then right-click the text field, and drag a connection from the delegate to the view controller icon in the scene’s dock (Figure 4.8).
Figure 4.8 Connecting the text field’s delegate
Next, drag the Did End On Exit event to the header file and create a new action (Figure 4.9). Name the action saveWeight.
Figure 4.9 Creating a new action
Dismiss the connections pop-up window, and Control-drag the text field to the header file (Figure 4.10). Create a new strong outlet named weightTextField.
Figure 4.10 Creating a new outlet
Finally, Control-drag from the Current Date and Time label and create a strong outlet named dateLabel.
The header file should now appear as shown here:
#import <UIKit/UIKit.h> @class WeightHistory; @interface EnterWeightViewController : UIViewController <UITextFieldDelegate> @property (nonatomic, strong) WeightHistory* weightHistory; @property (strong, nonatomic) IBOutlet UITextField *weightTextField; @property (strong, nonatomic) IBOutlet UILabel *dateLabel; - (IBAction)saveWeight:(id)sender; @end
Creating the Unit Button
Now we want to add a unit button inside the text field. This will allow us to both display the current unit type and change units.
Our unit button is somewhat odd. We won’t be adding it directly to our view hierarchy. Instead, we will programmatically assign it to our text field’s rightView property. This is not something we can do in Interface Builder.
Of course, we could still create the button in Interface Builder and assign it as a new top-level object. We could then use Interface Builder to configure its settings. Unfortunately, this doesn’t really help us, since we won’t be able to visually inspect it as we edit it. All things considered, it’s probably easiest to just create the button in code.
Open EnterWeightViewController.h and add the following property and action:
@property (nonatomic, strong) WeightHistory* weightHistory; @property (strong, nonatomic) IBOutlet UITextField *weightTextField; @property (strong, nonatomic) IBOutlet UILabel *dateLabel;@property (strong, nonatomic) UIButton* unitsButton;
- (IBAction)saveWeight:(id)sender;- (IBAction)changeUnits:(id)sender;
Next, switch to the implementation file and synthesize the property.
@synthesize unitsButton=_unitsButton;
Now, scroll down to the viewDidLoad method. Modify it as shown:
- (void)viewDidLoad { [super viewDidLoad]; self.unitsButton = [UIButton buttonWithType:UIButtonTypeCustom]; self.unitsButton.frame = CGRectMake(0.0f, 0.0f, 25.0f, 17.0f); self.unitsButton.backgroundColor = [UIColor lightGrayColor]; self.unitsButton.titleLabel.font = [UIFont boldSystemFontOfSize:12.0f]; self.unitsButton.titleLabel.textAlignment = UITextAlignmentCenter; [self.unitsButton setTitle:@"lbs" forState:UIControlStateNormal]; [self.unitsButton setTitleColor:[UIColor darkGrayColor] forState:UIControlStateNormal]; [self.unitsButton setTitleColor:[UIColor blueColor] forState:UIControlStateHighlighted]; [self.unitsButton addTarget:self action:@selector(changeUnits:) forControlEvents:UIControlEventTouchUpInside]; self.weightTextField.rightView = self.unitsButton; self.weightTextField.rightViewMode = UITextFieldViewModeAlways;}
We start by creating a custom button. Then we configure it. It’s 17 points tall and 25 points wide, with a light gray background. We also configure its title. It uses a 12-point bold system font and is center aligned. It’s important to center the title; as the user changes units, we will switch the button’s label between “lbs” and “kg.” Centering the label gives the button a nice, consistent appearance, even when the label’s size changes.
Next, we set the default title to “lbs” and then assign text colors for the different control states. Normally the text is dark gray, but when the button is highlighted, the text turns blue.
We also assign the changeUnits: action to our button’s UIControlEventTouchUpInside event. This is identical to drawing a connection between a button’s TouchUpInside event and the desired action in Interface Builder. When the unitsButton is touched, the system will call changeUnits:.
Finally, we assign the unit button to the text field’s rightView property. This will cause it to appear inside the text field along the right side. We then set the view mode so that our button is always visible.
As always, since we assigned the button in viewDidLoad, we should clear it in viewDidUnload. Navigate down to viewDidUnload and add the following line:
self.unitsButton = nil;
Finally, add a method stub for our changeUnits: action. We will flesh out this method after adding the change units view to our storyboard.
- (IBAction)changeUnits:(id)sender { // method stub }
Implementing Actions and Callback Methods
Now switch to EnterWeightViewController.m. Before we start tackling the action and delegate methods, let’s add an extension with a couple of private properties.
@interface EnterWeightViewController() @property (nonatomic, strong) NSDate* currentDate; @property (nonatomic, strong) NSNumberFormatter* numberFormatter; @end
We’re adding two properties: currentDate will hold the current date (based on the date and time when the view appeared), and we will use the numberFormatter to process our user’s input.
Next, synthesize these properties.
@synthesize currentDate = _currentDate; @synthesize numberFormatter = _numberFormatter;
Then, navigate to the viewDidLoad method. Add the following code to instantiate and configure our number formatter:
- (void)viewDidLoad { [super viewDidLoad];self.numberFormatter = [[NSNumberFormatter alloc] init];
[self.numberFormatter
setNumberStyle:NSNumberFormatterDecimalStyle];
[self.numberFormatter
setMinimum:[NSNumber numberWithFloat:0.0f]];
self.unitsButton = [UIButton buttonWithType:UIButtonTypeCustom]; self.unitsButton.frame = CGRectMake(0.0f, 0.0f, 25.0f, 17.0f); self.unitsButton.backgroundColor = [UIColor lightGrayColor]; ... }
In the last chapter, we used number formatters to create number strings that would format properly regardless of the device’s language and country settings. In this chapter, we will see the other side. We will use the NSNumberFormatter to verify and filter the user’s input. Here, we set it to accept only positive decimal numbers. We will also use the formatter to parse the user input, converting it from a string into a float value.
Again, anything we set up in viewDidLoad needs to be torn down in viewDidUnload. Add the following line:
- (void)viewDidUnload
{
[self setWeightTextField:nil];
[self setDateLabel:nil];
self.unitsButton = nil;
self.numberFormatter = nil;
[super viewDidUnload];
}
We still need to reset the screen each time it appears. Remember, a view might be created only once but appear many times. Actually, it’s even more complicated than this. A view may be loaded and unloaded multiple times (usually due to memory shortages). Each time it is loaded, it may appear onscreen more than once. Therefore, it’s important to think things through. Which configuration items need to be performed once and only once? These are typically performed in the application delegate’s application:didFinishLaunchingWithOptions: method. Which configuration items should be performed each time a view loads? These should be performed in the view controller’s viewDidLoad method. Finally, which ones should be performed every time the view appears onscreen? These are done in the viewWillAppear: or viewDidAppear: method.
In our case, we want to update the current date and make sure our text field is ready to receive new information. Implement the viewWillAppear: method as shown:
- (void)viewWillAppear:(BOOL)animated { // Sets the current time and date. self.currentDate = [NSDate date]; self.dateLabel.text = [NSDateFormatter localizedStringFromDate:self.currentDate dateStyle:NSDateFormatterLongStyle timeStyle:NSDateFormatterShortStyle]; // Clear the text field. self.weightTextField.text = @""; [self.weightTextField becomeFirstResponder]; [super viewWillAppear:animated]; }
Here we create a new NSDate object set to the current date and time. We then use the NSDateFormatter class method localizedStringFromDate:dateStyle:timeStyle: to produce a properly localized string representation. As you might expect, the formatting of dates and times also varies greatly from country to country and language to language. The NSDateFormatter lets us easily create date strings based on the device’s language and region settings.
Next, we clear the text field and make it the first responder. Making a text field the first responder will automatically display the keyboard. Now, we’ve already linked the text field’s Did End On Exit event to the saveWeight: method. This method will be called whenever the keyboard’s Done button is pressed.
As we described earlier, this provides a very streamlined system for entering the weights. When the user opens this view, the text field is automatically selected and the keyboard is ready and waiting. The user just types in the weight value and presses Done. They don’t need to select the text box or press a Save button. Everything is simple, automatic, and clean.
However, it does create one small problem. The keyboard covers our tab bar. This prevents our users from navigating away from this screen without entering a new weight.
Obviously, this is not ideal. We need to provide a way (preferably something intuitive and non-intrusive) to dismiss the keyboard, giving us access to the tab bar again. Let’s add a gesture recognizer that responds to a simple down swipe.
Open MainStoryboard.storyboard again. Drag a swipe gesture recognizer from the library and drop it onto the enter weight view controller scene’s main view (Figure 4.12).
Figure 4.12 Adding a swipe gesture recognizer
The gesture recognizer will appear in the scene’s dock. Select it and open the Attributes inspector. Set the Swipe attribute to Down. Leave the Touches attribute at 1. This will now trigger on a single-finger, downward swipe.
If we’re going to recognize downward swipes, we should recognize upward swipes as well. So, let’s add a second recognizer. Drag out another swipe gesture recognizer and add it to the view. Set its Swipe attribute to Up. Leave the Touches attribute at 1.
Now open the Assistant editor, and make sure the EnterWeightViewController.h file is showing. Right-click and drag from the down swipe gesture recognizer to just below the declaration of our changeUnits: method (Figure 4.13). Change the Connection to Action, and name it handleDownwardSwipe. Then do the same for the up gesture recognizer. Name its action handleUpwardSwipe.
Figure 4.13 Connecting a gesture recognizer
Then create an outlet for each gesture recognizer. Control-drag from the recognizer to the header file. Name the first outlet downSwipeRecognizer. Name the second upSwipeRecognizer.
Now switch to EnterWeightViewController.m and implement the actions:
- (IBAction)handleDownwardSwipe:(id)sender { // Get rid of the keyboard. [self.weightTextField resignFirstResponder]; } - (IBAction)handleUpwardSwipe:(id)sender { // display keyboard [self.weightTextField becomeFirstResponder]; }
The handleDownwardSwipe method simply has the text field resign as first responder. Just as before, the keyboard is automatically linked to the first responder. When the text field resigns, the keyboard disappears. The handleUpwardSwipe method is just the inverse of that. It assigns the text field as the first responder, causing the keyboard to appear again. Of course, the user could do the same thing by simply tapping the text field, but many users will automatically try to undo a downward swipe with an upward swipe. Adding the inverse operation makes the interface feel more complete.
While this is an elegant solution, it brings up a common problem with iOS development. We can easily build complicated touch-, gesture-, and motion-based controls (see Chapter 8 for more examples), but how do we make sure the user knows they exist? iOS applications usually don’t have help screens, and—in my experience—few users actually read the help information that does exist.
For example, you might create a great three-finger swipe that radically simplifies your application’s workflow. However, unless your users stumble upon it by accident, most will never know it exists. That’s not to say that you should avoid using unusual gestures. On the contrary, many applications use novel gestures to great effect. The Twitter app is an excellent example: You scroll through the table view of incoming tweets. When you get to the top, you just pull down to check for new messages.
This is a brilliant gesture. Users will almost certainly stumble upon it as they accidentally try to scroll past the end of their tweets. More importantly, once you find it, the gesture is so natural that it quickly becomes part of your regular workflow.
The bottom line is that successfully communicating how your app operates can be one of the biggest challenges in iOS development. Typically, this involves extensive usability testing to make sure your interface is as intuitive and natural as possible.
OK, let’s switch gears and tackle the saveWeight: action.
#pragma mark – Action Methods - (IBAction)saveWeight:(id)sender { // Save the weight to the model. NSNumber* weight = [self.numberFormatter numberFromString:self.weightTextField.text]; WeightEntry* entry = [[WeightEntry alloc] initWithWeight:[weight floatValue] usingUnits:self.weightHistory.defaultUnits forDate:self.currentDate]; [self.weightHistory addWeight:entry]; // Automatically move to the second tab. // Should be the graph view. self.tabBarController.selectedIndex = 1; }
First, we parse the text field to extract the weight’s floating point value. Normally you want to check numberFromString:’s return value. If the number does not match the specified format, this method will return nil. However, in this case we know that the text field can only have valid values. The saveWeight: action is only triggered when the keyboard’s Done button is pressed, and the Done button only becomes active when our text field contains text. Since we will be filtering the user input, this text can only contain a valid decimal number.
Next, we instantiate a new WeightEntry object using this weight value, our defaultUnits, and the currentDate property (if you remember, currentDate was set when the enter weight view appeared onscreen). We add this entry to our model.
Finally, we change the tab bar’s selected controller. This will automatically move us to the second tab—currently set to the graph view. Again, we are trying to make entering new weights as streamlined as possible. For the most part, this means removing unnecessary touches. Users will typically enter only one weight at a time. Therefore, we should streamline their interaction and automatically bring up the weight trends graph after each new value.
We’re going to skip the changeUnits: method for now. We’ll get back to it in the “Changing Weight Units” section. Instead, let’s begin filtering the user’s input.
Filtering Keyboard Input
The UITextFieldDelegate protocol has a number of optional methods that we can use to monitor and control our text field. In particular, we will implement textField:shouldChangeCharactersInRange:replacementString: to filter the user input. Implement the method as shown:
#pragma mark - Delegate Methods - (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string { // It's OK to hit return. if ([string isEqualToString:@"\n"]) return YES; NSString* changedString = [textField.text stringByReplacingCharactersInRange:range withString:string]; // It's OK to delete everything. if ([changedString isEqualToString:@""]) return YES; NSNumber* number = [self.numberFormatter numberFromString:changedString]; // Filter out invalid number formats. if (number == nil) { // We might want to add an alert sound here. return NO; } return YES; }
This method is called whenever the user presses a button on the keyboard (including the backspace button). If we return YES, the change is made. If we return NO, the change is canceled.
We start by checking to see if the user hit the Return key. Since this is the trigger for our saveWeight: action, we need to accept it.
Next, we create a new string by applying the proposed change to textField’s current contents. If the resulting string represents a valid decimal number, we accept the change. Otherwise, we reject it.
Of course, it’s not quite that simple. First, we have to deal with another corner case. If the resulting string is empty, we allow the change. Technically, an empty string is not a valid decimal number; however, we really want to let the users delete all the characters, just in case they made a typing mistake and want to start over.
If the string is not empty, we use our numberFormatter to parse our string. Again, we use the numberFromString: method. If the string does not match the expected format, this method returns nil. We simply check the return value and return YES or NO as appropriate.
Technically, we could simplify the code and just return the result from parsing the string as shown here:
return [self.numberFormatter numberFromString:changedString];
However, we may want to add an alert sound or other feedback to the user. Using the more verbose version of the code will make those additions easier.
Run the application. The text field should appear with the embedded unit button. Check to make sure the input filtering works correctly. When you press the Done button, the view should switch to the graph view. Nothing shows up yet (of course), but the transition should work. Go back to the enter weight view. The system should automatically clear and select the text field. The keyboard should be visible. Swipe down to dismiss the keyboard. Swipe up to re-enable it. You can even tap the units button, but it won’t do anything yet. We’ll fix that next (Figure 4.14).
Figure 4.14 The completed enter weight view