iOS Programming: The Big Nerd Ranch Guide (2014)

5. Views: Redrawing and UIScrollView

In this chapter, you are going to see how views are redrawn in response to an event. In particular, you will update Hypnosister so that when the user touches the BNRHypnosisView, its circle color will change. A change in color which will require the view to redraw itself. Later in the chapter, you will also add a UIScrollView to Hypnosister’s view hierarchy.

The first step is to declare a property for the color in BNRHypnosisView. In earlier applications, you declared properties in header files. You can also declare properties in class extensions.

Open BNRHypnosisView.m and add the following code near the top of the file.

#import "BNRHypnosisView.h"

@interface BNRHypnosisView ()

@property (strong, nonatomic) UIColor *circleColor;

@end

@implementation BNRHypnosisView

These three lines of code are a class extension with one property declaration. Why is this property declared in a class extension and not in the header file? Hold on to that question, and we will get back to it after you have finished implementing the color change. In the meantime, think ofcircleColor as just another property on BNRHypnosisView.

In BNRHypnosisView.m, update the initWithFrame: method to create a default circleColor for instances of BNRHypnosisView.

- (instancetype)initWithFrame:(CGRect)frame

{

    self = [super initWithFrame:frame];

    if (self) {

        self.backgroundColor = [UIColor clearColor];

        self.circleColor = [UIColor lightGrayColor];

    }

    return self;

}

In drawRect:, modify the message that sets the current stroke color to use circleColor.

// Configure line width to 10 points

path.lineWidth = 10;

[[UIColor lightGrayColor] setStroke];

[self.circleColor setStroke];

// Draw the line!

[path stroke];

You can build and run the application to confirm that it works as before. The next step is to write the code that will update circleColor when the view is touched.

When the user touches a view, the view is sent the message touchesBegan:withEvent:. The touchesBegan:withEvent: method is a touch event handler. You will dive into touch events and touch event handlers in detail in Chapter 12. Right now, you are simply going to overridetouchesBegan:withEvent: to change the circleColor property of the view to a random color.

In BNRHypnosisView.m, override touchesBegan:withEvent: to generate a log message, create a random-colored UIColor, and set circleColor to this color.

// When a finger touches the screen

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event

{

    NSLog(@"%@ was touched", self);

    // Get 3 random numbers between 0 and 1

    float red = (arc4random() % 100) / 100.0;

    float green = (arc4random() % 100) / 100.0;

    float blue = (arc4random() % 100) / 100.0;

    UIColor *randomColor = [UIColor colorWithRed:red

                                           green:green

                                            blue:blue

                                           alpha:1.0];

    self.circleColor = randomColor;

}

Build and run the application and touch anywhere on the view. Your message will appear in the console, but the circle color will not change. Your view is not being redrawn. To understand why and how to fix the problem, you need to know about the run loop.

The Run Loop and Redrawing Views

When an iOS application is launched, it starts a run loop. The run loop’s job is to listen for events, such as a touch. When an event occurs, the run loop then finds the appropriate handler methods for the event. Those handler methods call other methods, which call more methods, and so on. Once all of the methods have completed, control returns to the run loop.

When the run loop regains control, it checks a list of “dirty views” – views that need to be re-rendered based on what happened in the most recent round of event handling. The run loop then sends the drawRect: message to the views in this list before all of the views in the hierarchy are composited together again.

Figure 5.1 shows where redrawing the screen happens in the run loop using an example of the user entering text into a text field.

Figure 5.1  Redrawing views with the run loop

Redrawing views with the run loop

These two optimizations – only re-rendering views that need it and only sending drawRect: once per event – keep iOS interfaces responsive. If iOS applications had to redraw every view every time an event was processed, there would be a lot of time wasted doing unnecessary work. Batching the redrawing of views at the end of a run loop cycle prevents needlessly redrawing a view more than once if more than one of its properties is changed in a single event.

