Creating Custom Views and Custom Transitions in iOS Apps
This chapter will focus on customizing the user interface. First, we will look at drawing. By drawing our own views, we can create a wide range of visual effects—and by extension, a wide range of custom controls. Then we will look at setting the transitions between scenes and providing animations for those transitions.
In previous versions of iOS, custom drawing grew to become a large part of many applications. As developers tried to give their applications a unique look and feel or tried to push beyond the boundaries of the UIKit controls, they began to increasingly rely on custom drawing to make their applications stand out.
However, iOS 7 is different. In iOS 7, Apple recommends pushing the application’s user interface into the background. It should, for the most part, disappear, focusing attention on the user’s data rather than drawing attention to itself. As a result, the developers are encouraged to create their own unique look and feel through less visually obvious techniques. In this new paradigm, custom animations may replace custom interfaces as the new distinguishing feature.
Custom Drawing
In previous chapters, we set up the GraphViewController and made sure that it received a copy of our weightHistoryDocument. However, it is still just displaying an empty, white view. Let’s change that. We’re going to create a custom view with custom drawing—actually, to more easily handle the tab bar and status bar, we will be creating two custom views and split our drawing between them.
Let’s start with the background view. This will be the backdrop to our graph. So, let’s make it look like a sheet of graph paper.
Drawing the Background View
Create a new Objective-C class in the Views group. Name it BackgroundView, and make it a subclass of UIView. You should be an expert at creating classes by now, so I won’t walk through all the steps. However, if you need help, just follow the examples from previous chapters.
There are a number of values we will need when drawing our graph grid. For example, how thick are the lines? How much space do we have between lines, and what color are the lines?
We could hard-code all this information into our application; however, I prefer to create properties for these values and then set up default values when the view is instantiated. This gives us a functional view that we can use right out of the box—but also lets us programmatically modify the view, if we want.
Open BackgroundView.h. Add the following properties to the header file:
@property
(assign
,nonatomic
)CGFloat
gridSpacing;@property
(assign
,nonatomic
)CGFloat
gridLineWidth;@property
(assign
,nonatomic
)CGFloat
gridXOffset;@property
(assign
,nonatomic
)CGFloat
gridYOffset;@property
(strong
,nonatomic
)UIColor
*gridLineColor;
Now, open BackgroundView.m. The template automatically creates an initWithFrame: method for us, as well as a commented-out drawRect:. We will use both of these.
Let’s start by setting up our default values. Views can be created in one of two ways. We could programmatically create the view by calling alloc and one of its init methods. Eventually, this will call our initWithFrame: method, since that is our designated initializer. More likely, however, we will load the view from a nib file or storyboard. In this case, initWithFrame: is not called. The system calls initWithCoder: instead.
To be thorough, we should have both an initWithFrame: method and an initWithCoder: method. Furthermore, both of these methods should perform the same basic setup steps. We will do this by moving the real work to a private method that both init methods can then call.
Add the following line to the existing initWithFrame: method:
- (id
)initWithFrame:(CGRect
)frame {self
= [super
initWithFrame
:frame];if
(self
) {[self setDefaults];
}return self
; }
This calls our (as yet undefined) setDefaults method. Now, just below initWithFrame:, create the initWithCoder: method as shown here:
- (id
)initWithCoder:(NSCoder
*)aDecoder {self
= [super
initWithCoder
:aDecoder];if
(self
) { [self
setDefaults]; }return self
; }
We discussed initWithCoder: in the “Secret Life of Nibs” section of Chapter 2, “Our Application’s Architecture.” It is similar to, but separate from, our regular initialization chain. Generally speaking, initWithCoder: is called whenever we load an object from disk, and nibs and storyboards get compiled into binary files, which the system loads at runtime. Not surprising, we will see initWithCoder: again in Chapter 5, “Loading and Saving Data.”
Now, we just need to create our setDefaults method. Create the following code at the bottom of our implementation block:
#pragma mark - Private Methods
- (void
)setDefaults {self
.backgroundColor
= [UIColor
whiteColor
];self
.opaque
=YES
;self
.gridSpacing
=20.0
;if
(self
.contentScaleFactor
==2.0
) {self
.gridLineWidth
=0.5
;self
.gridXOffset
=0.25
;self
.gridYOffset
=0.25
; }else
{self
.gridLineWidth
=1.0
;self
.gridXOffset
=0.5
;self
.gridYOffset
=0.5
; }self
.gridLineColor
= [UIColor
lightGrayColor
]; }
We start by setting our view’s background color to clear. This will override any background color we set in Interface Builder. I often use this trick when working with custom views, since they will not get drawn in Interface Builder. By default, all views appear as a rectangular area on the screen. Often we need to set a temporary background color, since the views otherwise match the parent’s background color or are clear (letting the parent view show through). This, effectively, makes them invisible in Interface Builder.
By setting this property when the view is first loaded, we can freely give it any background color we want in Interface Builder. This make our custom views easier to see while we work on them. Plus, by setting the value when the view is created, we can still override it programmatically in the controller’s viewDidLoad if we want.
Next, we set the view to opaque. This means we promise to fill the entire view from corner to corner with opaque data. In our case, we’re providing a completely opaque background color. If we programmatically change the background color to a translucent color, we should also change the opaque property.
Next, we check the view’s scale factor. The default value we use for our line width, the x-offset and the y-offset, all vary depending on whether we are drawing to a regular or a Retina display.
All the iPhones and iPod touches that support iOS 7 have Retina displays. However, the iPad 2 and the iPad mini still use regular displays—so it’s a good idea to support both resolutions. After all, even if we are creating an iPhone-only app, it may end up running on an iPad.
Additionally, Apple may release new devices that also have non-Retina displays. The iPad mini is a perfect example. It has the same resolution as a regular size iPad 2; it just shrinks the pixel size down by about 80 percent. Also, like the iPad 2, it uses a non-Retina display, presumably to reduce cost and improve performance and energy use.
We want to draw the smallest line possible. This means the line must be 1-pixel wide, not 1-point wide. We, therefore, have to check the scale and calculate our line width appropriately. Furthermore, we also need to make sure our lines are properly aligned with the underlying pixels. That means we have to offset them by half the line’s width (or, in this case, half a pixel).
Now we need to draw our grid. Uncomment the drawRect: method. The system calls this method during each display phase, as discussed in Chapter 3, “Developing Views and View Controllers.” Basically, each time through the run loop, the system will check all the views in the view hierarchy and see whether they need to be redrawn. If they do, it will call their drawRect: method.
Also as mentioned in the previous chapter, our views aggressively cache their content. Therefore, most normal operations (changing their frame, rotating them, covering them up, uncovering them, moving them, hiding them, or even fading them in and out) won’t force the views to be redrawn. Most of the time this means our view will be drawn once when it is first created. It may be redrawn if our system runs low on memory while it’s offscreen. Otherwise, the system will just continue to use the cached version.
Additionally, when the system does ask us to draw or redraw a view, it typically asks us to draw the entire view. The rect passed into drawRect: will be equal to the view’s bounds. The only time this isn’t true is when we start using tiled layers or when we explicitly call setNeedsDisplayInRect: and specify a subsection of our view.
Drawing is expensive. If you’re creating a view that updates frequently, you should try to minimize the amount of drawing your class performs with each update. Write your drawRect: method so that it doesn’t do unnecessary drawing outside the specified rect. You should also use setNeedsDisplayInRect: to redraw the smallest possible portion. However, for Health Beat, our views display largely static data. The graph is updated only when a new entry is added. As a result, we can take the easier approach, disregard the rect argument, and simply redraw the entire view.
Implement drawRect: as shown here:
- (void
)drawRect:(CGRect
)rect {CGFloat
width =CGRectGetWidth
(self
.bounds
);CGFloat
height =CGRectGetHeight
(self
.bounds
);UIBezierPath
*path = [UIBezierPath
bezierPath
]; path.lineWidth
=self
.gridLineWidth
;CGFloat
x =self
.gridXOffset
;while
(x <= width) { [pathmoveToPoint
:CGPointMake
(x,0.0
)]; [pathaddLineToPoint
:CGPointMake
(x, height)]; x +=self
.gridSpacing
; }CGFloat
y =self
.gridYOffset
;while
(y <= height) { [pathmoveToPoint
:CGPointMake
(0.0
, y)]; [pathaddLineToPoint
:CGPointMake
(width, y)]; y +=self
.gridSpacing
; } [self
.gridLineColor setStroke
]; [pathstroke
]; }
We start by using some of the CGGeometry functions to extract the width and height from our view’s bounds. Then we create an empty UIBezierPath.
Bezier paths let us mathematically define shapes. These could be open shapes (basically lines and curves) or closed shapes (squares, circles, rectangles, and so on). We define the path as a series of lines and curved segments. The lines have a simple start and end point. The curves have a start and end point and two control points, which define the shape of the curve. If you’ve ever drawn curved lines in a vector art program, you know what I’m talking about. We can use our UIBezierPath to draw the outline (stroking the path) or to fill in the closed shape (filling the path).
UIBezierPath also has a number of convenience methods to produce ready-made shapes. For example, we can create paths for rectangles, ovals, rounded rectangles, and arcs using these convenience methods.
For our grid, the path will just be a series of straight lines. We start with an empty path and then add each line to the path. Then we draw it to the screen.
Notice, when we create the path, we set the line’s width. Then we create a line every 20 points, both horizontally and vertically. We start by moving the cursor to the beginning of the next line by calling moveToPoint:, and then we add the line to the path by calling addLineToPoint:. Once all the lines have been added, we set the line color by calling UIColor’s setStroke method. Then we draw the lines by calling the path’s stroke method.
We still need to tell our storyboard to use this background view. Open Main.storyboard and zoom in on the Graph View Controller scene. Select the scene’s view object (either in the Document Outline or by clicking the center of the scene), and in the Identity inspector, change Class to BackgroundView.
Just like in Chapter 3, the class should be listed in the drop-down; however, sometimes Xcode doesn’t correctly pick up all our new classes. If this happens to you, you can just type in the name, or you can quit out of Xcode and restart it—forcing it to update the class lists.
Run the application and tap the Graph tab. It should display a nice, graph paper effect (Figure 4.1).
FIGURE 4.1 Our background view
There are a couple of points worth discussing here. First, notice how our drawing stretches up under the status bar. Actually, it’s also stretched down below the tab bar, but the tab bar blurs and fades the image quite a bit, so it’s not obvious.
One of the biggest changes with iOS 7 is the way it handles our bars. In previous versions of iOS, our background view would automatically shrink to fit the area between the bars. In iOS 7, it extends behind the bars by default.
We can change this, if we want. We could set the view controller’s edgesForExtended Layout to UIRectEdgeNone. In our view, this would prevent our background view from extending below the tab bar. It will also make the tab bar opaque. However, this won’t affect the status bar.
We actually want our background view to extend behind our bars. It gives our UI a sense of context. However, we want the actual content to be displayed between the bars. To facilitate this, we will draw our actual graph in a separate view, and we will use auto layout to size that view properly between the top and bottom layout guides.
Also notice, the status bar does not have any background color at all. It is completely transparent. We can set whether it displays its contents using a light or a dark font—but that’s it. If we want to blur or fade out the content behind the bar, we need to provide an additional view to do that for us. However, I will leave that as a homework exercise.
There is one slight problem with our background view, however. Run the app again and try rotating it. When we rotate our view, auto layout will change its frame to fill the screen horizontally. However, as I said earlier, this does not cause our view to redraw. Instead, it simply reuses the cached version, stretching it to fit (Figure 4.2).
FIGURE 4.2 The background view is stretched when rotated.
We could fix this by calling setNeedsDisplay after our view rotates—but there’s an easier way. Open BackgroundView.m again. Find setDefaults, and add the following line after self.opaque = YES:
self
.contentMode
=UIViewContentModeRedraw
;
The content mode is used to determine how a view lays out its content when its bounds change. Here, we are telling our view to automatically redraw itself. We can still move the view, rotate it, fade it in, or cover it up. None of that will cause it to redraw. However, change the size of its frame or bounds, and it will be redrawn to match.
Run the app again. It will now display correctly as you rotate it.
Drawing the Line Graph
Now let’s add the line graph. Let’s start by creating a new UIView subclass named GraphView. Place this in the Views group with our other views.
Before we get to the code, let’s set up everything in Interface Builder. Open Main.storyboard. We should still be zoomed into the Graph View Controller scene.
Drag a new view out and place it in the scene. Shrink it so that it is a small box in the middle of the scene and then change its Class to GraphView and its background to light Gray. This will make it easier to see (Figure 4.3).
FIGURE 4.3 Adding the view
We want to resize it so that it fills the screen from left to right, from the top layout guide to the bottom. However, it will be easier to let Auto Layout do that for us. With the graph view selected, click the Pin tool. We want to use the Add New Constraints controls to set the constraints shown in Table 4.1. Next, set Update Frames to Items of New Constraints, and click the Add 4 Constraints button. The view should expand as desired.
TABLE 4.1 Graph View’s Constraints
Side |
Target |
Constant |
Left |
Background View |
0 |
Top |
Top Layout Guide |
0 |
Right |
Background View |
0 |
Bottom |
Background View* |
49* |
* Ideally, we would like to pin the bottom of our graph view to the Bottom Layout Guide with a spacing constant of 0. This should align the bottom of our graph view with the top of the tab bar. If the tab bar’s height changes or if we decide to remove the tab bar, the Bottom Layout Guide would automatically adjust.However, as of the Xcode 5.0 release, there is a bug in the Bottom Layout Guide. When the view first appears it begins aligned with the bottom of the background view, not the top of the tab bar. If you rotate the view, it adjusts to the correct position. Unfortunately, until this bug is fixed, we must hard-code the proper spacing into our constraints. That is the only way to guarantee that our graph view gets laid out properly.
Run the app and navigate to our graph view. It should fill the screen as shown in Figure 4.4. Rotate the app and make sure it resizes correctly.
FIGURE 4.4 The properly sized graph view
There’s one last step before we get to the code. We will need an outlet to our graph view. Switch to the Assistant editor, Control-drag from the graph view to GraphViewController.m’s class extension, and create the following outlet:
@property
(weak
,nonatomic
)IBOutlet
GraphView *graphView;
Unfortunately, this creates an “Unknown type name ‘GraphView’” error. We can fix this by scrolling to the top of GraphViewController.m and importing GraphView.h.
Now, switch back to the Standard editor, and open GraphView.h. We want to add a number of properties to our class. First, we need an NSArray to contain the weight entries we intend to graph. We also need to add a number of properties to control our graph’s appearance. This will follow the general pattern we used for the background view.
Add the following properties to GraphView’s public header:
// This must be an array sorted by date in ascending order.
@property
(strong
,nonatomic
)NSArray
*weightEntries;@property
(assign
,nonatomic
)CGFloat
margin;@property
(assign
,nonatomic
)CGFloat
guideLineWidth;@property
(assign
,nonatomic
)CGFloat
guideLineYOffset;@property
(strong
,nonatomic
)UIColor
*guideLineColor;@property
(assign
,nonatomic
)CGFloat
graphLineWidth;@property
(assign
,nonatomic
)CGFloat
dotSize;
Notice that we’re assuming our weight entries are sorted in ascending order. When we implement our code, we’ll add a few sanity checks to help validate the incoming data—but we won’t do an item-by-item check of our array. Instead, we will assume that the calling code correctly follows the contract expressed in our comment. If it doesn’t, the results will be unpredictable.
We’ve seen this methodology before. Objective-C often works best when you clearly communicate your intent and assume other developers will follow that intent. Unfortunately, in this case, we don’t have any syntax tricks that we can use to express our intent. We must rely on comments. This is, however, an excellent use case for documentation comments. I’ll leave that as an exercise for the truly dedicated reader.
Switch to GraphView.m. Just like our background view, we want the pair of matching init... methods, with a private setDefaults method. The init... methods are shown here:
- (id
)initWithFrame:(CGRect
)frame {self
= [super
initWithFrame
:frame];if
(self
) { [self
setDefaults
]; }return self
; } - (id
)initWithCoder:(NSCoder
*)aDecoder {self
= [super
initWithCoder
:aDecoder];if
(self
) { [self
setDefaults
]; }return self
; }
And, at the bottom of the implementation block, add the setDefaults method.
#pragma mark - Private Methods
- (void
)setDefaults {self
.contentMode
=UIViewContentModeRedraw
;self
.backgroundColor
= [UIColor
clearColor
];self
.opaque
=NO
;self
.margin
=20.0
;if
(self
.contentScaleFactor
==2.0
) {self
.guideLineYOffset
=0.0
; }else
{self
.guideLineYOffset
=0.5
; }self
.guideLineWidth
=1.0
;self
.guideLineColor
= [UIColor
darkGrayColor
];self
.graphLineWidth
=2.0
;self
.dotSize
=4.0
; }
This code is almost the same as our background view. Yes, the property names and values have changed, but the basic concept is the same. Notice that we are using a clear backgroundColor, letting our background view show through. We have also set the opaque property to YES.
Additionally, since our graph line’s width is an even number of pixels, we don’t need to worry about aligning it to the underlying pixels. If it’s drawn as a vertical or horizontal line, it will already correctly fill 1 pixel above and 1 pixel below the given coordinates (in a non-Retina display). By a similar logic, the 1-point guidelines need to be offset only in non-Retina displays.
The app should successfully build and run. However, notice that our previously gray graph view has completely vanished. Now we just need to add some custom drawing, to give it a bit of content.
For the purpose of this book, we want to keep our graph simple. So, we will just draw three horizontal guidelines with labels and the line graph itself. Also, we’re not trying to match the graph with the background view’s underlying graph paper. In a production app, you would probably want to add guidelines or labels for the horizontal axis, as well as coordinate the background and graph views, giving the graph paper actual meaning.
Passing the Data
The graph will be scaled to vertically fill our space, with the maximum value near the top of our view and the minimum near the bottom. It will also horizontally fill our view, with the earliest dates close to the left side and the latest date close to the right.
But, before we can do this, we need to pass in our weight entry data. Open GraphViewController.m. First things first—we need to import WeightHistoryDocument.h. We already added a forward declaration in the .h file, but we never actually imported the class. It hasn’t been necessary, since we haven’t used it—until now.
Then, add a viewWillAppear: method, as shown here:
- (void
)viewWillAppear:(BOOL
)animated { [super
viewWillAppear
:animated];self
.graphView
.weightEntries
=[[[
self
.weightHistoryDocument allEntries] reverseObjectEnumerator] allObjects]; }
As our GraphView’s comments indicated, we want to pass our weight entries in ascending order. However, our document stores them in descending order. So, we have to reverse this array.
We grab all the entries from our weight history document. We then access the reverse enumerator for these entries. The reverse enumerator lets us iterate over all the objects in the reverse order. Finally we call the enumerator’s allObjects method. This returns the remaining objects in the collection. Since we haven’t enumerated over any objects yet, it returns the entire array. This is a quick technique for reversing any array.
Of course, we could have just passed the entire weight history document to our graph view, but isolating an array of entries has several advantages. First, as a general rule, we don’t want to give any object more access to our model than is absolutely necessary. More pragmatically, writing the code this way gives us a lot of flexibility for future expansions. We could easily modify our code to just graph the entries from last week, last month, or last year. We’d just need to filter our entries before we passed them along.
Unfortunately, there is one small problem with this approach. WeightHistoryDocument doesn’t have an allEntries method. Let’s fix that. Open WeightHistoryDocument.h and declare allEntries just below weightEntriesAfter:before:.
- (NSArray
*)allEntries;
Now switch to WeightHistoryDocument.m. Implement allEntries as shown here:
- (NSArray
*)allEntries {return
[self
.weightHistory copy
]; }
Here, we’re making a copy of our history array and returning the copy. This avoids exposing our internal data, and prevents other parts of our application from accidentally altering it.
Now, switch back to GraphView.m. There are a number of private properties we want to add. Some of these will hold statistical data about our entries. Others will hold layout data—letting us easily calculate these values once and then use them across a number of different drawing methods.
At the top of GraphView.m, import WeightEntry.h, and then add a class extension as shown here:
#import
“WeightEntry.h”
@interface
GraphView
()@property
(assign
,nonatomic
)float
minimumWeight;@property
(assign
,nonatomic
)float
maximumWeight;@property
(assign
,nonatomic
)float
averageWeight;@property
(strong
,nonatomic
)NSDate
*earliestDate;@property
(strong
,nonatomic
)NSDate
*latestDate;@property
(assign
,nonatomic
)CGFloat
topY;@property
(assign
,nonatomic
)CGFloat
bottomY;@property
(assign
,nonatomic
)CGFloat
minX;@property
(assign
,nonatomic
)CGFloat
maxX;@end
Now, let’s add a custom setter for our weightEntries property. This will set the statistical data any time our entries change.
#pragma mark - Accessor Methods
- (void
)setWeightEntries:(NSArray
*)weightEntries {_weightEntries
= weightEntries;if
([_weightEntries count
] >0
) {self
.minimumWeight
= [[weightEntriesvalueForKeyPath
:@”@min.weightInLbs”
]floatValue
];self
.maximumWeight
= [[weightEntriesvalueForKeyPath
:@”@max.weightInLbs”
]floatValue
];self
.averageWeight
= [[weightEntriesvalueForKeyPath
:@”@avg.weightInLbs”
]floatValue
];self
.earliestDate
= [weightEntriesvalueForKeyPath
:@”@min.date”
];self
.latestDate
= [weightEntriesvalueForKeyPath
:@”@max.date”
];NSAssert
([self
.latestDate isEqualToDate
: [[self
.weightEntries lastObject
]date
]],@”The weight entry array must be “
@”in ascending chronological order”
);NSAssert
([self
.earliestDate isEqualToDate
: [self
.weightEntries
[0
]date
]],@”The weight entry array must be “
@”in ascending chronological order”
); }else
{self
.minimumWeight
=0.0
;self
.maximumWeight
=0.0
;self
.averageWeight
=0.0
;self
.earliestDate
=nil
;self
.latestDate
=nil
; } [self
setNeedsDisplay
]; }
Ideally, nothing in this method comes as a surprise. We assign the incoming value to our property. Then we check to see whether our new array has any elements. If it does, we set up our statistical data. Here, we’re using the KVC operators we used in the “Updating Our View” section of Chapter 3. Notice that we can use them for both the weight and the date properties. @min.date gives us the earliest date in the array, while @max.date gives us the latest. We also check the first and last object and make sure the first object is our earliest date and the last object is our latest date. This acts as a quick sanity check. Obviously it won’t catch every possible mistake—but it is fast, and it will catch most obvious problems (like forgetting to reverse our array). As a bonus, it works even if our array has only one entry.
If our array is empty (or has been set to nil), we simply clear out the stats data.
Now, let’s draw the graph. Drawing code can get very complex, so we’re going to split it into a number of helper methods.
Start by adding methods to calculate our graph size. We’ll be storing these values in instance variables. I’m always a bit uncomfortable when I store temporary data in an instance variable. I like to clear it out as soon as I’m done—preventing me from accidentally misusing the data when it’s no longer valid. So, let’s make a method to clean up our data as well.
Add these private methods to the bottom of our implementation block:
- (void
)calculateGraphSize {CGRect
innerBounds =
CGRectInset
(self
.bounds
,self
.margin
,self
.margin
);self
.topY
=CGRectGetMinY
(innerBounds) +self
.margin
;self
.bottomY
=CGRectGetMaxY
(innerBounds);self
.minX
=CGRectGetMinX
(innerBounds);self
.maxX
=CGRectGetMaxX
(innerBounds); } - (void
)clearGraphSize {self
.topY
=0.0
;self
.bottomY
=0.0
;self
.minX
=0.0
;self
.maxX
=0.0
; }
The calculateGraphSize shows off more of the CGGeometry functions. Here, we use CGRectInset to take a rectangle and shrink it. Each side will be moved in by our margin. Given our default values, this will create a new rectangle that is centered on the old one but is 40 points shorter and 40 points thinner.
You can also use this method to make rectangles larger, by using negative inset values.
Then we use the CGRectGet... methods to pull out various bits of data. The topY value deserves special attention. We want the top margin to be twice as big as the bottom, left, and right. Basically, we want all of our content to fit inside the innerBounds rectangle. We will use the topY and bottomY to draw our minimum and maximum weight guidelines. However, the top guideline needs a little extra room for its label.
The clearGraphSize method is much simpler; we just set everything to 0.0.
Now, let’s add a private drawGraph method.
- (void
)drawGraph {// if we don’t have any entries, we’re done
if
([self
.weightEntries count
] ==0
)return
;UIBezierPath
*barGraph = [UIBezierPath
bezierPath
]; barGraph.lineWidth
=self
.graphLineWidth
;BOOL
firstEntry =YES
;for
(WeightEntry
*entryin self
.weightEntries
) {CGPoint
point = CGPointMake([self
calculateXForDate:entry.date], [self
calculateYForWeight:entry.weightInLbs]);if
(firstEntry) { [barGraphmoveToPoint
:point]; firstEntry =NO
; }else
{ [barGraphaddLineToPoint
:point]; }if
(entry.weightInLbs
==self
.maximumWeight
) { [self
drawDotForEntry:entry]; }if
(entry.weightInLbs
==self
.minimumWeight
) { [self
drawDotForEntry:entry]; } } [self
.tintColor
setStroke
]; [barGraphstroke
]; }
We start by checking to see whether we have any entries. If we don’t have any entries, we’re done. There’s no graph to draw.
If we do have entries, we create an empty Bezier path to hold our line graph. We iterate over all the entries in our array. We calculate the correct x- and y-coordinates for each entry, based on their weight and date. Then, for the first entry we move the cursor to the correct coordinates. For every other entry, we add a line from the previous coordinates to our new coordinates. If our entry is our minimum or maximum value, we draw a dot at that location as well. Finally we draw the line. This time, we’re using our view’s tintColor. We discussed tintColor in the “Customizing Our Appearance” section of Chapter 3. Basically, graphView’s tintColor will default to our window’s tint color—but we could programmatically customize this view by giving it a different tintColor.
Let’s add the methods to calculate our x- and y-coordinates. Start with the x-coordinate.
- (CGFloat
) calculateXForDate:(NSDate
*)date {NSAssert
([self
.weightEntries count
] >0
,@”You must have more than one entry “
@”before you call this method”
);if
([self
.earliestDate compare
:self
.latestDate
] ==NSOrderedSame
) {return
(self
.maxX
+self
.minX
) / (CGFloat
)2.0
; }NSTimeInterval
max = [self
.latestDate timeIntervalSinceDate
:self
.earliestDate
];NSTimeInterval
interval = [datetimeIntervalSinceDate
:self
.earliestDate
];CGFloat
width =self
.maxX
-self
.minX
;CGFloat
percent = (CGFloat
)(interval / max);return
percent * width +self
.minX
; }
We start with a quick sanity check. This method should never be called if we don’t have any entries. Then we check to see whether our earliest date and our latest date are the same. If they are (for example, if we have only one date), they should be centered in our graph. Otherwise, we need to convert our dates into numbers that we can perform mathematical operations on. The easiest way to do this is using the timeIntervalSinceDate: method. This will return a double containing the number of seconds between the current date and the specified dates.
We can then use these values to calculate each date’s relative position between our earliest and latest dates. We calculate a percentage from 0.0 (the earliest date) to 1.0 (the latest date). Then we use that percentage to determine our x-coordinate.
Note that our literal floating-point values and our time intervals are doubles. If we compile this on 32-bit, we want to convert them down to floats. If we compile on 64-bit, we want to leave them alone. We can accomplish this by casting them to CGFloat—since its size changes with the target platform.
Now, let’s calculate the y-values.
- (CGFloat
)calculateYForWeight:(CGFloat
)weight {NSAssert
([self
.weightEntries count
] >0
,@”You must have more than one entry “
@”before you call this method”);
if
(self
.minimumWeight
==self
.maximumWeight
) {return
(self
.bottomY
+self
.topY
) / (CGFloat
)2.0
; }CGFloat
height =self
.bottomY
-self
.topY
;CGFloat
percent = (CGFloat
)1.0
- (weight -self
.minimumWeight
) / (self
.maximumWeight
-self
.minimumWeight
);return
height * percent +self
.topY
; }
The Y value calculations are similar. On the one hand, the code is a little simpler, since we can do mathematical operations on the weight values directly. Notice, however, that the math to calculate our percentage is much more complicated. There are two reasons for this. First, in our dates, the earliest date will always have a value of 0.0. With our weights, this is not true. We need to adjust for our minimum weight. Second, remember that our y-coordinates increase as you move down the screen. This means our maximum weight must have the smallest y-coordinate, while our minimum weight has the largest. We do this by inverting our percentage, subtracting the value we calculate from 1.0.
Now, let’s add the method to draw our dots.
- (void
)drawDotForEntry:(WeightEntry
*)entry {CGFloat
x = [self
calculateXForDate
:entry.date
];CGFloat
y = [self
calculateYForWeight
:entry.weightInLbs
];CGRect
boundingBox =CGRectMake
(x - (self
.dotSize
/ (CGFloat
)2.0
), y - (self
.dotSize
/ (CGFloat
)2.0
),self
.dotSize
,self
.dotSize
);UIBezierPath
*dot = [UIBezierPath
bezierPathWithOvalInRect
:boundingBox]; [self
.tintColor
setFill
]; [dotfill
]; }
Here we get the x- and y-coordinates for our entry and calculate a bounding box for our dot. Our bounding box’s height and width are equal to our dotSize property, and it is centered on our x- and y-coordinates. We then call bezierPathWithOvalInRect: to calculate a circular Bezier path that will fit inside this bounding box. Then we draw the dot, using our view’s tint color as the fill color.
Now, we just need to call these methods. Replace the commented out drawRect: with the version shown here:
- (void
)drawRect:(CGRect
)rect { [self
calculateGraphSize
]; [self
drawGraph
]; [self
clearGraphSize
]; }
Build and run the application. When it has zero entries, the graph view is blank. If you add one entry, it will appear as a dot, roughly in the center of the screen. Add two or more entries, and it will draw the graph’s line (Figure 4.5). Rotate it, and the graph will be redrawn in the new orientation.
FIGURE 4.5 A graph with four entries
Now we just need to add our guidelines. Let’s start by making a private method to draw a guideline.
- (void
)drawGuidelineAtY:(CGFloat
)y withLabelText:(NSString
*)text {UIFont
*font =[
UIFont
preferredFontForTextStyle
:UIFontTextStyleCaption1
];NSDictionary
*textAttributes =@
{NSFontAttributeName
:font,NSForegroundColorAttributeName
:self
.guideLineColor
}
;CGSize
textSize = [textsizeWithAttributes
:textAttributes];CGRect
textRect =CGRectMake
(self
.minX
, y - textSize.height
-1
, textSize.width
, textSize.height
); textRect =CGRectIntegral
(textRect);UIBezierPath
*textbox = [UIBezierPath
bezierPathWithRect
:textRect]; [self
.superview
.backgroundColor
setFill
]; [textboxfill
]; [textdrawInRect
:textRectwithAttributes
:textAttributes];CGFloat
pattern[] = {5
,2
};UIBezierPath
*line = [UIBezierPath
bezierPath
]; line.lineWidth
=self
.guideLineWidth
; [linesetLineDash
:patterncount
:2
phase
:0
]; [linemoveToPoint
:CGPointMake
(CGRectGetMinX
(self
.bounds
), y)]; [lineaddLineToPoint
:CGPointMake
(CGRectGetMaxX
(self
.bounds
), y)]; [self
.guideLineColor setStroke
];; [linestroke
]; }
We start by getting the Dynamic Type font for the Caption 1 style. Then we create a dictionary of text attributes. These attributes are defined in NSAttributedString UIKit Addons Reference. They can be used for formatting parts of an attributed string; however, we will be using them to format our entire string. We’re setting the font to our Dynamic Type font and setting the text color to the guideline color.
Once that’s done, we use these attributes to calculate the size needed to draw our text. Then we create a rectangle big enough to hold the text and whose bottom edge is 1 point above the provided y-coordinate. Notice that we use CGRectIntegral() on our rectangle. This will return the smallest integral-valued rectangle that contains the source rectangle.
The sizeWithAttributes: method typically returns sizes that include fractions of a pixel. However, we want to make sure our rectangle is properly aligned with the underlying pixels. Unlike the custom line drawing, when working with text, we should not offset our rectangle—the text-rendering engine will handle that for us.
We first use the textRect to draw a box using our BackgroundView’s background color. This will create an apparently blank space for our label, making it easier to read. Then we draw our text into the same rectangle.
Next, we define our line. Again, we start with an empty path. We set the line’s width, and we set the dash pattern. Here, we’re using a C array. This is one of the few places where you’ll see a C array in Objective-C. Our dashed line pattern will draw a segment 5 points long, followed by a 2-point gap. It then repeats this pattern.
We draw this line from the left edge of our view to the right edge of our view along the provided y-coordinate. This means our line will lay just below the bottom edge of our text’s bounding box. Finally, we set the line color and draw the line.
Now we need to create our guidelines. We will have three: one for the max weight, one for the minimum weight, and one for the average weight. Add the following method to our implementation file:
- (void
) drawLabelsAndGuides {if
([self
.weightEntries count
] ==0
)return
;WeightUnit
unit =getDefaultUnits
();if
(self
.minimumWeight
==self
.maximumWeight
) {float
weight = [self
.weightEntries
[0
]weightInLbs
];NSString
* weightLabel = [NSString
stringWithFormat
:@”Weight: %@”
, [WeightEntry
stringForWeightInLbs
:weightinUnit:unit
]]; [self
drawGuidelineAtY
:[self
calculateYForWeight
:weight]withLabelText
:weightLabel]; }else
{NSString
*minLabel = [NSString
stringWithFormat
:@”Min: %@”
, [WeightEntry
stringForWeightInLbs
:self
.minimumWeight
inUnit
:unit]];NSString
*maxLabel = [NSString
stringWithFormat
:@”Max: %@”
, [WeightEntry
stringForWeightInLbs
:self
.maximumWeight
inUnit
:unit]];NSString
*averageLabel = [NSString
stringWithFormat
:@”Average: %@”
, [WeightEntry
stringForWeightInLbs
:self
.averageWeight
inUnit
:unit]]; [self
drawGuidelineAtY
:self
.topY
withLabelText
:maxLabel]; [self
drawGuidelineAtY
:self
.bottomY
withLabelText
:minLabel]; [self
drawGuidelineAtY
: [self
calculateYForWeight
:self
.averageWeight
]withLabelText:averageLabel
]; } }
In this method, we simply check to see whether we don’t have any entries. If there are no entries, then we don’t need to draw any guidelines, so we just return. Next, we check to see whether all of our entries have the same weight (either because we have only one entry or because the user entered the same weight for all entries). If we have only one weight value, we create a single weight label and draw our line at the calculated y-coordinate (which should be the vertical center of the graph). Our line should match up with the dot or line that we drew in our drawGraph method.
If we have more than one weight value, we create a minimum, maximum, and average strings and then draw lines corresponding to the appropriate values.
Now, add this method to drawRect: as shown here:
- (void
)drawRect:(CGRect
)rect { [self
calculateGraphSize
]; [self
drawGraph
];[self drawLabelsAndGuides];
[self
clearGraphSize
]; }
Run the application. With no entries, the graph view is still blank. Add a single entry. This will appear in roughly the middle of the screen, with a guideline running right through our dot. Add multiple lines. You should see the Max, Average, and Min guidelines (Figure 4.6).
FIGURE 4.6 Our completed graph view
Our graph is almost done. We just need to update the fonts when the user changes them. We will use the same basic procedure we used in the previous chapter for our history and entry detail views.
Open GraphViewController.m and import UIViewController+HBTUpdates.h. Now we need to override our HBT_updateFonts method as shown here:
- (void
)HBT_updateFonts { [super
HBT_updateFonts
]; [self
.graphView setNeedsDisplay
]; }
If the user changes the font size, this method will mark our graph view as needing to be redrawn. The next time through the run loop, the system will redraw the graph, picking up the new fonts in the process.
Run the app. It will now update correctly when the user changes the font size.
Now, as you can see, there’s a lot of room for improvement in this graph. Ideally, however, you’ve learned a lot along the way. We’ve covered drawing lines (the grid, graph, and guide lines), shapes (the dots), and text. There are similar methods for drawing images as well—though, it is often easier to just insert a UIImageView into your application. The UIImageView’s drawing code is highly optimized, and you’re unlikely to match its performance.
You should be able to use these, and the other UIKit drawing methods, to create your own custom views. However, it doesn’t end here. There are a couple of other graphic techniques worth considering.
Other Graphics Techniques
Under the hood, all of the UIKit drawing methods are actually calling Core Graphics methods. Core Graphics is a low-level C-based library for high-performance drawing. While, most of the time, we can do everything we want at the UIKit level—if you need special features (e.g., gradients or shadows), you will need to switch to Core Graphics.
All core graphics functions take a CGContextRef as their first argument. The context is an opaque type that represents the drawing environment. Our UIKit methods also draw into a context—however, the context is hidden from us by default. We can access it, if we need, by calling UIGraphicsGetCurrentContext(). This lets us freely mix UIKit and Core Graphics drawing code.
In the drawRect: method, our code is drawn into a context that is then displayed on the screen, we can also create graphics contexts to produce JPEG or PNG images or PDFs. We may even use graphics contexts when printing.
However, custom drawing isn’t the only way to produce a custom user interface. We can also drop bitmap images into our layout. There are two main techniques for adding bitmaps. If it’s simply artwork, we can just place a UIImageView in our view hierarchy and set its image property. If, however, our image needs to respond to touches, we can place a custom UIButton and then call its backgroundImageForState: method (or set the background image in Interface Builder).
Bitmaps are often easier than custom drawing. You simply ask your graphic designer to create them for you. You place them in the assets catalog, and you drop them into your interface.
However, custom drawn interfaces have a number of advantages. The biggest is their size. Custom drawn views require considerably less memory than bitmaps. The custom drawn views can also be redrawn to any arbitrary size. If we want to change the size of a bitmapped image, we usually need to get our graphic designer to make a new one. Finally, custom drawn views can be dynamically generated at runtime. Bitmaps can only really present static, unchanging pieces of art.
Often, however, we can get many of the advantages of bitmaps while sidestepping their weaknesses by using resizeable images or image patterns. For more information, check out -[UIImage resizableImageWithCapInsets:], +[UIColor colorWithPatternImage:}, and the Asset Catalog Help.
Updating Our Drawing When the Data Changes
There’s one last modification to our graph view. We need to make sure our graph is updated whenever our data changes. This isn’t so important now, but it will become extremely important once we implement iCloud syncing.
To do this, we need to listen for our model’s notifications. However, unlike our history view, we don’t need to track each change individually. Instead, we will update our entire graph once all the changes are complete. This means we need to track only a single notification.
Open GraphViewController.m. In the class extension, add the following property:
@property
(strong
,nonatomic
)id
modelChangesCompleteObserver;
Now, add a dealloc method just below initWithNibName:bundle:.
- (void
)dealloc {NSNotificationCenter
*center = [NSNotificationCenter
defaultCenter
];if
(self
.modelChangesCompleteObserver
) { [centerremoveObserver
:self
.modelChangesCompleteObserver
]; } }
Next, let’s set up our observer. Since there’s only one and the code is so simple, let’s just put this in viewDidLoad:.
- (void
)viewDidLoad { [super
viewDidLoad
];self
.tabBarItem
.selectedImage
=[
UIImage
imageNamed
:@”graph_selected”
];NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
NSOperationQueue *mainQueue = [NSOperationQueue mainQueue];
__weak GraphViewController *_self = self;
self.modelChangesCompleteObserver =
[center
addObserverForName:WeightHistoryDocumentChangesCompleteNotification
object:self.weightHistoryDocument
queue:mainQueue
usingBlock:^(NSNotification *note) {
_self.graphView.weightEntries =
[[[_self.weightHistoryDocument allEntries]
reverseObjectEnumerator] allObjects];
}];
}
This just resets our graph view’s weight entry array whenever our document changes. Unfortunately, there’s no way to test this code yet. We will get a chance to test it later this chapter, however.