- Entering Weight Data
- Changing Weight Units
- Showing Weight History
- Showing Detail Views
- Wrapping Up
Changing Weight Units
When the user presses the units button, we need to open a new view and let them change the default units. To do this, we will use a modal view. Modal views are perfect for presenting focused, short-term tasks that the user must either complete or explicitly cancel.
Let’s start by adding another UIViewController subclass. Name it UnitSelectorViewController and place it in the Controllers group (if you need help, follow the step-by-step instructions in “Configuring the Tab Bar” in Chapter 3).
Now open MainStoryboard.storyboard. Drag out a new UIViewController object and place it next to our enter weight view controller scene. Switch to the Identity inspector, and change its Class setting so that it uses our new UnitSelectorViewController class.
Ideally, we want a segue from our changeUnits: action to the unit selector view. Unfortunately, we cannot draw segues from actions directly. Instead, let’s create a segue that we can manually call from our code.
Control-drag from the enter weight view controller icon to our new scene. In the pop-up window, select modal. This creates a generic modal storyboard segue. This sets the segue between the two scenes. Select the segue and switch to the Attributes inspector. Set the Identifier attribute to Unit Selector Segue, and the Transition attribute to Flip Horizontal. Now we just need to trigger this segue from our changeUnits: action.
Switch back to EnterWeightViewController.m. Let’s start by defining a string constant for our segue’s identifier. Add the following line before the @implementation block:
static NSString* const UNIT_SELECTOR_SEGUE = @"Unit Selector Segue";
Now navigate down to the changeUnits: action. We just need to call our controller’s performSegueWithIdentifier:sender: method.
- (IBAction)changeUnits:(id)sender { [self performSegueWithIdentifier: UNIT_SELECTOR_SEGUE sender:self]; }
This will trigger our segue. Our enter weight view will flip over and reveal the new unit selector view.
So, let’s design that view. Open the storyboard again, and zoom in on our unit selector scene. Select the view and change its Background attribute to View Flipside Background Color. Next, drag a picker view from the Object library and place it at the top of the view. In the Size inspector, make sure it is locked to the left, right, and top and that it scales horizontally.
Next, drag out a button and place it at the bottom of the view. Stretch it so that it fills the view from margin to margin. Its autosizing settings should lock it to the left, bottom, and right, with horizontal scaling enabled. Finally, set its Title attribute to Done.
We ideally want a colored button. The iOS 5 SDK gives us a number of functions for customizing the appearance of our controls. Unfortunately, this doesn’t include setting a button’s background color. There are a number of ways to work around this. For example, many developers create stretchable background images for their buttons. However, this does not give us very much control at runtime. We could create a UIButton subclass and provide custom drawing code—but that’s a lot of work. Instead, we’ll modify the button’s appearance using Core Animation (while also looking at some of the problems with this approach).
Change the button’s Type attribute to Custom, and set the Background attribute to a dark green. I selected Clover from the crayon box color selector. Click the Background attribute. When the pop-up menu appears, select Other. Make sure the crayon box tab is selected, and then choose the Clover crayon (third from the left on the top row).
Finally, set the Text Color attribute to Light Text Color.
The interface should now match Figure 4.15. Everything looks OK—except for the square corners on our Done button. We’ll fix that shortly. In the meantime, let’s set up our outlets and actions.
Figure 4.15 The unit selector view
First, open UnitSelectorViewController.h. This class needs to adopt both the UIPickerViewDelegate and the UIPickerViewDataSource protocols.
@interface UnitSelectorViewController : UIViewController
<UIPickerViewDelegate, UIPickerViewDataSource>
{
}
@end
Then switch back to the storyboard file, and open the Assistant editor. Make sure UnitSelectorViewController.h is showing. Control-drag the picker view to the header file, and create a strong outlet named unitPickerView. Next, Control-drag the button twice. First, create a strong outlet named doneButton. Then create an action named done. Make sure its Event is set to Touch Up Inside.
Now we need to link the picker view to its delegate and data source. Right-click the picker view, and then drag from the pop-up window’s delegate outlet to the view controller icon in the scene’s dock (Figure 4.16). Next, drag the pop-up’s dataSource outlet to the view controller as well.
Figure 4.16 Connecting the delegate
Defining the View Delegate
Switch back to the Standard editor and the UnitSelectorViewController.h file. It should now appear as shown here:
#import <UIKit/UIKit.h> @interface UnitSelectorViewController : UIViewController <UIPickerViewDelegate, UIPickerViewDataSource> @property (strong, nonatomic) IBOutlet UIPickerView *unitPickerView; @property (strong, nonatomic) IBOutlet UIButton *doneButton; - (IBAction)done:(id)sender; @end
We still need to make a few additional changes. Start by importing our WeightEntry class, and add a forward declaration for the UnitSelectorViewControllerDelegate protocol before its @interface block.
#import <UIKit/UIKit.h>#import "WeightEntry.h"
@protocol UnitSelectorViewControllerDelegate;
@interface UnitSelectorViewController : UIViewController <UIPickerViewDelegate, UIPickerViewDataSource> { ...
Now let’s declare two additional properties: one for our delegate, the other for our default unit.
@property (strong, nonatomic) IBOutlet UIButton *doneButton;@property (weak, nonatomic) id<UnitSelectorViewControllerDelegate>
delegate;
@property (assign, nonatomic) WeightUnit defaultUnit;
- (IBAction)done:(id)sender;
Finally, we need to define our protocol. Add the following, after the @interface block:
@protocol UnitSelectorViewControllerDelegate <NSObject> - (void)unitSelectorDone:(UnitSelectorViewController*)controller; - (void)unitSelector:(UnitSelectorViewController*)controller changedUnits:(WeightUnit)unit; @end
That’s it. We’re done with the interface. Now we need to implement these methods.
Implementing the Controller
Open UnitSelectorViewController.m and synthesize the delegate and the defaultUnit.
@synthesize delegate = _delegate; @synthesize defaultUnit = _defaultUnit;
Now we want to automatically select the current default unit when our view loads. To do this, uncomment viewDidLoad and make the following changes:
#pragma mark - View lifecycle - (void)viewDidLoad { [super viewDidLoad];// Set the default units.
[self.unitPickerView selectRow:self.defaultUnit
inComponent:0
animated:NO];
}
Remember, our WeightUnit enum values are assigned sequentially starting with 0. Our picker view is also zero-indexed, with exactly one row for each WeightUnit value. This means we can use WeightUnits and row indexes interchangeably. Each row index maps to a corresponding WeightUnit. Here, we simply select the row that corresponds with the default unit value.
Next, we need to enable autorotation to any orientation. As before, simply have the shouldAutorotateToInterfaceOrientation: method return YES.
- (BOOL)shouldAutorotateToInterfaceOrientation: (UIInterfaceOrientation)interfaceOrientation { return YES; }
Now let’s implement the done: action. We just call the delegate’s unitSelectorDone: method, as shown here:
- (IBAction)done:(id)sender { [self.delegate unitSelectorDone:self]; }
OK, we’re almost done with this controller. We still need to implement the UIPickerViewDataSource methods:
#pragma mark - UIPickerViewDataSource Methods - (NSInteger)numberOfComponentsInPickerView: (UIPickerView *)pickerView { return 1; } - (NSInteger)pickerView:(UIPickerView *)pickerView numberOfRowsInComponent:(NSInteger)component { return 2; }
The numberOfComponentsInPickerView: method simply returns the number of components that our picker view will use. Each component represents a separate settable field. For example, a date picker has three components: month, day, and year. In our case, we only need a single component.
The pickerView:numberOfRowsInComponent: method returns the number of rows (or possible values) for the given component. We know that our picker view only has a single component, so we don’t need to check the component argument. Since we only have two possible values, pounds and kilograms, we can just return 2.
Now let’s look at the delegate methods:
#pragma mark - UIPickerViewDelegate Methods - (NSString *)pickerView:(UIPickerView *)pickerView titleForRow:(NSInteger)row forComponent:(NSInteger)component { return [WeightEntry stringForUnit:row]; } - (void)pickerView:(UIPickerView *)pickerView didSelectRow:(NSInteger)row inComponent:(NSInteger)component { [self.delegate unitSelector:self changedUnits:row]; }
The pickerView:titleForRow:forComponent: method should return the title that will be displayed for the given row and component. In our case, we can map the rows directly to the WeightUnit enum values and simply call stringForUnit: to generate the correct string (@"lbs" or @"kg").
Meanwhile, the pickerView:didSelectRow:inComponent: method is called whenever the user changes the current selection. Here, we simply call the delegate’s unitSelector:changedUnits: method, passing in the row value. Again, our row values correspond directly to the appropriate WeightUnit values.
Passing Data Back and Forth
We still need to pass data into and out of our modal view. Start by opening EntryWeightViewController.h. We need to import UnitSelectorViewController.h and declare that EnterWeightViewController will adopt the UnitSelectorViewControllerDelegate protocol.
#import <UIKit/UIKit.h>#import "UnitSelectorViewController.h"
@class WeightHistory; @interface EnterWeightViewController : UIViewController <UITextFieldDelegate,UnitSelectorViewControllerDelegate
> {
Now switch to the implementation file. We trigger the modal segue in our changeUnits: method—but we cannot set the default unit value there. Our destination view controller may not exist yet. Instead, we wait for the prepareForSegue:sender: method—just as we did in Chapter 3.
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender { if ([segue.identifier isEqualToString:UNIT_SELECTOR_SEGUE]) { UnitSelectorViewController* unitSelectorController = segue.destinationViewController; unitSelectorController.delegate = self; unitSelectorController.defaultUnit = self.weightHistory.defaultUnits; } }
Here, we check the segue’s identifier, just to make sure we have the correct segue. Then we grab a reference to our UnitSelectorViewController, and we set both the delegate and the default unit value.
To get data from our modal view, we simply implement the UnitSeletorViewControllerDelegate methods. Let’s start with unitSelector:changedUnits:.
-(void)unitSelector:(UnitSelectorViewController*) sender changedUnits:(WeightUnit)unit { self.weightHistory.defaultUnits = unit; [self.unitsButton setTitle: [WeightEntry stringForUnit:unit] forState:UIControlStateNormal];}
This method is called whenever the user changes the units in the UnitSelectorViewController. Here, we tell the model to change its default units and then update the title in our unit button. Again, we only update the title for the UIControlStateNormal. All other control states will default back to this setting.
Now let’s look at unitSelectorDone:.
-(void)unitSelectorDone:(UnitSelectorViewController*) sender { [self dismissModalViewControllerAnimated:YES]; }
This method is called when the user presses the UnitSelectorViewController’s Done button. Note that we could have dismissed the modal view within UnitSelectorViewController’s done: method by calling [self.parentViewController dismissModalViewControllerAnimated:YES]. However, the pattern we’re using here is generally best.
Passing control back to the parent view through a delegate method and then letting the parent view dismiss the modal view may take a bit more code, but it also gives us additional flexibility. For example, our parent controller might want to access the delegate view’s properties before dismissing it. Or we may want to perform some postprocessing after dismissing the modal view. We can easily add these features in our delegate method. Additionally, it just feels cleaner. If a class presents a modal view, then it should also dismiss that view. Splitting the presentation and dismissal code into different classes makes everything just a little harder to follow.
And this isn’t an entirely academic argument. As our code is currently written, we will change our default unit value whenever the user changes the value in the picker view. However, this is not necessarily the best approach. We may want to wait until the user presses the Done button, and then set the default unit value based on their final selection. With the delegate methods in place, we can easily change our implementation based on actual performance testing. More importantly, we can change this behavior in our EnterWeightViewController class; we don’t need to touch our modal view at all.
Run the application. You should be able to press the unit button and bring up the unit selector view. Change the units to kilograms and press Done. The button’s title should change from “lbs” to “kg.” There’s only one problem remaining: Our Done button still looks chunky. Let’s fix that.
Rounding Corners with Core Animation
Most of the time when you talk about Core Animation, you’re talking about smoothly moving user interface elements around the screen, having them fade in and out, or flipping them over. Here, however, we will hijack some of the more obscure features of Core Animation to round off our button’s corners, add a border, and layer over a glossy sheen.
As you might guess, Core Animation is a deep and complex subject. We will look at techniques for animating view properties in “Managing Pop-Up Views” in Chapter 8. However, even that only scratches the surface. If you want to get all the gory details, I recommend reading the Core Animation Programming Guide in Apple’s documentation.
First things first, we need to add the QuartzCore framework to our project. Click the blue Health Beat icon to bring up the project settings. Make sure the Health Beat target is selected, and click the Build Phases tab. Next, expand the Link Binary With Libraries build phase, and click the plus button to add another framework. Scroll through the list and add QuartzCore.framework (Figure 4.17).
Figure 4.17 Adding a new framework
Next, open UnitSelectorViewController.m. We need to import the QuartzCore header.
#import <QuartzCore/QuartzCore.h>
Then navigate to the viewDidLoad method. Modify it as shown:
- (void)viewDidLoad { [super viewDidLoad]; // Set the default units. [self.unitPickerView selectRow:self.defaultUnit inComponent:0 animated:NO];//Build our gradient overlays.
CAGradientLayer* topGradient = [[CAGradientLayer alloc] init];
topGradient.name = @"Top Gradient";
// Make it half the height.
CGRect frame = self.doneButton.layer.bounds;
frame.size.height /= 2.0f;
topGradient.frame = frame;
UIColor* topColor = [UIColor colorWithWhite:1.0f alpha:0.75f];
UIColor* bottomColor = [UIColor colorWithWhite:1.0f alpha:0.0f];
topGradient.colors = [NSArray arrayWithObjects:
(__bridge id)topColor.CGColor,
(__bridge id)bottomColor.CGColor, nil];
CAGradientLayer* bottomGradient =
[[CAGradientLayer alloc] init];
bottomGradient.name = @"Bottom Gradient";
// Make it half the size.
frame = self.doneButton.layer.bounds;
frame.size.height /= 2.0f;
// And move it to the bottom.
frame.origin.y = frame.size.height;
bottomGradient.frame = frame;
topColor = [UIColor colorWithWhite:0.0f alpha:0.20f];
bottomColor = [UIColor colorWithWhite:0.0f alpha:0.0f];
bottomGradient.colors = [NSArray arrayWithObjects:
(__bridge id)topColor.CGColor,
(__bridge id)bottomColor.CGColor, nil];
// Round the corners.
[self.doneButton.layer setCornerRadius:8.0f];
// Clip sublayers.
[self.doneButton.layer setMasksToBounds:YES];
// Add a border.
[self.doneButton.layer setBorderWidth:2.0f];
[self.doneButton.layer
setBorderColor:[[UIColor lightTextColor] CGColor]];
// Add the gradient layers.
[self.doneButton.layer addSublayer:topGradient];
[self.doneButton.layer addSublayer:bottomGradient];
}
There’s a lot going on here, so let’s step through it slowly. This code modifies our button’s Core Animation layer. The CALayer is a lightweight object that encapsulates the timing, geometry, and visual properties of a view. In UIKit, a CALayer backs each UIView (and therefore, anything that inherits from UIView). Because of the tight coupling between layers and views, we can easily access and change the visual properties contained in our button’s layer.
We start by creating a CAGradientLayer and giving the layer a name. We will use this name to identify our gradient layer in later methods. Next, we calculate the frame for this layer. We start with the button layer’s bounds, but we divide the height in half. Remember, the frame is the object’s coordinates and size in the containing view or layer’s coordinate system. The bounds represent the object’s coordinates and size in its own coordinate system. In other words, the origin is almost always set to {0.0f, 0.0f} (there are situations where you might use a non-zero origin as an offset, for example when clipping part of an image, but these cases are rare). Using the superlayer’s bounds for the sublayer’s frame means the sublayer will fill the superlayer completely. By dividing the height in half, we end up with a sublayer that will cover just the top half of the main layer.
CAGradientLayers accept an NSArray filled with CGColorRefs. By default, it creates a linear, evenly spaced gradient that transitions from one color to the next. We will just pass in two colors, which will represent the two end points.
Note that an NSArray technically only accepts pointers to Objective-C objects. A CGColorRef is simply a pointer to a CGColor structure—definitely not an Objective-C object. However, we can cast them into id objects to get around the compiler warnings. It’s a bit wacky, but we do what we have to do.
For the curious, this works because NSArray is toll-free bridged with the Foundation CFArray class (under the surface, they are the same objects). While NSArrays are only used to store Objective-C objects, CFArrays can be used to store any arbitrary pointer-sized data. In fact, the CFArrayCreate() method includes parameters that define how the objects are (or are not) retained when placed in the array. When we create an NSArray, we are really creating a CFArray that uses ARC for memory management, which is good. Our CGColors came from an Objective-C method call—so ARC is already managing their memory (see “ARC and Toll-Free Bridging” in Chapter 2 for more information).
As a result, this trick requires considerably less typing than creating a CFArray directly. We do need to use the __bridge annotation to tell ARC that we’re not transferring the references’ ownership, but other than that, memory management works as expected.
In our code, we create two colors. One is white with a 75 percent alpha. The other is completely transparent. We place these into an array and pass the array to the gradient. The CAGradientLayer then makes a smooth, linear transition that slowly fades out as you move down the screen. This adds a highlight to the top of our button.
We do the same thing for the bottomGradient. The only difference is that we increase its origin’s y value to position it on the bottom half of the button. We also use black colors whose alpha values will transition between 20 percent and completely clear. These will slightly darken the bottom half of our control.
Next, we set the corner radius, thus rounding the corners. We then clip our drawing to the area bounded by our rounded corners. This will clip our gradient sublevels as well.
Then we give our button a 2-pixel border, whose color matches the button’s title color. Notice, however, that whereas the button used a UIColor object, the CALayer uses CGColor structures. Again, we can request a CGColor reference from our UIColor object.
Run the application again. Now when you press the unit button, our modal view’s Done button looks all fancy (Figure 4.18).
Figure 4.18 Done button with rounded corners
This works fine in portrait mode, but if you rotate the view, you’ll notice that our gradients don’t stretch to fill the button. The problem is, we cannot automatically resize these layers. Instead, we need to manually resize them whenever the system lays out our views. To do this, implement the view controller’s viewDidLayoutSubviews method.
- (void)viewDidLayoutSubviews { CALayer* layer = self.doneButton.layer; CGFloat width = layer.bounds.size.width; for (CALayer* sublayer in layer.sublayers) { if ([sublayer.name hasSuffix:@"Gradient"]) { CGRect frame = sublayer.frame; frame.size.width = width; sublayer.frame = frame; } } }
This method is called right after the system lays out all our subviews. Here, we just grab a reference to our doneButton’s layer and the layer’s width. We then iterate over all the sublayers. If the sublayer has a name that ends with “Gradient,” we resize it to match the button’s width. This way our custom layers will be resized, but we won’t alter any of the button’s other layers.
Try it out. Switch back and forth between the enter weight and unit select views. Rotate the interface to all the different orientations. If everything is working, commit all our changes. Next stop, the history view.