Let’s look at what is happening in Hypnosister. You know that your touch event is being routed correctly to BNRHypnosisView’s touch handler because you see your log message. But when touchesBegan:withEvent: finishes executing and control returns to the run loop, the run loop does not senddrawRect: to the BNRHypnosisView.

To get a view on the list of dirty views, you must send it the message setNeedsDisplay. The subclasses of UIView that are part of the iOS SDK send themselves setNeedsDisplay whenever their content changes. For example, an instance of UILabel will send itself setNeedsDisplay when it is sentsetText:, since changing the text of a label requires the label to re-render its layer. In custom UIView subclasses, like BNRHypnosisView, you must send this message yourself.

In BNRHypnosisView.m, implement a custom accessor for the circleColor property to send setNeedsDisplay to the view whenever this property is changed.

- (void)setCircleColor:(UIColor *)circleColor

{

    _circleColor = circleColor;

    [self setNeedsDisplay];

}

Build and run the application again. Touch the view, and the circle color will change.

(There is another possible optimization when redrawing: you can mark only a portion of a view as needing to be redrawn. This is done by sending setNeedsDisplayInRect: to a view. When drawRect: is sent to the dirty view, the argument to this method that we have been ignoring the whole time will be the rectangle passed to setNeedsDisplayInRect:. Overall, you do not gain that much performance and you end up doing some difficult work to get this partial redrawing behavior to work right, so most people do not bother with it unless their drawing code is obviously slowing the app down.)

Class Extensions

Now let’s return to the circleColor property that you declared in a class extension for BNRHypnosisView. What is the difference between a property declared in a class extension and one declared in a header file? Visibility.

A class’s header file is visible to other classes. That, in fact, is its purpose. A class declares properties and methods in its header file to advertise to other classes how they can interact with the class or its instances.

Not every property or method is for public consumption, however. Properties and methods that are used internally by the class belong in a class extension. The circleColor property is only used by the BNRHypnosisView class. No other class needs to know about this property. Thus, it belongs in the class extension.

Putting properties and methods in a class extension is not being paranoid or overly proprietary. It is good practice to keep your header file as brief as it can be. This makes it easier for others to understand how they can use your class.

Syntactically, a class extension looks a little like a header file. It begins with @interface followed by an empty set of parentheses. The @end marks the end of the class extension. Typically, you put the class extension at the top of the implementation file before the @implementation keyword announces the start of the method definitions.

#import "BNRHypnosisView.h"

@interface BNRHypnosisView ()

@property (strong, nonatomic) UIColor *circleColor;

@end

@implementation BNRHypnosisView

The same visibility rules hold for subclasses. If you were to subclass BNRHypnosisView, the subclass and its instances would not know about circleColor.

If you need limited visibility for certain properties and methods, you can create a class extension in an external file and import it into the implementation files of classes on a need-to-know basis.

We will use class extensions appropriately throughout the book to hide implementation details that do not need to be visible outside of the class.

Using UIScrollView

In this section, you are going to add an instance of UIScrollView to Hypnosister. This scroll view will be a direct subview of the window, and the instance of BNRHypnosisView will be a subview of the scroll view, as shown in Figure 5.2.

Figure 5.2  View hierarchy with UIScrollView

View hierarchy with UIScrollView

Scroll views are typically used for views that are larger than the screen. A scroll view draws a rectangular portion of its subview, and moving your finger, or panning, on the scroll view changes the position of that rectangle on the subview. Thus, you can think of the scroll view as a viewing port that you can move around (Figure 5.3). The size of the scroll view is the size of this viewing port. The size of the area that it can be used to view is the UIScrollView’s contentSize, which is typically the size of the UIScrollView’s subview.

Figure 5.3  UIScrollView and its content area

UIScrollView and its content area

UIScrollView is a subclass of UIView, so it can be initialized using initWithFrame: and it can be added as a subview to another view.

In BNRAppDelegate.m, put a super-sized version of BNRHypnosisView inside a scroll view and add that scroll view to the window:

- (BOOL)application:(UIApplication *)application

    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

{

    self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];

    // Override point for customization after application launch

    CGRect firstFrame = self.window.bounds;

    BNRHypnosisView *firstView = [[BNRHypnosisView alloc] initWithFrame:firstFrame];

    [self.window addSubview:firstView];

    // Create CGRects for frames

    CGRect screenRect = self.window.bounds;

    CGRect bigRect = screenRect;

    bigRect.size.width *= 2.0;

    bigRect.size.height *= 2.0;

    // Create a screen-sized scroll view and add it to the window

    UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:screenRect];

    [self.window addSubview:scrollView];

    // Create a super-sized hypnosis view and add it to the scroll view

    BNRHypnosisView *hypnosisView = [[BNRHypnosisView alloc] initWithFrame:bigRect];

    [scrollView addSubview:hypnosisView];

    // Tell the scroll view how big its content area is

    scrollView.contentSize = bigRect.size;

    self.window.backgroundColor = [UIColor whiteColor];

Build and run your application. You can pan your view up and down, left and right to see more of the super-sized BNRHypnosisView.

Figure 5.4  Top right quadrant of big BNRHypnosisView

Top right quadrant of big BNRHypnosisView

When you go to pan around the BNRHypnosisView, the circle color changes. You cannot pan without beginning a touch, so the run loop sends touch events to the UIScrollView and to the BNRHypnosisView. In Chapter 13, you will see how to recognize and handle a “tap” gesture so that it can be distinguished from a touch or a drag.

“Pinch-to-zoom” is also implemented using UIScrollView. It does not take many lines of code, but it involves a technique that we have not covered yet. So adding pinch-to-zoom to Hypnosister will be a challenge in Chapter 7.

Panning and paging

Another use for a scroll view is panning between a number of view instances.

In BNRAppDelegate.m, shrink the BNRHypnosisView back to the size of the screen and add a second screen-sized BNRHypnosisView as another subview of the UIScrollView. Set the scroll view’s contentSize to be twice as wide as the screen, but the same height.

// Create CGRects for frames

CGRect screenRect = self.window.bounds;

CGRect bigRect = screenRect;

bigRect.size.width *= 2.0;

bigRect.size.height *= 2.0;

// Create a screen-sized scroll view and add it to the window

UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:screenRect];

[self.window addSubview:scrollView];

// Create a super-sized hypnosis view and add it to the scroll view

BNRHypnosisView *hypnosisView = [[BNRHypnosisView alloc] initWithFrame:bigRect];

// Create a screen-sized hypnosis view and add it to the scroll view

BNRHypnosisView *hypnosisView = [[BNRHypnosisView alloc] initWithFrame:screenRect];

[scrollView addSubview:hypnosisView];

// Add a second screen-sized hypnosis view just off screen to the right

screenRect.origin.x += screenRect.size.width;

BNRHypnosisView *anotherView = [[BNRHypnosisView alloc] initWithFrame:screenRect];

[scrollView addSubview:anotherView];

// Tell the scroll view how big its content area is

scrollView.contentSize = bigRect.size;

Build and run the application. Pan from left to right to see each instance of BNRHypnosisView. Notice that you can stop in between the two views.

Figure 5.5  In between the two hypnosis views

In between the two hypnosis views

Sometimes you want this, but other times, you do not. To force the scroll view to snap its viewing port to one of the views, turn on paging for the scroll view in BNRAppDelegate.m.

UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:screenRect];

scrollView.pagingEnabled = YES;

[self.window addSubview:scrollView];

Build and run the application. Pan to the middle of two views and notice how it snaps to one or the other view. Paging works by taking the size of the scroll view’s bounds and dividing up the contentSize it displays into sections of the same size. After the user pans, the view port will scroll to show only one of these sections.